pax_global_header00006660000000000000000000000064141757332550014526gustar00rootroot0000000000000052 comment=476f6abc4362ae193e4fada7356a7639c9985912 svgelements-1.6.8/000077500000000000000000000000001417573325500140765ustar00rootroot00000000000000svgelements-1.6.8/.github/000077500000000000000000000000001417573325500154365ustar00rootroot00000000000000svgelements-1.6.8/.github/labeler.yml000066400000000000000000000003001417573325500175600ustar00rootroot00000000000000# Format at https://github.com/actions/labeler Config: - '*' - '.*/*' - '.*/**/*' Doc: - '**/*.md' Base: - 'svgelements/*.py' - 'svgelements/**/*.py' Test: - 'test/*.py' - 'test/**/*.py' svgelements-1.6.8/.github/workflows/000077500000000000000000000000001417573325500174735ustar00rootroot00000000000000svgelements-1.6.8/.github/workflows/codeql-analysis.yml000066400000000000000000000030601417573325500233050ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [master] pull_request: # The branches below must be a subset of the branches above branches: [master] schedule: - cron: '0 23 * * 5' concurrency: group: codeql-${{ github.ref }} cancel-in-progress: true jobs: analyze: name: CodeQL runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: python # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) #- name: Autobuild # uses: github/codeql-action/autobuild@v1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1svgelements-1.6.8/.github/workflows/pull-request-labeler.yml000066400000000000000000000004641417573325500242700ustar00rootroot00000000000000# Add labels to Pull-Requests based on the types of files modified # https://github.com/actions/labeler name: "Pull-Request labeler" on: - pull_request_target jobs: labeler: runs-on: ubuntu-latest steps: - uses: actions/labeler@v3 with: repo-token: "${{ secrets.GITHUB_TOKEN }}"svgelements-1.6.8/.github/workflows/python-code-quality.yml000066400000000000000000000054011417573325500241350ustar00rootroot00000000000000name: Python code quality on: push: branches: [master] pull_request: branches: [master] schedule: - cron: '0 22 * * 5' concurrency: group: codequality-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: analyze: name: Lint ${{ matrix.os }} / ${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-18.04] python-version: [3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Get full Python version id: full-python-version run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") - name: Python Cache - ${{ matrix.os }}-python-${{ steps.full-python-version.outputs.version }} uses: actions/cache@v2 with: path: ${{ env.pythonLocation }} key: ${{ matrix.os }}-python-${{ steps.full-python-version.outputs.version }} - name: Upgrade Python tools run: | python -m pip install --upgrade --upgrade-strategy eager pip setuptools wheel babel - name: Install Lint tools run: | pip install --upgrade --upgrade-strategy eager pylint pyproject-flake8 flake8-deprecated black mypy isort - name: Install Python dependencies run: | pip install --upgrade --upgrade-strategy eager pillow scipy numpy - name: List Python environment run: | pip list - name: Create output directory run: mkdir -p artifacts - name: Analyse with pylint id: pylint continue-on-error: true run: | pylint -j 0 --output-format=text:artifacts/pylint.txt,colorized svgelements - name: Analyse with flake8 id: flake8 continue-on-error: true run: | pflake8 svgelements --tee --output-file=artifacts/flake8.txt - name: Analyse with black id: black continue-on-error: true run: | black --diff --color svgelements - name: Analyse with isort id: isort continue-on-error: true run: | isort svgelements -c --diff - name: Analyse with mypy id: mypy continue-on-error: true run: | mypy svgelements - name: Upload analysis results uses: actions/upload-artifact@v2 with: name: Artifacts path: artifacts/ - name: "Set status" #if: ${{ failure() }} run: | if ${{ steps.pylint.outcome != 'success' || steps.flake8.outcome != 'success' || steps.black.outcome != 'success' || steps.isort.outcome != 'success' }}; then exit 0 exit 1 fi svgelements-1.6.8/.github/workflows/unittests.yml000066400000000000000000000037531417573325500222700ustar00rootroot00000000000000name: Unittest on: push: branches: [master] pull_request: branches: [master] schedule: - cron: '0 23 * * 5' concurrency: group: unittests-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: unittests: name: ${{ matrix.os }}+py${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-18.04, windows-2019, macos-10.15] python-version: [3.9] experimental: [false] include: - os: ubuntu-18.04 python-version: 3.6 experimental: false #- os: ubuntu-18.04 # python-version: '3.10.0-alpha - 3.10.9' # experimental: true - os: macos-11 python-version: 3.9 experimental: false steps: - name: Checkout ${{ github.ref }} uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Get detailed Python version id: full-python-version run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") - name: Python Cache - ${{ matrix.os }}-python-${{ steps.full-python-version.outputs.version }} uses: actions/cache@v2 with: path: ${{ env.pythonLocation }} key: ${{ matrix.os }}-python-${{ steps.full-python-version.outputs.version }} - name: Upgrade Python tools run: | python -m pip install --upgrade --upgrade-strategy eager pip setuptools wheel babel - name: Install Python dependencies run: | pip install --upgrade --upgrade-strategy eager pillow scipy numpy - name: List Python environment run: | pip list - name: Run Unittests run: | python -m unittest discover test -v if ${{ matrix.experimental }} == true; then exit 0 fi svgelements-1.6.8/.gitignore000066400000000000000000000022631417573325500160710ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ svgelements-1.6.8/LICENSE000066400000000000000000000020511417573325500151010ustar00rootroot00000000000000MIT License Copyright (c) 2019 meerk40t Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. svgelements-1.6.8/MANIFEST.in000066400000000000000000000000331417573325500156300ustar00rootroot00000000000000include *.md include *.txt svgelements-1.6.8/README.md000066400000000000000000001202311417573325500153540ustar00rootroot00000000000000# svgelements `svgelements` does high fidelity SVG parsing and geometric rendering. The goal is to successfully and correctly process SVG for use with any scripts that may need or want to use SVG files as geometric data. This is both facilitated by, and results in, very useful elements within the SVG spec: Path, Matrix, Angle, Length, Color, Point and other SVG and CSS Elements. The SVG spec defines a variety of elements which generally interoperate. In order to have a robust experience with SVGs we must be able to correctly deal with the parsing and interactions of these elements. This project began as part of `meerK40t` which does SVG loading of files for laser cutting. It attempts to more fully map out the SVG specification, objects, and paths, while remaining easy to use and largely backwards compatible. These elements are quite useful in their own right. For example, the zooming and panning within `meerK40t` is done using the SVG matrix which more robust than the wxPython one. Internal console commands within `meerK40t` allows specifying robustly parsed angles of rotation, colors of objects, and naively uses the Path() and SVGImage objects. The ability to have these robustly manipulated with affine transformations provides considerable utility. There is significant utility in the interactions between these objects, however if you just want to robustly parse some SVG and convert the data to your own structures that is entirely reasonable. Without robust SVG parsing you'll find repeated edge cases of some svg files that do not parse correctly. `svgelements` aims to avoid those pitfalls with robust adherence to the SVG spec. # License This module is under a MIT License. https://github.com/meerk40t/svgelements/blob/master/LICENSE # Installing `pip install svgelements` Then in a script: `from svgelements import *` # Requirements None. However, there are some soft dependencies, with some common additions do modify the functionality slightly. If `scipy` is installed then the arc length code quickly provide the exact correct answer. Some of the SVGImage code is able to load the images if given access to PIL/Pillow. And if `numpy` exists there's a special `npoint()` command to do lightning fast linearization for Shapes. # Compatibility `svgelements` is compatible with Python 3+. Support for 2.7 was dropped at Python 2 End-Of-Life January 1, 2020. We remain nominally backwards compatible with `svg.path`, passing the same robust tests from that project. There may be number of breaking changes. However, since `svgelements` permit a lot of leeway in what is accepted and how it's accepted it will have a huge degree of compatibility with projects seen and unseen. # Philosophy The goal of this project is to provide SVG spec-like elements and structures. Conforming to the SVG standard 1.1 and elements of 2.0. These provide much of the implementation decisions, with regard to the implementation of the objects. If there is a question on implementation and the SVG documentation has a methodology, that is the preferred methodology. If the SVG spec says one thing, and `svgelements` does something else, that is a bug. The primary goal of this project is to make a more robust version of `svg.path` to fully parse SVG files. This requires including other elements like `Point`, `Matrix`, and `Color`, etc. with clear emphasis on conforming to the SVG spec in all ways that realworld uses for SVG demands. `svgelements` should conform to the SVG Conforming Interpreter class (2.5.4. Conforming SVG Interpreters): >An SVG interpreter is a program which can parse and process SVG document fragments. Examples of SVG interpreters are server-side transcoding tools or optimizer (e.g., a tool which converts SVG content into modified SVG content) or analysis tools (e.g., a tool which extracts the text content from SVG content, or a validity checker). Real world functionality demands we must correctly and reasonably provide reading, transcoding, and manipulation of SVG content. The svgelements code should not include any hard dependencies. It should remain a single file with emphasis on allowing projects to merely include a copy of `svgelements.py` to do any SVG parsing required. # Features Supported SVG is a huge spec and bleeds into a lot of areas. Many of these are supported some are not. ## Supported * Robust SVG parsing. * SVG/CSS Lengths: `px`, `pt`, `pc`, `cm`, `mm`, `in`, `%` * SVG/CSS Color: keyword, `#rrggbb`, `#rgb`, `rgb(r,g,b)`, `rgb(r%,g%,b%)`, `rgba(r,g,b,a)`, `hsl(hue, s, l)` * SVG/CSS Matrix * Full matrix support. All objects have a `.transform` object with all cascading matrix operations. Including viewport. * SVG Viewport. - Correctly processes viewports, including `preserveAspectRatio`. * CSS Angle - `deg`, `rad`, `grad`, `turn`. Full use of these within `rotate()` transformation command. * SVG Shape: Rect - full parsing of `x`, `y`, `rx`, `ry`, `width`, `height`, presentation attributes in length/percent form. * SVG Shape: Circle - full parsing of `cx`, `cy`, `r`, presentation attributes in length/percent form. * SVG Shape: Ellipse - full parsing of `cx`, `cy`, `rx`, `ry`, presentation attributes in length/percent form. * SVG Shape: Polygon - full parsing of `.points` * SVG Shape: Polyline - full parsing of `.points` * SVG Shape: Line - full parsing of `x0`, `y0`, `x1`, `y1` presentation attributes in length/percent form. * (Internally this is called `SimpleLine` since `Line` is a `PathSegment` type.) * SVG Shape: Path - Perfect path_d parsing. Relative/Absolute, Smooth BezierCurve, preservation of original segment form. * PathSegments with advanced geometric functions. eg. `point()`, `npoint()`, `bbox()` `reverse()` * Transformation of Shapes and Paths, within groups as well as with respect to the SVG Viewport. * First order use of `.stroke`, `.fill` and `.stroke_width` for all Shapes. `Stroke` and `Fill` are colors (Fill may return a `Pattern`, or Gradient-type in future versions, but is currently *always* a Color), and `stroke_width` is a length/percent value (will be rendered to a float during parsing). * SVG Spec deconstruction of basic shapes into Paths within regard to the SVG 2.0 spec. Path(shape) or shape.d() * `Group` objects. Container class. * `clipPath` objects, these are assigned as a `.clip_path` to any object that referenced them. * `` and `` functionality within the parsing tree. * Accurate referencing of objects in the ShadowDOM. * `Pattern` objects. These are parsed they are not currently assigned. * `Text` objects. The lack of a font engine makes this class more of a parsed stub class. * `Image` creates `SVGImage` objects which will load Images if `Pillow` is installed with a call to `.load()`. Correct parsing of `x`, `y`, `width`, `height` and `viewbox`. * `Desc` description object. * `Title` description object. * Nested `SVG` objects. (Caveats see Non-Supported). * CSS Styling. ## Not supported Some things are currently not supported. * Full CSS/DOM specific parsing and modifications. * Full CSS StyleSheet. Stylesheets should be read anywhere in the file and styled all matching objects even those already parsed. We accept Styling that occurs before the objects. * Color: OS Specific System colors. * `Script` and Scripting. * `RadialGradiant` Fills * `LinearGradiant` Fills * `Pattern` linking to Fill the IRI linked object. * `a` hyperlink text objects * `Switch` elements. * `Marker` elements. * `Symbol` elements. * `Masking` elements. * `TextPath` elements. * `Metadata` elements. * Nesting of `SVG` elements within an `Image` object. * `em`, `ex` length and font engine requiring code. (the height of 'm' and 'x' is unknown). * Slicing of SVG geometry, outside of viewbox. * Slicing of SVG geometry, within clipPath * External Loading of SVG files. * External loading of SVGz files. * External loading of CSS data from another file. * SVG Animation * Styling based on Descendant, Child, FirstChild, Sibling, Attribute, AttributeWithValue. * `Glyph` - Dropped in SVG 2.0 * `tref` - Dropped in SVG 2.0 # Parsing The primary function of `svgelements` is to parse svg files. There are two main functions to facilitate this ```python def parse(source, reify=True, ppi=DEFAULT_PPI, width=1, height=1, color="black", transform=None, context=None): ``` This parse function takes in values that cannot be known to the SVG but which are essential to the the rendering of the shapes. Parsing will pre-apply things like the relative translation by the viewport. It will solve the structural changes for the with the `` and ``, and any items that are known SVG elements will be turned into their requisite values and parsed accordingly. So the `.fill` and `.stroke` of a `Path` will be filled in with a type of `Color` and the `.transform` of the Shape will be a type of `Matrix`. The `.values` for all the `SVGElement` will have the relevant inherited values. This permits parsing to deal with even unknown types of objects within the SVG by falling back to something akin to DOM parsing of the file. In cases of `` and `` these unknown elements can still reference other. Since this structural shadow tree will be solved during the parse. `parse()` is a static function which takes a `source` file or stream of svg data to be parsed. This will return an `SVG` object which is a type of `Group`. There are several values which can be configured with other values as needed. `reify` determines whether the parsed elements in the `SVG` should have their transform matrix applied or not. This includes the effective matrix resulting from viewport. The `ppi` value defaults to `96` as this is quite common some other graphics programs use `72` and other values are permitted. Since there's nothing directly in the SVG spec setting this value and other places can vary with their value here. We can't predetermine this value. However it regulates all the relationships between physical values like a 1in by 1in `rect` to the unitless pixel values of the SVG. The `width` and `height` values are unknown to the SVG parsing. This is the physical view size of the svg itself. This often will have little impact in the SVG rendering however sometimes things widths are set to `100%` or heights to `50%` and those are according to the spec relative to the actual view we're using. The svg has no direct access to this. If we have a `ppi` value these height and width can be set to absolute units like `6in` or any other acceptable `Length` value that can be solved with `ppi`. The `color` is the value of the `currentColor` within the SVG spec. Usually the default stroke and fill values are set but in some cases these are set to `currentColor` which is a property of the CSS outside the scope of the SVG. In this case that color needs to be provided. It will be a rare edge case. The `transform` value is typical CSS/SVG transform matrix code to be preappended to the matrix before even the viewbox. If you need to set some units or apply something to the entire svg without changing things within the CSS this value becomes important. Especially when dealing with edge cases like the difference of `transform` applied directly to the `SVG` tag itself. The `context` permits giving a context of already set values that are come from outside the current svg context, such as we would find if we had SVG files embedded into SVG files. The second function within parsing that matters is the `.elements()` this is a function that exists on any `SVG` object and will flatten the elements yielding them in order. Here's an example parser with elements(). ```python for element in svg.elements(): try: if element.values['visibility'] == 'hidden': continue except (KeyError, AttributeError): pass if isinstance(element, SVGText): elements.append(element) elif isinstance(element, Path): if len(element) != 0: elements.append(element) elif isinstance(element, Shape): e = Path(element) e.reify() # In some cases the shape could not have reified, the path must. if len(e) != 0: elements.append(e) elif isinstance(element, SVGImage): try: element.load(os.path.dirname(pathname)) if element.image is not None: elements.append(element) except OSError: pass ``` Here a few things are checked. The element.values for ['visibility'] is checked if it's hidden it is not added to our flat object list. Texts are specific added. Paths are only added if they have `PathSegments` and are not completely blank. Any Shape object is converted to a Path() object and reified. Any SVGImage objects are loaded. This is a soft dependency on PIL/Pillow to load images stored within SVG. The SVG `.elements()` function can also take a conditional function that well be used to test each element before yielding it. In most cases we don't want every single type of thing an svg can produce. We might just want all the Path objects so we check for any Path and include that but also for any non-Path Shape and convert that to a path. `pathname` is an attempt to get the local directory for loading relative path images. # Overview The versatility of the project is provided through through expansive and highly intuitive dunder methods, and robust parsing of object parameters. Points, PathSegments, Paths, Shapes, Subpaths can be multiplied by a matrix. We can add Shapes, Paths, PathSegments, and Subpaths together. And many non-declared but functionally understandable elements are automatically parsed. Such as adding strings of path_d characters to a Path or multiplying an element by the SVG Transform string elements. While many objects perform a lot of interoperations, a lot many svg elements are designed to also work independently, and be independently useful. ## Point Points define a single location in 2D space. The Point class is intended to take a wide variety of different initial definitions to wrap them into being a point. * Point(x,y) * (x,y) * [x,y] * "x, y" * x + yj (complex number) * a class with .x and .y as methods. Most objects requiring a point will wrap that object with the included Point class meaning any of these initial arguments is acceptable. Including independent x and y parameters, a tuple of x and y, a list of x and y, a string that parses akin to points within `polyline` objects, complex numbers with a real x and imag y values. And any class with `.x` or `.y` attributes. --- >>> Point(10,10) * "rotate(90)" Point(-10,10) ## Matrix Matrices define affine transformations of 2d space and objects. * Matrix.scale(s) * Matrix.scale(sx,sy) * Matrix.scale(sx,sy,px,py) * Matrix.rotate(angle) * Matrix.rotate(angle, px, py * Matrix.skew_x(angle) * Matrix.skew_x(angle, px, py) * Matrix.skew_y(angle) * Matrix.skew_y(angle, px, py) * Matrix.translate(tx) * Matrix.translate(tx, ty) * Transform string values. * "scale(s)" * "scale(sx,sy)" * "translate(20,20) scale(2)" * "rotate(0.25 turns)" * Any valid SVG or CSS transform string will be accepted as a matrix. --- >>> Matrix("rotate(100grad)") Matrix(0, 1, -1, 0, 0, 0) The matrix class also supports Length translates for x, and y. In some instances, CSS transforms permit length transforms so "translate(20cm, 200mm)" are valid transformations. However, these will cause issues for objects which require non-native units so it is expected that `.render()` will be called on these before they are used in some manner. ## Path Paths define sequences of PathSegments that can map out any path element in SVG. * Path() object * String path_d value. --- >>> Path() + "M0,0z" Path(Move(end=Point(0,0)), Close(start=Point(0,0), end=Point(0,0))) ## Angle Angles define various changes in direction. * Angle.degrees(degree_angle) * Angle.radians(radians_angle) * Angle.turns(turns) * Angle.gradians(gradian_angles) * CSS angle string. * "20deg" * "0.3turns" * "1rad" * "100grad" --- >>> Point(0,100) * "rotate(1turn)" Point(0,100) >>> Point(0,100) * "rotate(0.5turn)" Point(-0,-100) ## Color Colors define object color. * XHTML color names: "red", "blue", "dark grey", etc. * 3 digit hex: "#F00" * 4 digit hex: "#FF00" * 6 digit hex: "#FF0000" * 8 digit hex: "#FFFF0000" * "RGB(r,g,b)" * "RGB(r%, g%, b%)" --- >>> Circle(stroke="yellow") Circle(center=Point(0,0), r=1, stroke="#ffff00") ## Length Lengths define the amount of linear space between two things. * "20cm" * "200mm" * "3in" * Length('200mm') # Examples Parse an SVG file: >>> svg = SVG.parse(file) >>> list(svg.elements()) Make a PathSegment >>> Line((20,20), (40,40)) Line(start=Point(20,20), end=Point(40,40)) Rotate a PathSegment: >>> Line((20,20), (40,40)) * Matrix.rotate(Angle.degrees(45)) Line(start=Point(0,28.284271247462), end=Point(0,56.568542494924)) Rotate a PathSegment with a parsed matrix: >>> Line((20,20), (40,40)) * Matrix("Rotate(45)") Line(start=Point(0,28.284271247462), end=Point(0,56.568542494924)) Rotate a PathSegment with an implied parsed matrix: >>> Line((20,20), (40,40)) * "Rotate(45)" Line(start=Point(0,28.284271247462), end=Point(0,56.568542494924)) Rotate a Partial Path with an implied matrix: (Note: The SVG does not allow us to specify a start point for this invalid path) >>> Path("L 40,40") * "Rotate(45)" Path(Line(end=Point(40,40)), transform=Matrix(0.707106781187, 0.707106781187, -0.707106781187, 0.707106781187, 0, 0), stroke='None', fill='None') >>> abs(Path("L 40,40") * "Rotate(45)") Path(Line(end=Point(0,56.568542494924)), stroke='None', fill='None') Since Move() is a qualified element we can postpend the SVG text: >>> (Move((20,20)) + "L 40,40") Path(Move(end=Point(20,20)), Line(start=Point(20,20), end=Point(40,40)), stroke='None', fill='None') Define the entire qualified path: >>> Path("M 20,20 L 40,40")" Path(Move(end=Point(20,20)), Line(start=Point(20,20), end=Point(40,40))) Combine individual PathSegments together: >>> Move((2,2)) + Close() Path(Move(end=Point(2,2)), Close()) Print that as SVG path_d object: >>> print(Move((2,2)) + Close()) M 2,2 Z Scale a path: >>> Path("M1,1 1,2 2,2 2,1z") * "scale(2)" Path(Move(end=Point(1,1)), Line(start=Point(1,1), end=Point(1,2)), Line(start=Point(1,2), end=Point(2,2)), Line(start=Point(2,2), end=Point(2,1)), Close(start=Point(2,1), end=Point(1,1)), transform=Matrix(2, 0, 0, 2, 0, 0), stroke='None', fill='None') Print that: >>> print(Path("M1,1 1,2 2,2 2,1z") * "scale(2)") M 2,2 L 2,4 L 4,4 L 4,2 Z Reverse a scaled path: >>> p = (Path("M1,1 1,2 2,2 2,1z") * "scale(2)") >>> p.reverse() Path(Move(end=Point(2,1)), Line(start=Point(2,1), end=Point(2,2)), Line(start=Point(2,2), end=Point(1,2)), Line(start=Point(1,2), end=Point(1,1)), Close(start=Point(1,1), end=Point(2,1)), transform=Matrix(2, 0, 0, 2, 0, 0), stroke='None', fill='None') >>> print(p) M 4,2 L 4,4 L 2,4 L 2,2 Z Query length of paths: >>> QuadraticBezier("0,0", "50,50", "100,0").length() 114.7793574696319 Apply a translations: >>> Path('M 0,0 Q 50,50 100,0') * "translate(40,40)" Path(Move(end=Point(0,0)), QuadraticBezier(start=Point(0,0), control=Point(50,50), end=Point(100,0)), transform=Matrix(1, 0, 0, 1, 40, 40), stroke='None', fill='None') >>> abs(Path('M 0,0 Q 50,50 100,0') * "translate(40,40)") Path(Move(end=Point(40,40)), QuadraticBezier(start=Point(40,40), control=Point(90,90), end=Point(140,40)), stroke='None', fill='None') Query lengths of translated paths: >>> (Path('M 0,0 Q 50,50 100,0') * "translate(40,40)").length() 114.7793574696319 >>> Path('M 0,0 Q 50,50 100,0').length() 114.7793574696319 Query a subpath: >>> Path('M 0,0 Q 50,50 100,0 M 20,20 v 20 h 20 v-20 h-20 z').subpath(1).d() 'M 20,20 L 20,40 L 40,40 L 40,20 L 20,20 Z' Reverse a subpath: >>> p = Path('M 0,0 Q 50,50 100,0 M 20,20 v 20 h 20 v-20 h-20 z') >>> print(p) M 0,0 Q 50,50 100,0 M 20,20 L 20,40 L 40,40 L 40,20 L 20,20 Z >>> p.subpath(1).reverse() Path(Move(start=Point(100,0), end=Point(20,20)), Line(start=Point(20,20), end=Point(40,20)), Line(start=Point(40,20), end=Point(40,40)), Line(start=Point(40,40), end=Point(20,40)), Line(start=Point(20,40), end=Point(20,20)), Close(start=Point(20,20), end=Point(20,20))) >>> print(p) M 0,0 Q 50,50 100,0 M 20,20 L 40,20 L 40,40 L 20,40 L 20,20 Z Query a bounding box: >>> QuadraticBezier("0,0", "50,50", "100,0").bbox() (0.0, 0.0, 100.0, 50.0) Query a translated bounding box: >>> (Path('M 0,0 Q 50,50 100,0') * "translate(40,40)").bbox() (40.0, 40.0, 140.0, 90.0) Query a translated path's untranslated bounding box. >>> (Path('M 0,0 Q 50,50 100,0') * "translate(40,40)").bbox(transformed=False) (0.0, 0.0, 100.0, 50.0) Add a path and shape: >>> print(Path("M10,10z") + Circle("12,12", 2)) M 10,10 Z M 14,12 A 2,2 0 0,1 12,14 A 2,2 0 0,1 10,12 A 2,2 0 0,1 12,10 A 2,2 0 0,1 14,12 Z Add two shapes, and query their bounding boxes: >>> (Circle() + Rect()).bbox() (-1.0, -1.0, 1.0, 1.0) Add two shapes and query their length: >>> (Circle() + Rect()).length() 10.283185307179586 >>> tau + 4 10.283185307179586 Etc. # Elements The elements are the core functionality of this class. These are svg-based objects which interact in coherent ways. ## Path The Path element is based on regebro's code and methods from the `svg.path` project. The primary methodology is to use different PathSegment classes for each segment within a pathd code. These should always have a high degree of backwards compatibility. And for most purposes importing the relevant classes from `svgelements` should be highly compatible with any existing code. For this reason `svgelements` tests include `svg.path` tests in this project. And while the Point class accepts and works like a `complex` it is not actually a complex. This permits code from other projects to quickly port without requiring an extensive rewrite. But, the custom class allows for improvements like making the `Matrix` object easy. * ``Path(*segments)`` Just as with `svg.path` the ``Path`` class is a mutable sequence, and it behaves like a list. You can add to it and replace path segments etc: >>> path = Path(Line(100+100j,300+100j), Line(100+100j,300+100j)) >>> path.append(QuadraticBezier(300+100j, 200+200j, 200+300j)) >>> print(path) L 300,100 L 300,100 Q 200,200 200,300 >>> path[1] = Line(200+100j,300+100j) >>> print(path) L 300,100 L 300,100 Q 200,200 200,300 >>> del path[1] >>> print(path) L 300,100 Q 200,200 200,300 >>> path = Move() + path >>> print(path) M 100,100 L 300,100 Q 200,200 200,300 The path object also has a ``d()`` method that will return the SVG representation of the Path segments: >>> path.d() 'M 100,100 L 300,100 Q 200,200 200,300' The d() parameter also takes a value for relative: >>> path.d(relative=True) 'm 100,100 l 200,0 q -100,100 -100,200' More modern and preferred methods are to simply use path_d strings where needed. >>> print(Path("M0,0v1h1v-1z")) M 0,0 L 0,1 L 1,1 L 1,0 Z And to use scaling factors as needed. >>> (Path("M0,0v1h1v-1z") * "scale(20)").bbox() (0.0, 0.0, 20.0, 20.0) --- A ``Path`` object that is a collection of the PathSegment objects. These can be defined by combining a PathSegment with another PathSegment initializing it with `Path()` or `Path(*segments)` or `Path()`. ### Subpaths Subpaths provide a window into a Path object. These are backed by the Path they are created from and consequently operations performed on them apply to that part of the path. >>> p = Path('M 0,0 Q 50,50 100,0 M 20,20 v 20 h 20 v-20 h-20 z') >>> print(p) M 0,0 Q 50,50 100,0 M 20,20 L 20,40 L 40,40 L 40,20 L 20,20 Z >>> q = p.subpath(1) >>> q *= "scale(2)" >>> print(p) M 0,0 Q 50,50 100,0 M 40,40 L 40,80 L 80,80 L 80,40 L 40,40 Z or likewise `.reverse()` (notice the path will go 80,40 first rather than 40,80.) >>> q.reverse() >>> print(p) M 0,0 Q 50,50 100,0 M 40,40 L 80,40 L 80,80 L 40,80 L 40,40 Z ### Segments There are 6 PathSegment objects: ``Line``, ``Arc``, ``CubicBezier``, ``QuadraticBezier``, ``Move`` and ``Close``. These have a 1:1 correspondence to the commands in a `pathd`. >>> from svgelements import Path, Line, Arc, CubicBezier, QuadraticBezier, Close All of these objects have a ``.point()`` function which will return the coordinates of a point on the path, where the point is given as a floating point value where ``0.0`` is the start of the path and ``1.0`` is end. You can calculate the length of a Path or its segments with the ``.length()`` function. For CubicBezier and Arc segments this is done by geometric approximation and for this reason **may be very slow**. You can make it faster by passing in an ``error`` option to the method. If you don't pass in error, it defaults to ``1e-12``. While the project has no dependencies, if you have `scipy` installed the Arc.length() function will use to the hypergeometric exact formula contained and will quickly return with the exact answer. >>> CubicBezier(300+100j, 100+100j, 200+200j, 200+300j).length(error=1e-5) 297.2208145656899 CubicBezier and Arc also has a ``min_depth`` option that specifies the minimum recursion depth. This is set to 5 by default, resulting in using a minimum of 32 segments for the calculation. Setting it to 0 is a bad idea for CubicBeziers, as they may become approximated to a straight line. ``Line.length()`` and ``QuadraticBezier.length()`` also takes these parameters, but they unneeded as direct values rather than approximations are returned. CubicBezier and QuadraticBezier also have ``is_smooth_from(previous)`` methods, that checks if the segment is a "smooth" segment compared to the given segment. Unlike `svg.path` the preferred method of getting a Path from a `pathd` string is as an argument: >>> from svgelements import Path >>> Path('M 100 100 L 300 100') Path(Move(end=Point(100,100)), Line(start=Point(100,100), end=Point(300,100))) #### PathSegment Classes These are the SVG PathSegment classes. See the `SVG specifications `_ for more information on what each parameter means. * ``Move(start, end)`` The move object describes a move to the start of the next subpath. It may lack a start position but not en end position. * ``Close(start, end)`` The close object describes a close path element. It will have a length if and only if the end point is not equal to the subpath start point. Neither the start point or end point is required. * ``Line(start, end)`` The line object describes a line moving straight from one point to the next point. * ``Arc(start, radius, rotation, arc, sweep, end)`` The arc object describes an arc across a circular path. This supports multiple types of parameterizations. The given default there is compatible with `svg.path` and has a complex radius. It is also valid to divide radius into `rx` and `ry` or Arc(start, end, center, prx, pry, sweep) where start, end, center, prx, pry are points and sweep is the radians value of the arc distance traveled. * ``QuadraticBezier(start, control, end)`` the quadratic bezier object describes a single control point bezier curve. * ``CubicBezier(start, control1, control2, end)`` the cubic bezier curve object describes a two control point bezier curve. ### Examples This SVG path example draws a triangle: >>> path1 = Path('M 100 100 L 300 100 L 200 300 z') You can format SVG paths in many different ways, all valid paths should be accepted: >>> path2 = Path('M100,100L300,100L200,300z') And these paths should be equal: >>> path1 == path2 True You can also build a path from objects: >>> path3 = Path(Move(100 + 100j), Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j)) And it should again be equal to the first path:: >>> path1 == path3 True Paths are mutable sequences, you can slice and append:: >>> path1.append(QuadraticBezier(300+100j, 200+200j, 200+300j)) >>> len(path1[2:]) == 3 True Note that there is no protection against you creating paths that are invalid. You can for example have a Close command that doesn't end at the path start: >>> wrong = Path(Line(100+100j,200+100j), Close(200+300j, 0)) >>> wrong.d() 'L 200,100 Z' ## Matrix (Transformations) SVG 1.1, 7.15.3 defines the matrix form as: [a c e] [b d f] Since we are delegating to SVG spec for such things, this is how it is implemented in elements. To be compatible with SVG 1.1 and SVG 2.0 the matrix class provided has all the SVG functions as well as the CSS functions: * translate(x,[y]) * translateX(x) * translateY(y) * scale(x,[y]) * scaleX(x) * scaleY(y) * skew(x,[y]) * skewX(x) * skewY(y) Since we have compatibility with CSS for the SVG 2.0 spec compatibility we can perform length translations: >>> Point(0,0) * Matrix("Translate(1cm,1cm)") Point('1cm','1cm') Do note, however that this isn't an intended purpose. Points are expected in native units. You should render the Matrix prior to using it. This means you must give it the correct units to translate the information from one form to another. >>> Point(0,0) * (Matrix("Translate(1cm,1cm)").render(ppi=96.0)) Point(37.795296,37.795296) We can also rotate by `turns`, `grad`, `deg`, `rad` which are permitted CSS angles: >>> Point(10,0) * Matrix("Rotate(1turn)") Point(10,-0) >>> Point(10,0) * Matrix("Rotate(400grad)") Point(10,-0) >>> Point(10,0) * Matrix("Rotate(360deg)") Point(10,-0) A goal of this project is to provide a robust modifications of Path objects including matrix transformations. This is done by three major shifts from `svg.path`s methods. * Points are not stored as complex numbers. These are stored as Point objects, which have backwards compatibility with complex numbers, without the data actually being backed by a `complex`. * A matrix is added which conforms to the SVGMatrix element. The matrix contains valid versions of all the affine transformations elements required by the SVG Spec. * The `Arc` object is fundamentally backed by a different point-based parameterization. The objects themselves have robust dunder methods. So if you have a path object you may simply multiply it by a matrix. >>> Path(Line(0+0j, 100+100j)) * Matrix.scale(2) Path(Line(start=Point(0,0), end=Point(100,100)), transform=Matrix(2, 0, 0, 2, 0, 0), stroke='None', fill='None') Or rotate a parsed path. >>> Path("M0,0L100,100") * Matrix.rotate(30) Path(Move(end=Point(0,0)), Line(start=Point(0,0), end=Point(100,100)), transform=Matrix(0.154251449888, -0.988031624093, 0.988031624093, 0.154251449888, 0, 0)) Or modify an SVG path. >>> str(Path("M0,0L100,100") * Matrix.rotate(30)) 'M 0,0 L 114.228,-83.378' The Matrix objects can be used to modify points: >>> Point(100,100) * Matrix("scale(2)") Point(200,200) >>> Point(100,100) * (Matrix("scale(2)") * Matrix("Translate(40,40)")) Point(240,240) Do note that the order of operations for matrices matters: >>> Point(100,100) * (Matrix("Translate(40,40)") * Matrix("scale(2)")) Point(280,280) The first version is: >>> (Matrix("scale(2)") * Matrix("Translate(40,40)")) Matrix(2, 0, 0, 2, 40, 40) The second is: >>>> (Matrix("Translate(40,40)") * Matrix("scale(2)")) Matrix(2, 0, 0, 2, 80, 80) This is: >>>> Point(100,100) * Matrix("Matrix(2,0,0,2,80,80)") Point(280,280) ### SVG Dictionary Parsing >>> node = { 'd': "M0,0 100,0, 0,100 z", 'transform': "scale(0.5)"} >>> print(Path(node['d']) * Matrix(node['transform'])) M 0,0 L 50,0 L 0,50 Z ### SVG Viewport Scaling, Unit Scaling There is need in many applications to append a transformation for the viewbox, height, width. So as to prevent a variety of errors where the expected size is vastly different from the actual size. If we have a viewbox of "0 0 100 100" but the height and width show that to be 50cm wide, then a path "M25,50L75,50" within that viewbox has a real size of length of 25cm which can be quite different from 50 (unitless value). This conversion is done through the `Viewbox` object. This operation is automatically done for during SVG parsing. Viewbox objects have a call to `.transform()` which will provide the string for an equivalent transformation for the given viewbox. The `Viewbox.transform()` code conforms to the algorithm given in SVG 1.1 7.2, SVG 2.0 8.2 'equivalent transform of an SVG viewport.' This will also fully implement the `preserveAspectRatio`, `xMidYMid`, and `meetOrSlice` values for the viewboxes. ## SVG Shapes Another important SVG elements are the shapes. While all of these can be converted to paths. They can serve some usages in their original form. There are methods to deform a rectangle that simple don't exist in the path form of that object. * Rect * Ellipse * Circle * Line (SimpleLine) * Polyline * Polygon The Line shape is converted into a shape called SimpleLine to not interfere with the Line(PathSegment). A Shape is said to be equal to another Shape or a Path if they decompose to same Path. >>> Circle() == Ellipse() True >>> Rect() == Path('m0,0h1v1h-1z') True ### Rect Rectangles are defined by x, y and height, width. Within SVG there are also rounded corners defined with `rx` and `ry`. >>> Rect(10,10,8,4).d() 'M 10,10 L 18,10 L 18,14 L 10,14 Z' Much like all the paths these shapes also contain a `.d()` function that produces the path data for them. This could then be wrapped into a Path(). >>> print(Path(Rect(10,10,8,4).d()) * "rotate(0.5turns)") M -10,-10 L -18,-10 L -18,-14 L -10,-14 Z Or simply passed to the Path: >>> print(Path(Rect(10,10,8,4)) * "rotate(0.5turns)") M -10,-10 L -18,-10 L -18,-14 L -10,-14 L -10,-10 Z Or simply multiplied by the matrix itself: >>> print(Rect(10,10,8,4) * "rotate(0.5turns)") Rect(x=10, y=10, width=8, height=4, transform=Matrix(-1, 0, -0, -1, 0, 0), stroke='None', fill='None') And you can equally decompose that Shape: >>> (Rect(10,10,8,4) * "rotate(0.5turns)").d() 'M -10,-10 L -18,-10 L -18,-14 L -10,-14 L -10,-10 Z' Matrices can be applied to Rect objects directly. >>> from svgelements import * >>> Rect(10,10,8,4) * "rotate(0.5turns)" Rect(x=10, y=10, width=8, height=4, transform=Matrix(-1, 0, -0, -1, 0, 0), stroke='None', fill='None') >>> Rect(10,10,8,4) * "rotate(0.25turns)" Rect(x=10, y=10, width=8, height=4, transform=Matrix(0, 1, -1, 0, 0, 0)) Rotated Rects produce path_d strings.: >>> Rect(10,10,8,4) * "rotate(14deg)" Rect(x=10, y=10, width=8, height=4, transform=Matrix(0.970295726276, 0.2419218956, -0.2419218956, 0.970295726276, 0, 0)) >>> (Rect(10,10,8,4) * "rotate(14deg)").d() 'M 7.28373830676,12.1221762188 L 15.046104117,14.0575513836 L 14.0784165346,17.9387342887 L 6.31605072436,16.0033591239 Z' This also works with `rx` and `ry`: (Note: the path will now contain Arcs) >>> (Rect(10,10,8,4, 2, 1) * "rotate(0.25turns)").d() 'M -10,12 L -10,16 A 2,1 90 0,1 -11,18 L -13,18 A 2,1 90 0,1 -14,16 L -14,12 A 2,1 90 0,1 -13,10 L -11,10 A 2,1 90 0,1 -10,12 Z' You can also decompose the shapes in relative modes: >>> (Rect(10,10,8,4, 2, 1) * "rotate(0.25turns)").d(relative=True) 'm -10,12 l 1.77636E-15,4 a 2,1 90 0,1 -1,2 l -2,0 a 2,1 90 0,1 -1,-2 l -1.77636E-15,-4 a 2,1 90 0,1 1,-2 l 2,0 a 2,1 90 0,1 1,2 z' ### Ellipse & Circle Ellipses and Circles are different shapes but since a circle is a particular kind of Ellipse much of the functionality here is duplicated. While the objects are different they can be checked for equivalency: >>> Ellipse(center=(0,0), rx=10, ry=10) == Circle(center="0,0", r=10.0) True ### SimpleLine SimpleLine is renamed from the SVG form of `Line` since we already have `Line` objects as `PathSegment`. >>> s = SimpleLine(0,0,200,200) >>> s SimpleLine(x1=0.0, y1=0.0, x2=200.0, y2=200.0) >>> s *= "rotate(45)" >>> s SimpleLine(x1=0.0, y1=0.0, x2=200.0, y2=200.0, transform=Matrix(0.707106781187, 0.707106781187, -0.707106781187, 0.707106781187, 0, 0)) >>> abs(s) SimpleLine(x1=0.0, y1=0.0, x2=2.842170943040401e-14, y2=282.842712474619, stroke='None', fill='None') >>> s.d() 'M 0,0 L 2.84217094304E-14,282.842712475 ### Polyline and Polygon The difference here is polylines are not closed while Polygons are closed. >>> p = Polygon(0,0, 100,0, 100,100, 0,100) >>> p *= "scale(2)" >>> p.d() 'M 0,0, L 200,0, L 200,200, L 0,200 Z' and the same for Polyline: >>> p = Polyline(0,0, 100,0, 100,100, 0,100) >>> p *= "scale(2)" >>> p.d() 'M 0,0, L 200,0, L 200,200, L 0,200' You can just append a "z" to the polyline path though. >>> Path(Polyline((20,0), (10,10), 0)) + "z" == Polygon("20,0 10,10 0,0") True ## CSS Length The conversion of lengths to utilizes another element `Length` It provides conversions for `mm`, `cm`, `in`, `px`, `pt`, `pc`, `%`. You can also parse an element like the string '25mm' calling Length('25mm').value(ppi=96) and get the expected results. You can also call `Length('25mm').in_inches()` which will return 25mm in inches. This can be independently useful when dealing with lengths, etc. >>> Length('25mm').in_inches() 0.9842525 ## Color Color is another fundamental element within SVG that is also useful elsewhere. The object contains an 'int' as 'value' in RGBA order, storing alpha in the 8 least signficant bits. It parses all the SVG color functions. If we get the `.fill` or `.stroke` of an object. This can be expressed in many ways, and needs to be converted to a consistent form. We could have a 3, 4, 6, or 8 digit hex. rgb(r,g,b) value, a static dictionary name or percent rgb(r,g,b). And must be properly parsed according to the spec. >>> Color("red").hex '#ff0000' >>> Color('red').red 255 >>>Color('hsl(120, 100%, 50%)') Color('#00ff00') >>> c = Color('hsl(120, 100%, 50%)') >>> c.blue = 50 >>> c Color('#00ff32') In addition you can set various properties of a particular color. Check distances to other colors. >>> Color.distance('red', 'lightred') 25.179356624028344 >>> Color.distance('red', 'blue') 403.97524676643246 >>> Color('red').distance_to('blue') 403.97524676643246 ## Angle Angle is backed by a 'float' and contains all the CSS angle values. 'deg', 'rad', 'grad', 'turn'. >>> Angle.degrees(360).as_radians Angle(6.283185307180) The Angle element is used automatically with the Skew and Rotate for matrix. >>> Point(100,100) * Matrix("SkewX(0.05turn)") Point(132.491969623291,100) ## Point Point is used in all the SVG path segment objects. With regard to `svg.path` it is not back by, but implements all the same functionality as a `complex` and will take a complex as an input. This is so that older `svg.path` code will remain valid. While also allowing for additional functionality like finding a distance. >>> Point(0+100j).distance_to([0,0]) 100.0 The class supports `complex` subscribable elements, `.x` and `.y` methods, and `.imag` and `.real`. As well as providing several of these indexing methods. It includes a number of point functions like: * `move_towards(point,float)`: Move this point towards the other point. with an amount [0,1] * `distance_to(point)`: Calculate the Euclidean distance to the other point. * `angle_to(point)`: Calculate the angle to the given point. * `polar_to(angle,distance)`: Return a point via polar coords at the angle and distance. * `reflected_across(point)`: Returns a point reflected across another point. (Smooth bezier curves use this). This for example takes the 0,0 point turns 1/8th of a turn, and moves forward by 5cm. >>> Point(0).polar_to(Angle.turns(0.125), Length("5cm").value(ppi=96)) Point(133.626550492764,133.626550492764) # Acknowledgments The Path element of this project is based in part on the `regebro/svg.path` ( https://github.com/regebro/svg.path ) project. It is also may be based, in part, on some elements of `mathandy/svgpathtools` ( https://github.com/mathandy/svgpathtools ). svgelements-1.6.8/pyproject.toml000066400000000000000000000461071417573325500170220ustar00rootroot00000000000000[tool.mypy] color_output = true error_summary = true pretty = true show_error_context = true show_column_numbers = true soft_error_limit = 20 warn_redundant_casts = true warn_return_any = true warn_unreachable = true warn_unused_configs = true warn_unused_ignores = true [tool.isort] profile = "black" line_length = 88 src_paths = ["svgelements"] [tool.black] line-length = 88 target-version = ['py36'] include = '\.pyi?$' [tool.flake8] filename = "*.py" count = "true" exclude = [ "*.pyc", "__pycache__" ] indent-size = 4 max-complexity = 10 max-line-length = 88 show-source = "true" statistics = "true" [tool.pylint.master] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. extension-pkg-whitelist = "" # Add files or directories to the blacklist. They should be base names, not # paths. ignore = "CVS" # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns = "" # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook = "" # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. jobs = 0 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or # complex, nested conditions. limit-inference-results = 100 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins = "" # Pickle collected data for later comparisons. persistent = true # Specify a configuration file. #rcfile = "" # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode = true # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension = false [tool.pylint.'messages control'] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence = "" # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable = all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable = all # --enable = similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable = all --enable = classes # --disable = W". #disable = "all" enable = "all" # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. #enable = [ # consider-using-enumerate, # format-combined-specification, # return-in-init, # catching-non-exception, # bad-except-order, # unexpected-special-method-signature, # # Enforce list comprehensions # # Newline at EOF # raising-bad-type, # raising-non-exception, # format-needs-mapping, # invalid-all-object, # bad-super-call, # nonexistent-operator, # missing-kwoa, # missing-format-argument-key, # init-is-generator, # access-member-before-definition, # used-before-assignment, # redundant-keyword-arg, # assert-on-tuple, # assignment-from-no-return, # expression-not-assigned, # misplaced-bare-raise, # redefined-argument-from-local, # not-in-loop, # bad-exception-context, # unidiomatic-typecheck, # no-staticmethod-decorator, # nonlocal-and-global, # confusing-with-statement, # global-variable-undefined, # global-variable-not-assigned, # inconsistent-mro, # no-classmethod-decorator, # nonlocal-without-binding, # duplicate-bases, # duplicate-argument-name, # duplicate-key, # useless-else-on-loop, # arguments-differ, # logging-too-many-args, # too-few-format-args, # bad-format-string-key, # invalid-sequence-index, # inherit-non-class, # bad-format-string, # invalid-format-index, # invalid-star-assignment-target, # no-method-argument, # no-value-for-parameter, # missing-format-attribute, # logging-too-few-args, # too-few-format-args, # mixed-format-string, # # Old style class # logging-format-truncated, # truncated-format-string, # notimplemented-raised, # # Builtin redefined # function-redefined, # reimported, # repeated-keyword, # lost-exception, # return-outside-function, # return-arg-in-generator, # non-iterator-returned, # method-hidden, # too-many-star-expressions, # trailing-whitespace, # unexpected-keyword-arg, # missing-format-string-key, # unnecessary-lambda, # unnecessary-pass, # unreachable, # logging-unsupported-format, # bad-format-character, # unused-import, # exec-used, # pointless-statement, # pointless-string-statement, # undefined-all-variable, # misplaced-future, # continue-in-finally, # invalid-slots, # invalid-slice-index, # invalid-slots-object, # star-needs-assignment-target, # global-at-module-level, # yield-outside-function, # mixed-indentation, # non-parent-init-called, # bare-except, # no-self-use, # dangerous-default-value, # arguments-differ, # signature-differs, # duplicate-except, # abstract-class-instantiated, # binary-op-exception, # undefined-variable #] [tool.pylint.reports] # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). Default is: # evaluation = 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # evaluation = 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details. #msg-template = "" # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. #output-format = text output-format = "colorized" # Tells whether to display a full report or only the messages. #reports = false reports = true # Activate the evaluation score. score = true [tool.pylint.refactoring] # Maximum number of nested blocks for function / method body max-nested-blocks = 5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions = ["sys.exit"] [tool.pylint.format] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format = "LF" # Regexp for a line that is allowed to be longer than the limit. #ignore-long-lines = "^\s*(# )??$" # Number of spaces of indent required inside a hanging or continued line. indent-after-paren = 4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string = " " # Maximum number of characters on a single line. max-line-length = 88 # Maximum number of lines in a module. max-module-lines = 1000 # List of optional constructs for which whitespace checking is disabled. # `dict-separator` is used to allow tabulation in dicts, etc. e.g. # {1 : 1, # 222: 2}. # `trailing-comma` allows a space between comma and closing bracket: (a, ). # `empty-line` allows space-only lines. no-space-check = [ "trailing-comma", "dict-separator" ] # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt = false # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt = false [tool.pylint.spelling] # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions = 4 # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package.. spelling-dict = "" # List of comma separated words that should not be checked. spelling-ignore-words = "" # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file = "" # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words = false [tool.pylint.similarities] # Ignore comments when computing similarities. ignore-comments = true # Ignore docstrings when computing similarities. ignore-docstrings = true # Ignore imports when computing similarities. ignore-imports = false # Minimum lines number of a similarity. min-similarity-lines = 4 [tool.pylint.variables] # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins = [ "_", "N_", "ngettext", "gettext_countries", "gettext_attributes" ] # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables = true # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks = [ "cb_", "_cb" ] # A regular expression matching the name of dummy variables (i.e. expected to # not be used). dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" # Argument names that match this expression will be ignored. Default to name # with leading underscore. ignored-argument-names = "_.*|^ignored_|^unused_" # Tells whether we should check for unused import in __init__ files. init-import = true # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules = [ "six.moves", "past.builtins", "future.builtins", "builtins", "io" ] [tool.pylint.miscellaneous] # List of note tags to take in consideration, separated by a comma. notes = [ "FIXME", "XXX", "TODO" ] [tool.pylint.logging] # Format style used to check logging format string. `old` means using % # formatting, while `new` is for `{}` formatting. logging-format-style = "old" # Logging modules to check that the string format arguments are in logging # function parameter format. #logging-modules = "logging" logging-modules = "" [tool.pylint.basic] # Naming style matching correct argument names. argument-naming-style = "snake_case" # Regular expression matching correct argument names. Overrides argument- # naming-style. #argument-rgx = "" # Naming style matching correct attribute names. attr-naming-style = "snake_case" # Regular expression matching correct attribute names. Overrides attr-naming- # style. #attr-rgx = "" # Bad variable names which should always be refused, separated by a comma. bad-names = [ "foo", "bar", "baz", "toto", "tutu", "tata" ] # Naming style matching correct class attribute names. class-attribute-naming-style = "any" # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style. #class-attribute-rgx = "" # Naming style matching correct class names. class-naming-style = "PascalCase" # Regular expression matching correct class names. Overrides class-naming- # style. #class-rgx = "" # Naming style matching correct constant names. const-naming-style = "UPPER_CASE" # Regular expression matching correct constant names. Overrides const-naming- # style. #const-rgx = "" # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length = -1 # Naming style matching correct function names. function-naming-style = "snake_case" # Regular expression matching correct function names. Overrides function- # naming-style. #function-rgx = "" # Good variable names which should always be accepted, separated by a comma. good-names = [ "i", "j", "k", "_" ] # Include a hint for the correct naming format with invalid-name. include-naming-hint = false # Naming style matching correct inline iteration names. inlinevar-naming-style = "any" # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style. #inlinevar-rgx = "" # Naming style matching correct method names. method-naming-style = "snake_case" # Regular expression matching correct method names. Overrides method-naming- # style. #method-rgx = "" # Naming style matching correct module names. module-naming-style = "snake_case" # Regular expression matching correct module names. Overrides module-naming- # style. #module-rgx = "" # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group = "" # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx = "^_" # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. # These decorators are taken in consideration only for invalid-name. property-classes = "abc.abstractproperty" # Naming style matching correct variable names. variable-naming-style = "snake_case" # Regular expression matching correct variable names. Overrides variable- # naming-style. #variable-rgx = "" [tool.pylint.typecheck] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators = ["contextlib.contextmanager"] # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members = "" # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members = true # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. ignore-none = true # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference = true # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes = [ "optparse.Values", "thread._local", "_thread._local" ] # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. ignored-modules = "" # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint = true # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance = 1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices = 1 [tool.pylint.classes] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods = [ "__init__", "__new__" ] # List of member names, which should be excluded from the protected access # warning. #exclude-protected = [ # _asdict, # _fields, # _replace, # _source, # _make #] exclude-protected = "" # List of valid names for the first argument in a class method. valid-classmethod-first-arg = "cls" # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg = "cls" [tool.pylint.imports] # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all = true # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks = false # Deprecated modules which should not be used, separated by a comma. deprecated-modules = [ "optparse", "tkinter.tix" ] # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled). ext-import-graph = "" # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled). import-graph = "" # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled). int-import-graph = "" # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library = "" # Force import order to recognize a module as part of a third party library. known-third-party = "" [tool.pylint.design] # Maximum number of arguments for function / method. max-args = 5 # Maximum number of attributes for a class (see R0902). max-attributes = 7 # Maximum number of boolean expressions in an if statement. max-bool-expr = 5 # Maximum number of branch for function / method body. max-branches = 12 # Maximum number of locals for function / method body. max-locals = 15 # Maximum number of parents for a class (see R0901). max-parents = 7 # Maximum number of public methods for a class (see R0904). max-public-methods = 20 # Maximum number of return / yield for function / method body. max-returns = 6 # Maximum number of statements in function / method body. max-statements = 50 # Minimum number of public methods for a class (see R0903). min-public-methods = 2 [tool.pylint.exceptions] # Exceptions that will emit a warning when being caught. Defaults to # "Exception". #overgeneral-exceptions = ["Exception"] svgelements-1.6.8/requirements.txt000066400000000000000000000000021417573325500173520ustar00rootroot00000000000000 svgelements-1.6.8/setup.cfg000066400000000000000000000023461417573325500157240ustar00rootroot00000000000000[metadata] name = svgelements version = 1.6.7 description = Svg Elements Parsing long_description_content_type=text/markdown long_description = file: README.md classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 2.7 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: Implementation :: PyPy Topic :: Multimedia :: Graphics Topic :: Multimedia :: Graphics :: Editors :: Vector-Based Topic :: Software Development :: Libraries :: Python Modules Topic :: Utilities keywords = svg, path, elements, matrix, vector, parser author = Tatarize author_email = tatarize@gmail.com url = https://github.com/meerk40t/svgelements license = MIT [options] zip_safe = True include_package_data = True packages = find: package_dir = = . test_suite = test [pep8] max-line-length=100 [bdist_wheel] universal=1 svgelements-1.6.8/setup.py000066400000000000000000000000471417573325500156110ustar00rootroot00000000000000from setuptools import setup setup()svgelements-1.6.8/svgelements/000077500000000000000000000000001417573325500164325ustar00rootroot00000000000000svgelements-1.6.8/svgelements/__init__.py000066400000000000000000000000621417573325500205410ustar00rootroot00000000000000name = "svgelements" from .svgelements import *svgelements-1.6.8/svgelements/svgelements.py000066400000000000000000011177641417573325500213610ustar00rootroot00000000000000# -*- coding: ISO-8859-1 -*- import re try: from collections.abc import MutableSequence # noqa except ImportError: from collections import MutableSequence # noqa from copy import copy from math import ( acos, atan, atan2, ceil, cos, degrees, hypot, log, radians, sin, sqrt, tan, ) from xml.etree.ElementTree import iterparse try: from math import tau except ImportError: from math import pi tau = pi * 2 """ The path elements are derived from regebro's svg.path project ( https://github.com/regebro/svg.path ) with some of the math from mathandy's svgpathtools project ( https://github.com/mathandy/svgpathtools ). The goal is to provide svg like path objects and structures. The svg standard 1.1 and elements of 2.0 will be used to provide much of the decisions within path objects. Such that if there is a question on implementation if the SVG documentation has a methodology it should be used. Though not required the Image class acquires new functionality if provided with PIL/Pillow as an import and the Arc can do exact arc calculations if scipy is installed. """ SVGELEMENTS_VERSION = "1.6.8" MIN_DEPTH = 5 ERROR = 1e-12 max_depth = 0 # SVG STATIC VALUES DEFAULT_PPI = 96.0 SVG_NAME_TAG = "svg" SVG_ATTR_VERSION = "version" SVG_VALUE_VERSION = "1.1" SVG_ATTR_XMLNS = "xmlns" SVG_VALUE_XMLNS = "http://www.w3.org/2000/svg" SVG_ATTR_XMLNS_LINK = "xmlns:xlink" SVG_VALUE_XLINK = "http://www.w3.org/1999/xlink" SVG_ATTR_XMLNS_EV = "xmlns:ev" SVG_VALUE_XMLNS_EV = "http://www.w3.org/2001/xml-events" XLINK_HREF = "{http://www.w3.org/1999/xlink}href" SVG_HREF = "href" SVG_ATTR_WIDTH = "width" SVG_ATTR_HEIGHT = "height" SVG_ATTR_VIEWBOX = "viewBox" SVG_VIEWBOX_TRANSFORM = "viewbox_transform" SVG_TAG_PATH = "path" SVG_TAG_GROUP = "g" SVG_TAG_RECT = "rect" SVG_TAG_CIRCLE = "circle" SVG_TAG_ELLIPSE = "ellipse" SVG_TAG_LINE = "line" SVG_TAG_POLYLINE = "polyline" SVG_TAG_POLYGON = "polygon" SVG_TAG_TEXT = "text" SVG_TAG_TSPAN = "tspan" SVG_TAG_IMAGE = "image" SVG_TAG_DESC = "desc" SVG_TAG_TITLE = "title" SVG_TAG_METADATA = "metadata" SVG_TAG_STYLE = "style" SVG_TAG_DEFS = "defs" SVG_TAG_USE = "use" SVG_TAG_CLIPPATH = "clipPath" SVG_TAG_PATTERN = "pattern" SVG_STRUCT_ATTRIB = "attributes" SVG_ATTR_ID = "id" SVG_ATTR_DATA = "d" SVG_ATTR_DISPLAY = "display" SVG_ATTR_COLOR = "color" SVG_ATTR_FILL = "fill" SVG_ATTR_FILL_OPACITY = "fill-opacity" SVG_ATTR_STROKE = "stroke" SVG_ATTR_STROKE_OPACITY = "stroke-opacity" SVG_ATTR_STROKE_WIDTH = "stroke-width" SVG_ATTR_TRANSFORM = "transform" SVG_ATTR_STYLE = "style" SVG_ATTR_CLASS = "class" SVG_ATTR_CLIP_PATH = "clip-path" SVG_ATTR_CLIP_RULE = "clip-rule" SVG_ATTR_CLIP_UNIT_TYPE = "clipPathUnits" SVG_ATTR_CENTER_X = "cx" SVG_ATTR_CENTER_Y = "cy" SVG_ATTR_RADIUS_X = "rx" SVG_ATTR_RADIUS_Y = "ry" SVG_ATTR_RADIUS = "r" SVG_ATTR_POINTS = "points" SVG_ATTR_PRESERVEASPECTRATIO = "preserveAspectRatio" SVG_ATTR_X = "x" SVG_ATTR_Y = "y" SVG_ATTR_X0 = "x0" SVG_ATTR_Y0 = "y0" SVG_ATTR_X1 = "x1" SVG_ATTR_Y1 = "y1" SVG_ATTR_X2 = "x2" SVG_ATTR_Y2 = "y2" SVG_ATTR_DX = "dx" SVG_ATTR_DY = "dy" SVG_ATTR_TAG = "tag" SVG_ATTR_FONT = "font" SVG_ATTR_FONT_FAMILY = "font-family" # Serif, sans-serif, cursive, fantasy, monospace SVG_ATTR_FONT_FACE = "font-face" SVG_ATTR_FONT_SIZE = "font-size" SVG_ATTR_FONT_WEIGHT = "font-weight" # normal, bold, bolder, lighter, 100-900 SVG_ATTR_TEXT_ANCHOR = "text-anchor" SVG_ATTR_PATTERN_CONTENT_UNITS = "patternContentUnits" SVG_ATTR_PATTERN_TRANSFORM = "patternTransform" SVG_ATTR_PATTERN_UNITS = "patternUnits" SVG_ATTR_VECTOR_EFFECT = "vector-effect" SVG_UNIT_TYPE_USERSPACEONUSE = "userSpaceOnUse" SVG_UNIT_TYPE_OBJECTBOUNDINGBOX = "objectBoundingBox" SVG_RULE_NONZERO = "nonzero" SVG_RULE_EVENODD = "evenodd" SVG_TRANSFORM_MATRIX = "matrix" SVG_TRANSFORM_TRANSLATE = "translate" SVG_TRANSFORM_SCALE = "scale" SVG_TRANSFORM_ROTATE = "rotate" SVG_TRANSFORM_SKEW_X = "skewx" SVG_TRANSFORM_SKEW_Y = "skewy" SVG_TRANSFORM_SKEW = "skew" SVG_TRANSFORM_TRANSLATE_X = "translatex" SVG_TRANSFORM_TRANSLATE_Y = "translatey" SVG_TRANSFORM_SCALE_X = "scalex" SVG_TRANSFORM_SCALE_Y = "scaley" SVG_VALUE_NONE = "none" SVG_VALUE_CURRENT_COLOR = "currentColor" SVG_VALUE_NON_SCALING_STROKE = "non-scaling-stroke" PATTERN_WS = r"[\s\t\n]*" PATTERN_COMMA = r"(?:\s*,\s*|\s+|(?=-))" PATTERN_COMMAWSP = r"[ ,\t\n\x09\x0A\x0C\x0D]+" PATTERN_FLOAT = r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?" PATTERN_LENGTH_UNITS = "cm|mm|Q|in|pt|pc|px|em|cx|ch|rem|vw|vh|vmin|vmax" PATTERN_ANGLE_UNITS = "deg|grad|rad|turn" PATTERN_TIME_UNITS = "s|ms" PATTERN_FREQUENCY_UNITS = "Hz|kHz" PATTERN_RESOLUTION_UNITS = "dpi|dpcm|dppx" PATTERN_PERCENT = "%" PATTERN_TRANSFORM = ( SVG_TRANSFORM_MATRIX + "|" + SVG_TRANSFORM_TRANSLATE + "|" + SVG_TRANSFORM_TRANSLATE_X + "|" + SVG_TRANSFORM_TRANSLATE_Y + "|" + SVG_TRANSFORM_SCALE + "|" + SVG_TRANSFORM_SCALE_X + "|" + SVG_TRANSFORM_SCALE_Y + "|" + SVG_TRANSFORM_ROTATE + "|" + SVG_TRANSFORM_SKEW + "|" + SVG_TRANSFORM_SKEW_X + "|" + SVG_TRANSFORM_SKEW_Y ) PATTERN_TRANSFORM_UNITS = ( PATTERN_LENGTH_UNITS + "|" + PATTERN_ANGLE_UNITS + "|" + PATTERN_PERCENT ) REGEX_IRI = re.compile(r"url\(#?(.*)\)") REGEX_DATA_URL = re.compile(r"^data:([^,]*),(.*)") REGEX_FLOAT = re.compile(PATTERN_FLOAT) REGEX_COORD_PAIR = re.compile( "(%s)%s(%s)" % (PATTERN_FLOAT, PATTERN_COMMA, PATTERN_FLOAT) ) REGEX_TRANSFORM_TEMPLATE = re.compile( r"(?u)(%s)%s\(([^)]+)\)" % (PATTERN_TRANSFORM, PATTERN_WS) ) REGEX_TRANSFORM_PARAMETER = re.compile( "(%s)%s(%s)?" % (PATTERN_FLOAT, PATTERN_WS, PATTERN_TRANSFORM_UNITS) ) REGEX_COLOR_HEX = re.compile(r"^#?([0-9A-Fa-f]{3,8})$") REGEX_COLOR_RGB = re.compile( r"rgba?\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*(?:,\s*(%s)\s*)?\)" % (PATTERN_FLOAT, PATTERN_FLOAT, PATTERN_FLOAT, PATTERN_FLOAT) ) REGEX_COLOR_RGB_PERCENT = re.compile( r"rgba?\(\s*(%s)%%\s*,\s*(%s)%%\s*,\s*(%s)%%\s*(?:,\s*(%s)\s*)?\)" % (PATTERN_FLOAT, PATTERN_FLOAT, PATTERN_FLOAT, PATTERN_FLOAT) ) REGEX_COLOR_HSL = re.compile( r"hsla?\(\s*(%s)\s*,\s*(%s)%%\s*,\s*(%s)%%\s*(?:,\s*(%s)\s*)?\)" % (PATTERN_FLOAT, PATTERN_FLOAT, PATTERN_FLOAT, PATTERN_FLOAT) ) REGEX_LENGTH = re.compile(r"(%s)([A-Za-z%%]*)" % PATTERN_FLOAT) REGEX_CSS_STYLE = re.compile(r"([^{]+)\s*\{\s*([^}]+)\s*\}") REGEX_CSS_FONT = re.compile( r"(?:(normal|italic|oblique)\s|(normal|small-caps)\s|(normal|bold|bolder|lighter|\d{3})\s|(normal|ultra-condensed|extra-condensed|condensed|semi-condensed|semi-expanded|expanded|extra-expanded|ultra-expanded)\s)*\s*(xx-small|x-small|small|medium|large|x-large|xx-large|larger|smaller|\d+(?:em|pt|pc|px|%))(?:/(xx-small|x-small|small|medium|large|x-large|xx-large|larger|smaller|\d+(?:em|pt|pc|px|%)))?\s*(.*),?\s+(serif|sans-serif|cursive|fantasy|monospace);?" ) svg_parse = [("COMMAND", r"[MmZzLlHhVvCcSsQqTtAa]"), ("SKIP", PATTERN_COMMAWSP)] svg_re = re.compile("|".join("(?P<%s>%s)" % pair for pair in svg_parse)) num_parse = [("FLOAT", PATTERN_FLOAT), ("CLOSE", r"[Zz]"), ("SKIP", PATTERN_COMMAWSP)] num_re = re.compile("|".join("(?P<%s>%s)" % pair for pair in num_parse)) flag_parse = [("FLAG", r"[01]"), ("SKIP", PATTERN_COMMAWSP)] flag_re = re.compile("|".join("(?P<%s>%s)" % pair for pair in flag_parse)) class SVGLexicalParser: def __init__(self): self.parser = None self.pathd = None self.pos = 0 self.limit = 0 self.inline_close = None def _command(self): while self.pos < self.limit: match = svg_re.match(self.pathd, self.pos) if match is None: return None # Did not match at command sequence. self.pos = match.end() kind = match.lastgroup if kind == "SKIP": continue return match.group() return None def _more(self): while self.pos < self.limit: match = num_re.match(self.pathd, self.pos) if match is None: return False kind = match.lastgroup if kind == "CLOSE": self.inline_close = match.group() return False if kind == "SKIP": # move skipped elements forward. self.pos = match.end() continue return True return None def _number(self): while self.pos < self.limit: match = num_re.match(self.pathd, self.pos) if match is None: break # No more matches. kind = match.lastgroup if kind == "CLOSE": # Inline Close self.inline_close = match.group() return None self.pos = match.end() if kind == "SKIP": continue return float(match.group()) return None def _flag(self): while self.pos < self.limit: match = flag_re.match(self.pathd, self.pos) if match is None: break # No more matches. self.pos = match.end() kind = match.lastgroup if kind == "SKIP": continue return bool(int(match.group())) return None def _coord(self): x = self._number() if x is None: return None y = self._number() if y is None: raise ValueError return x, y def _rcoord(self): position = self._coord() if position is None: return None current_pos = self.parser.current_point if current_pos is None: return position return position[0] + current_pos.x, position[1] + current_pos.y def parse(self, parser, pathd): self.parser = parser self.parser.start() self.pathd = pathd self.pos = 0 self.limit = len(pathd) while True: cmd = self._command() if cmd is None: return elif cmd == "z" or cmd == "Z": if self._more(): raise ValueError self.parser.closed(relative=cmd.islower()) self.inline_close = None continue elif cmd == "m": if not self._more(): raise ValueError coord = self._rcoord() self.parser.move(coord, relative=True) while self._more(): coord = self._rcoord() self.parser.line(coord, relative=True) elif cmd == "M": if not self._more(): raise ValueError coord = self._coord() self.parser.move(coord, relative=False) while self._more(): coord = self._coord() self.parser.line(coord, relative=False) elif cmd == "l": while True: coord = self._rcoord() if coord is None: coord = self.inline_close if coord is None: raise ValueError self.parser.line(coord, relative=True) if not self._more(): break elif cmd == "L": while True: coord = self._coord() if coord is None: coord = self.inline_close if coord is None: raise ValueError self.parser.line(coord, relative=False) if not self._more(): break elif cmd == "t": while True: coord = self._rcoord() if coord is None: coord = self.inline_close if coord is None: raise ValueError self.parser.smooth_quad(coord, relative=True) if not self._more(): break elif cmd == "T": while True: coord = self._coord() if coord is None: coord = self.inline_close if coord is None: raise ValueError self.parser.smooth_quad(coord, relative=False) if not self._more(): break elif cmd == "h": while True: value = self._number() self.parser.horizontal(value, relative=True) if not self._more(): break elif cmd == "H": while True: value = self._number() self.parser.horizontal(value, relative=False) if not self._more(): break elif cmd == "v": while True: value = self._number() self.parser.vertical(value, relative=True) if not self._more(): break elif cmd == "V": while self._more(): value = self._number() self.parser.vertical(value, relative=False) elif cmd == "c": while True: coord1, coord2, coord3 = ( self._rcoord(), self._rcoord(), self._rcoord(), ) if coord1 is None: coord1 = self.inline_close if coord1 is None: raise ValueError if coord2 is None: coord2 = self.inline_close if coord2 is None: raise ValueError if coord3 is None: coord3 = self.inline_close if coord3 is None: raise ValueError self.parser.cubic(coord1, coord2, coord3, relative=True) if not self._more(): break elif cmd == "C": while True: coord1, coord2, coord3 = self._coord(), self._coord(), self._coord() if coord1 is None: coord1 = self.inline_close if coord1 is None: raise ValueError if coord2 is None: coord2 = self.inline_close if coord2 is None: raise ValueError if coord3 is None: coord3 = self.inline_close if coord3 is None: raise ValueError self.parser.cubic(coord1, coord2, coord3, relative=False) if not self._more(): break elif cmd == "q": while True: coord1, coord2 = self._rcoord(), self._rcoord() if coord1 is None: coord1 = self.inline_close if coord1 is None: raise ValueError if coord2 is None: coord2 = self.inline_close if coord2 is None: raise ValueError self.parser.quad(coord1, coord2, relative=True) if not self._more(): break elif cmd == "Q": while True: coord1, coord2 = self._coord(), self._coord() if coord1 is None: coord1 = self.inline_close if coord1 is None: raise ValueError if coord2 is None: coord2 = self.inline_close if coord2 is None: raise ValueError self.parser.quad(coord1, coord2, relative=False) if not self._more(): break elif cmd == "s": while True: coord1, coord2 = self._rcoord(), self._rcoord() if coord1 is None: coord1 = self.inline_close if coord1 is None: raise ValueError if coord2 is None: coord2 = self.inline_close if coord2 is None: raise ValueError self.parser.smooth_cubic(coord1, coord2, relative=True) if not self._more(): break elif cmd == "S": while True: coord1, coord2 = self._coord(), self._coord() if coord1 is None: coord1 = self.inline_close if coord1 is None: raise ValueError if coord2 is None: coord2 = self.inline_close if coord2 is None: raise ValueError self.parser.smooth_cubic(coord1, coord2, relative=False) if not self._more(): break elif cmd == "a": while self._more(): rx, ry, rotation, arc, sweep, coord = ( self._number(), self._number(), self._number(), self._flag(), self._flag(), self._rcoord(), ) if sweep is None: raise ValueError if coord is None: coord = self.inline_close if coord is None: raise ValueError self.parser.arc(rx, ry, rotation, arc, sweep, coord, relative=True) elif cmd == "A": while self._more(): rx, ry, rotation, arc, sweep, coord = ( self._number(), self._number(), self._number(), self._flag(), self._flag(), self._coord(), ) if coord is None: coord = self.inline_close if coord is None: raise ValueError self.parser.arc(rx, ry, rotation, arc, sweep, coord, relative=False) self.parser.end() class Length(object): """ SVGLength as used in SVG Length class is lazy when solving values. Several conversion values are unknown by default and length simply stores that ambiguity. So we can have a length of 50% and without calling .value(relative_length=3000) it will simply store as 50%. Likewise you can have absolute values like 30cm or 20in which are not knowable in pixels unless a PPI value is supplied. We can say .value(relative_length=30cm, PPI=96) and solve this for a value like 12%. We can also convert values between knowable lengths. So 30cm is 300mm regardless whether we know how to convert this to pixels. 0% is 0 in any units or relative values. We can convert pixels to pc and pt without issue. We can convert vh, vw, vmax, vmin values if we know viewbox values. We can convert em values if we know the font_size. We can add values together if they are convertible units e.g. Length("20in") + Length("3cm"). If .value() cannot solve for the value with the given information then it will return a Length value. If it can be solved it will return a float. """ def __init__(self, *args, **kwargs): if len(args) == 1: value = args[0] if value is None: self.amount = None self.units = None return s = str(value) for m in REGEX_LENGTH.findall(s): self.amount = float(m[0]) self.units = m[1] return elif len(args) == 2: self.amount = args[0] self.units = args[1] return self.amount = 0.0 self.units = "" def __float__(self): if self.amount is None: return None if self.units == "pt": return self.amount * 1.3333 elif self.units == "pc": return self.amount * 16.0 return self.amount def __imul__(self, other): if isinstance(other, (int, float)): self.amount *= other return self if self.amount == 0.0: return 0.0 if isinstance(other, str): other = Length(other) if isinstance(other, Length): if other.amount == 0.0: self.amount = 0.0 return self if self.units == other.units: self.amount *= other.amount return self if self.units == "%": self.units = other.units self.amount = self.amount * other.amount / 100.0 return self elif other.units == "%": self.amount = self.amount * other.amount / 100.0 return self raise ValueError def __iadd__(self, other): if not isinstance(other, Length): other = Length(other) if self.units == other.units: self.amount += other.amount return self if self.amount == 0: self.amount = other.amount self.units = other.units return self if other.amount == 0: return self if self.units == "px" or self.units == "": if other.units == "px" or other.units == "": self.amount += other.amount elif other.units == "pt": self.amount += other.amount * 1.3333 elif other.units == "pc": self.amount += other.amount * 16.0 else: raise ValueError return self if self.units == "pt": if other.units == "px" or other.units == "": self.amount += other.amount / 1.3333 elif other.units == "pc": self.amount += other.amount * 12.0 else: raise ValueError return self elif self.units == "pc": if other.units == "px" or other.units == "": self.amount += other.amount / 16.0 elif other.units == "pt": self.amount += other.amount / 12.0 else: raise ValueError return self elif self.units == "cm": if other.units == "mm": self.amount += other.amount / 10.0 elif other.units == "in": self.amount += other.amount / 0.393701 else: raise ValueError return self elif self.units == "mm": if other.units == "cm": self.amount += other.amount * 10.0 elif other.units == "in": self.amount += other.amount / 0.0393701 else: raise ValueError return self elif self.units == "in": if other.units == "cm": self.amount += other.amount * 0.393701 elif other.units == "mm": self.amount += other.amount * 0.0393701 else: raise ValueError return self raise ValueError("%s units were not determined." % self.units) def __abs__(self): c = self.__copy__() c.amount = abs(c.amount) return c def __truediv__(self, other): if isinstance(other, (int, float)): c = self.__copy__() c.amount /= other return c if self.amount == 0.0: return 0.0 if isinstance(other, str): other = Length(other) if isinstance(other, Length): if self.units == other.units: q = self.amount / other.amount return q # no units if self.units == "px" or self.units == "": if other.units == "px" or other.units == "": return self.amount / other.amount elif other.units == "pt": return self.amount / (other.amount * 1.3333) elif other.units == "pc": return self.amount / (other.amount * 16.0) else: raise ValueError if self.units == "pt": if other.units == "px" or other.units == "": return self.amount / (other.amount / 1.3333) elif other.units == "pc": return self.amount / (other.amount * 12.0) else: raise ValueError if self.units == "pc": if other.units == "px" or other.units == "": return self.amount / (other.amount / 16.0) elif other.units == "pt": return self.amount / (other.amount / 12.0) else: raise ValueError if self.units == "cm": if other.units == "mm": return self.amount / (other.amount / 10.0) elif other.units == "in": return self.amount / (other.amount / 0.393701) else: raise ValueError if self.units == "mm": if other.units == "cm": return self.amount / (other.amount * 10.0) elif other.units == "in": return self.amount / (other.amount / 0.0393701) else: raise ValueError if self.units == "in": if other.units == "cm": return self.amount / (other.amount * 0.393701) elif other.units == "mm": return self.amount / (other.amount * 0.0393701) else: raise ValueError raise ValueError __floordiv__ = __truediv__ __div__ = __truediv__ def __lt__(self, other): return (self - other).amount < 0.0 def __le__(self, other): return (self - other).amount <= 0.0 def __gt__(self, other): return (self - other).amount > 0.0 def __ge__(self, other): return (self - other).amount >= 0.0 def __ne__(self, other): return not self.__eq__(other) def __add__(self, other): if isinstance(other, (str, float, int)): other = Length(other) c = self.__copy__() c += other return c __radd__ = __add__ def __mul__(self, other): c = copy(self) c *= other return c def __rdiv__(self, other): c = copy(self) c *= 1.0 / other.amount return c def __neg__(self): s = self.__copy__() s.amount = -s.amount return s def __isub__(self, other): if isinstance(other, (str, float, int)): other = Length(other) self += -other return self def __sub__(self, other): s = self.__copy__() s -= other return s def __rsub__(self, other): if isinstance(other, (str, float, int)): other = Length(other) return (-self) + other def __copy__(self): return Length(self.amount, self.units) __rmul__ = __mul__ def __repr__(self): return "Length('%s')" % (str(self)) def __str__(self): if self.amount is None: return SVG_VALUE_NONE return "%s%s" % (Length.str(self.amount), self.units) def __eq__(self, other): if other is None: return False s = self.in_pixels() if isinstance(other, (float, int)): if s is not None: return abs(s - other) <= ERROR else: return other == 0 and self.amount == 0 if isinstance(other, str): other = Length(other) if self.amount == other.amount and self.units == other.units: return True if s is not None: o = self.in_pixels() if abs(s - o) <= ERROR: return True s = self.in_inches() if s is not None: o = self.in_inches() if abs(s - o) <= ERROR: return True return False @property def value_in_units(self): return self.amount def in_pixels(self): if self.units == "px" or self.units == "": return self.amount if self.units == "pt": return self.amount / 1.3333 if self.units == "pc": return self.amount / 16.0 return None def in_inches(self): if self.units == "mm": return self.amount * 0.0393701 if self.units == "cm": return self.amount * 0.393701 if self.units == "in": return self.amount return None def to_mm( self, ppi=DEFAULT_PPI, relative_length=None, font_size=None, font_height=None, viewbox=None, ): value = self.value( ppi=ppi, relative_length=relative_length, font_size=font_size, font_height=font_height, viewbox=viewbox, ) v = value / (ppi * 0.0393701) return Length("%smm" % (Length.str(v))) def to_cm( self, ppi=DEFAULT_PPI, relative_length=None, font_size=None, font_height=None, viewbox=None, ): value = self.value( ppi=ppi, relative_length=relative_length, font_size=font_size, font_height=font_height, viewbox=viewbox, ) v = value / (ppi * 0.393701) return Length("%scm" % (Length.str(v))) def to_inch( self, ppi=DEFAULT_PPI, relative_length=None, font_size=None, font_height=None, viewbox=None, ): value = self.value( ppi=ppi, relative_length=relative_length, font_size=font_size, font_height=font_height, viewbox=viewbox, ) v = value / ppi return Length("%sin" % (Length.str(v))) def value( self, ppi=None, relative_length=None, font_size=None, font_height=None, viewbox=None, **kwargs, ): if self.amount is None: return None if self.units == "%": if relative_length is None: return self fraction = self.amount / 100.0 if isinstance(relative_length, (float, int)): return fraction * relative_length elif isinstance(relative_length, (str, Length)): length = relative_length * self if isinstance(length, Length): return length.value( ppi=ppi, font_size=font_size, font_height=font_height, viewbox=viewbox, ) return length return self if self.units == "mm": if ppi is None: return self return self.amount * ppi * 0.0393701 if self.units == "cm": if ppi is None: return self return self.amount * ppi * 0.393701 if self.units == "in": if ppi is None: return self return self.amount * ppi if self.units == "px" or self.units == "": return self.amount if self.units == "pt": return self.amount * 1.3333 if self.units == "pc": return self.amount * 16.0 if self.units == "em": if font_size is None: return self return self.amount * float(font_size) if self.units == "ex": if font_height is None: return self return self.amount * float(font_height) if self.units == "vw": if viewbox is None: return self v = Viewbox(viewbox) return self.amount * v.width / 100.0 if self.units == "vh": if viewbox is None: return self v = Viewbox(viewbox) return self.amount * v.height / 100.0 if self.units == "vmin": if viewbox is None: return self v = Viewbox(viewbox) m = min(v.height, v.height) return self.amount * m / 100.0 if self.units == "vmax": if viewbox is None: return self v = Viewbox(viewbox) m = max(v.height, v.height) return self.amount * m / 100.0 try: return float(self) except ValueError: return self @staticmethod def str(s): if s is None: return "n/a" if isinstance(s, Length): if s.units == "": s = s.amount else: a = "%.12f" % s.amount if "." in a: a = a.rstrip("0").rstrip(".") return "'%s%s'" % (a, s.units) try: s = "%.12f" % s except TypeError: return str(s) if "." in s: s = s.rstrip("0").rstrip(".") return s class Color(object): """ SVG Color Parsing Parses different forms of defining colors. Including keyword: https://www.w3.org/TR/SVG11/types.html#ColorKeywords """ def __init__(self, *args, **kwargs): self.value = 0 arglen = len(args) if arglen == 1: v = args[0] if isinstance(v, Color): self.value = v.value elif isinstance(v, int): self.rgb = v else: self.value = Color.parse(v) elif arglen == 2: v = args[0] if isinstance(v, Color): self.value = v.value elif isinstance(v, int): self.rgb = v else: self.value = Color.parse(v) self.opacity = float(args[1]) elif arglen == 3: r = args[0] g = args[1] b = args[2] self.value = Color.rgb_to_int(r, g, b) elif arglen == 4: r = args[0] g = args[1] b = args[2] opacity = args[3] / 255.0 self.value = Color.rgb_to_int(r, g, b, opacity) if "red" in kwargs: self.red = kwargs["red"] if "green" in kwargs: self.green = kwargs["green"] if "blue" in kwargs: self.blue = kwargs["blue"] if "alpha" in kwargs: self.alpha = kwargs["alpha"] if "opacity" in kwargs: self.opacity = kwargs["opacity"] if "r" in kwargs: self.red = kwargs["r"] if "g" in kwargs: self.green = kwargs["g"] if "b" in kwargs: self.blue = kwargs["b"] if "rgb" in kwargs: self.rgb = kwargs["rgb"] if "bgr" in kwargs: self.bgr = kwargs["bgr"] if "argb" in kwargs: self.argb = kwargs["argb"] if "rgba" in kwargs: self.rgba = kwargs["rgba"] if "h" in kwargs: self.hue = kwargs["h"] if "s" in kwargs: self.saturation = kwargs["s"] if "l" in kwargs: self.lightness = kwargs["l"] if "hue" in kwargs: self.hue = kwargs["hue"] if "saturation" in kwargs: self.saturation = kwargs["saturation"] if "lightness" in kwargs: self.lightness = kwargs["lightness"] def __int__(self): return self.value def __str__(self): if self.value is None: return str(self.value) return self.hex def __repr__(self): return "Color('%s')" % str(self) def __eq__(self, other): if self is other: return True first = self.value second = other if isinstance(second, str): second = Color(second) if isinstance(second, Color): second = second.value if first is None: return second is None if second is None: return first is None return first & 0xFFFFFFFF == second & 0xFFFFFFFF def __ne__(self, other): return not self == other def __abs__(self): # Return opaque color. if self.value is None: return Color(self.value) return Color(self.red, self.green, self.blue) @staticmethod def rgb_to_int(r, g, b, opacity=1.0): if opacity > 1: opacity = 1.0 if opacity < 0: opacity = 0 r = Color.crimp(r) g = Color.crimp(g) b = Color.crimp(b) a = int(round(opacity * 255.0)) a = Color.crimp(a) r <<= 24 g <<= 16 b <<= 8 c = r | g | b | a return c @staticmethod def hsl_to_int(h, s, l, opacity=1.0): def hue_2_rgb(v1, v2, vh): if vh < 0: vh += 1 if vh > 1: vh -= 1 if 6.0 * vh < 1.0: return v1 + (v2 - v1) * 6.0 * vh if 2.0 * vh < 1: return v2 if 3 * vh < 2.0: return v1 + (v2 - v1) * ((2.0 / 3.0) - vh) * 6.0 return v1 if s == 0.0: r = 255.0 * l g = 255.0 * l b = 255.0 * l else: if l < 0.5: v2 = l * (1.0 + s) else: v2 = (l + s) - (s * l) v1 = 2 * l - v2 r = 255.0 * hue_2_rgb(v1, v2, h + (1.0 / 3.0)) g = 255.0 * hue_2_rgb(v1, v2, h) b = 255.0 * hue_2_rgb(v1, v2, h - (1.0 / 3.0)) value = Color.rgb_to_int(r, g, b, opacity=opacity) return value @staticmethod def parse(color_string): """Parse SVG color, will return a set value.""" if color_string is None or color_string == SVG_VALUE_NONE: return None match = REGEX_COLOR_HEX.match(color_string) if match: return Color.parse_color_hex(color_string) match = REGEX_COLOR_RGB.match(color_string) if match: return Color.parse_color_rgb(match.groups()) match = REGEX_COLOR_RGB_PERCENT.match(color_string) if match: return Color.parse_color_rgbp(match.groups()) match = REGEX_COLOR_HSL.match(color_string) if match: return Color.parse_color_hsl(match.groups()) return Color.parse_color_lookup(color_string) @staticmethod def parse_color_lookup(v): """Parse SVG Color by Keyword on dictionary lookup""" if not isinstance(v, str): return Color.rgb_to_int(0, 0, 0) else: v = v.replace(" ", "").lower() if v == "transparent": return Color.rgb_to_int(0, 0, 0, 0.0) if v == "aliceblue": return Color.rgb_to_int(250, 248, 255) if v == "aliceblue": return Color.rgb_to_int(240, 248, 255) if v == "antiquewhite": return Color.rgb_to_int(250, 235, 215) if v == "aqua": return Color.rgb_to_int(0, 255, 255) if v == "aquamarine": return Color.rgb_to_int(127, 255, 212) if v == "azure": return Color.rgb_to_int(240, 255, 255) if v == "beige": return Color.rgb_to_int(245, 245, 220) if v == "bisque": return Color.rgb_to_int(255, 228, 196) if v == "black": return Color.rgb_to_int(0, 0, 0) if v == "blanchedalmond": return Color.rgb_to_int(255, 235, 205) if v == "blue": return Color.rgb_to_int(0, 0, 255) if v == "blueviolet": return Color.rgb_to_int(138, 43, 226) if v == "brown": return Color.rgb_to_int(165, 42, 42) if v == "burlywood": return Color.rgb_to_int(222, 184, 135) if v == "cadetblue": return Color.rgb_to_int(95, 158, 160) if v == "chartreuse": return Color.rgb_to_int(127, 255, 0) if v == "chocolate": return Color.rgb_to_int(210, 105, 30) if v == "coral": return Color.rgb_to_int(255, 127, 80) if v == "cornflowerblue": return Color.rgb_to_int(100, 149, 237) if v == "cornsilk": return Color.rgb_to_int(255, 248, 220) if v == "crimson": return Color.rgb_to_int(220, 20, 60) if v == "cyan": return Color.rgb_to_int(0, 255, 255) if v == "darkblue": return Color.rgb_to_int(0, 0, 139) if v == "darkcyan": return Color.rgb_to_int(0, 139, 139) if v == "darkgoldenrod": return Color.rgb_to_int(184, 134, 11) if v == "darkgray": return Color.rgb_to_int(169, 169, 169) if v == "darkgreen": return Color.rgb_to_int(0, 100, 0) if v == "darkgrey": return Color.rgb_to_int(169, 169, 169) if v == "darkkhaki": return Color.rgb_to_int(189, 183, 107) if v == "darkmagenta": return Color.rgb_to_int(139, 0, 139) if v == "darkolivegreen": return Color.rgb_to_int(85, 107, 47) if v == "darkorange": return Color.rgb_to_int(255, 140, 0) if v == "darkorchid": return Color.rgb_to_int(153, 50, 204) if v == "darkred": return Color.rgb_to_int(139, 0, 0) if v == "darksalmon": return Color.rgb_to_int(233, 150, 122) if v == "darkseagreen": return Color.rgb_to_int(143, 188, 143) if v == "darkslateblue": return Color.rgb_to_int(72, 61, 139) if v == "darkslategray": return Color.rgb_to_int(47, 79, 79) if v == "darkslategrey": return Color.rgb_to_int(47, 79, 79) if v == "darkturquoise": return Color.rgb_to_int(0, 206, 209) if v == "darkviolet": return Color.rgb_to_int(148, 0, 211) if v == "deeppink": return Color.rgb_to_int(255, 20, 147) if v == "deepskyblue": return Color.rgb_to_int(0, 191, 255) if v == "dimgray": return Color.rgb_to_int(105, 105, 105) if v == "dimgrey": return Color.rgb_to_int(105, 105, 105) if v == "dodgerblue": return Color.rgb_to_int(30, 144, 255) if v == "firebrick": return Color.rgb_to_int(178, 34, 34) if v == "floralwhite": return Color.rgb_to_int(255, 250, 240) if v == "forestgreen": return Color.rgb_to_int(34, 139, 34) if v == "fuchsia": return Color.rgb_to_int(255, 0, 255) if v == "gainsboro": return Color.rgb_to_int(220, 220, 220) if v == "ghostwhite": return Color.rgb_to_int(248, 248, 255) if v == "gold": return Color.rgb_to_int(255, 215, 0) if v == "goldenrod": return Color.rgb_to_int(218, 165, 32) if v == "gray": return Color.rgb_to_int(128, 128, 128) if v == "grey": return Color.rgb_to_int(128, 128, 128) if v == "green": return Color.rgb_to_int(0, 128, 0) if v == "greenyellow": return Color.rgb_to_int(173, 255, 47) if v == "honeydew": return Color.rgb_to_int(240, 255, 240) if v == "hotpink": return Color.rgb_to_int(255, 105, 180) if v == "indianred": return Color.rgb_to_int(205, 92, 92) if v == "indigo": return Color.rgb_to_int(75, 0, 130) if v == "ivory": return Color.rgb_to_int(255, 255, 240) if v == "khaki": return Color.rgb_to_int(240, 230, 140) if v == "lavender": return Color.rgb_to_int(230, 230, 250) if v == "lavenderblush": return Color.rgb_to_int(255, 240, 245) if v == "lawngreen": return Color.rgb_to_int(124, 252, 0) if v == "lemonchiffon": return Color.rgb_to_int(255, 250, 205) if v == "lightblue": return Color.rgb_to_int(173, 216, 230) if v == "lightcoral": return Color.rgb_to_int(240, 128, 128) if v == "lightcyan": return Color.rgb_to_int(224, 255, 255) if v == "lightgoldenrodyellow": return Color.rgb_to_int(250, 250, 210) if v == "lightgray": return Color.rgb_to_int(211, 211, 211) if v == "lightgreen": return Color.rgb_to_int(144, 238, 144) if v == "lightgrey": return Color.rgb_to_int(211, 211, 211) if v == "lightpink": return Color.rgb_to_int(255, 182, 193) if v == "lightsalmon": return Color.rgb_to_int(255, 160, 122) if v == "lightseagreen": return Color.rgb_to_int(32, 178, 170) if v == "lightskyblue": return Color.rgb_to_int(135, 206, 250) if v == "lightslategray": return Color.rgb_to_int(119, 136, 153) if v == "lightslategrey": return Color.rgb_to_int(119, 136, 153) if v == "lightsteelblue": return Color.rgb_to_int(176, 196, 222) if v == "lightyellow": return Color.rgb_to_int(255, 255, 224) if v == "lime": return Color.rgb_to_int(0, 255, 0) if v == "limegreen": return Color.rgb_to_int(50, 205, 50) if v == "linen": return Color.rgb_to_int(250, 240, 230) if v == "magenta": return Color.rgb_to_int(255, 0, 255) if v == "maroon": return Color.rgb_to_int(128, 0, 0) if v == "mediumaquamarine": return Color.rgb_to_int(102, 205, 170) if v == "mediumblue": return Color.rgb_to_int(0, 0, 205) if v == "mediumorchid": return Color.rgb_to_int(186, 85, 211) if v == "mediumpurple": return Color.rgb_to_int(147, 112, 219) if v == "mediumseagreen": return Color.rgb_to_int(60, 179, 113) if v == "mediumslateblue": return Color.rgb_to_int(123, 104, 238) if v == "mediumspringgreen": return Color.rgb_to_int(0, 250, 154) if v == "mediumturquoise": return Color.rgb_to_int(72, 209, 204) if v == "mediumvioletred": return Color.rgb_to_int(199, 21, 133) if v == "midnightblue": return Color.rgb_to_int(25, 25, 112) if v == "mintcream": return Color.rgb_to_int(245, 255, 250) if v == "mistyrose": return Color.rgb_to_int(255, 228, 225) if v == "moccasin": return Color.rgb_to_int(255, 228, 181) if v == "navajowhite": return Color.rgb_to_int(255, 222, 173) if v == "navy": return Color.rgb_to_int(0, 0, 128) if v == "oldlace": return Color.rgb_to_int(253, 245, 230) if v == "olive": return Color.rgb_to_int(128, 128, 0) if v == "olivedrab": return Color.rgb_to_int(107, 142, 35) if v == "orange": return Color.rgb_to_int(255, 165, 0) if v == "orangered": return Color.rgb_to_int(255, 69, 0) if v == "orchid": return Color.rgb_to_int(218, 112, 214) if v == "palegoldenrod": return Color.rgb_to_int(238, 232, 170) if v == "palegreen": return Color.rgb_to_int(152, 251, 152) if v == "paleturquoise": return Color.rgb_to_int(175, 238, 238) if v == "palevioletred": return Color.rgb_to_int(219, 112, 147) if v == "papayawhip": return Color.rgb_to_int(255, 239, 213) if v == "peachpuff": return Color.rgb_to_int(255, 218, 185) if v == "peru": return Color.rgb_to_int(205, 133, 63) if v == "pink": return Color.rgb_to_int(255, 192, 203) if v == "plum": return Color.rgb_to_int(221, 160, 221) if v == "powderblue": return Color.rgb_to_int(176, 224, 230) if v == "purple": return Color.rgb_to_int(128, 0, 128) if v == "red": return Color.rgb_to_int(255, 0, 0) if v == "rosybrown": return Color.rgb_to_int(188, 143, 143) if v == "royalblue": return Color.rgb_to_int(65, 105, 225) if v == "saddlebrown": return Color.rgb_to_int(139, 69, 19) if v == "salmon": return Color.rgb_to_int(250, 128, 114) if v == "sandybrown": return Color.rgb_to_int(244, 164, 96) if v == "seagreen": return Color.rgb_to_int(46, 139, 87) if v == "seashell": return Color.rgb_to_int(255, 245, 238) if v == "sienna": return Color.rgb_to_int(160, 82, 45) if v == "silver": return Color.rgb_to_int(192, 192, 192) if v == "skyblue": return Color.rgb_to_int(135, 206, 235) if v == "slateblue": return Color.rgb_to_int(106, 90, 205) if v == "slategray": return Color.rgb_to_int(112, 128, 144) if v == "slategrey": return Color.rgb_to_int(112, 128, 144) if v == "snow": return Color.rgb_to_int(255, 250, 250) if v == "springgreen": return Color.rgb_to_int(0, 255, 127) if v == "steelblue": return Color.rgb_to_int(70, 130, 180) if v == "tan": return Color.rgb_to_int(210, 180, 140) if v == "teal": return Color.rgb_to_int(0, 128, 128) if v == "thistle": return Color.rgb_to_int(216, 191, 216) if v == "tomato": return Color.rgb_to_int(255, 99, 71) if v == "turquoise": return Color.rgb_to_int(64, 224, 208) if v == "violet": return Color.rgb_to_int(238, 130, 238) if v == "wheat": return Color.rgb_to_int(245, 222, 179) if v == "white": return Color.rgb_to_int(255, 255, 255) if v == "whitesmoke": return Color.rgb_to_int(245, 245, 245) if v == "yellow": return Color.rgb_to_int(255, 255, 0) if v == "yellowgreen": return Color.rgb_to_int(154, 205, 50) try: return int(v) except ValueError: return Color.rgb_to_int(0, 0, 0) @staticmethod def parse_color_hex(hex_string): """Parse SVG Color by Hex String""" h = hex_string.lstrip("#") size = len(h) if size == 8: return int(h[:8], 16) elif size == 6: s = "{0}FF".format(h[:6]) v = int(s, 16) return v elif size == 4: s = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3] return int(s, 16) elif size == 3: s = "{0}{0}{1}{1}{2}{2}FF".format(h[0], h[1], h[2]) v = int(s, 16) return v return Color.rgb_to_int(0, 0, 0) @staticmethod def parse_color_rgb(values): """Parse SVG Color, RGB value declarations""" r = int(values[0]) g = int(values[1]) b = int(values[2]) if values[3] is not None: opacity = float(values[3]) else: opacity = 1 return Color.rgb_to_int(r, g, b, opacity) @staticmethod def parse_color_rgbp(values): """Parse SVG color, RGB percent value declarations""" ratio = 255.0 / 100.0 r = round(float(values[0]) * ratio) g = round(float(values[1]) * ratio) b = round(float(values[2]) * ratio) if values[3] is not None: opacity = float(values[3]) else: opacity = 1 return Color.rgb_to_int(r, g, b, opacity) @staticmethod def parse_color_hsl(values): """Parse SVG color, HSL value declarations""" h = Angle.parse(values[0]) h = h.as_turns s = float(values[1]) / 100.0 if s > 1: s = 1.0 if s < 0: s = 0.0 l = float(values[2]) / 100.0 if l > 1: l = 1.0 if l < 0: l = 0.0 if values[3] is not None: opacity = float(values[3]) else: opacity = 1 return Color.hsl_to_int(h, s, l, opacity) @classmethod def distinct(cls, index): """ Produces a deterministic distinct color for the given index. """ def _pattern(pattern: int): n = int(pattern ** (1.0 / 3.0)) pattern -= n * n * n p = [n] * 3 if pattern == 0: return p pattern -= 1 v = int(pattern % 3) pattern = int(pattern // 3) if pattern < n: p[v] = pattern % n return p pattern -= n p[v] = pattern // n v += 1 p[v % 3] = pattern % n return p def _8bit_reverse(r: int): value = r - 1 v = 0 for i in range(0, 8): v = v | (value & 1) v <<= 1 value >>= 1 v >>= 1 return v & 0xFF p = _pattern(index) return Color( _8bit_reverse(p[0]), _8bit_reverse(p[1]), _8bit_reverse(p[2]), ) @property def rgb(self): if self.value is None: return None return self.value >> 8 @rgb.setter def rgb(self, rgb): rgb <<= 8 rgb |= 0xFF self.value = rgb @property def bgr(self): if self.value is None: return None return self.blue << 16 | self.green << 8 | self.red @bgr.setter def bgr(self, bgr): self.value = 0 self.alpha = 0xFF self.red = bgr & 0xFF self.green = (bgr >> 8) & 0xFF self.blue = (bgr >> 16) & 0xFF @property def rgba(self): return self.value @rgba.setter def rgba(self, rgba): self.value = rgba @property def argb(self): if self.value is None: return None return ((self.value >> 8) & 0xFFFFFF) | (self.alpha << 24) @argb.setter def argb(self, argb): self.value = ((argb << 8) & 0xFFFFFF00) | (argb >> 24 & 0xFF) @property def opacity(self): return self.alpha / 255.0 if self.value is not None else None @opacity.setter def opacity(self, opacity): if self.value is None: raise ValueError a = int(round(opacity * 255.0)) a = Color.crimp(a) self.alpha = a @property def alpha(self): return self.value & 0xFF if self.value is not None else None @alpha.setter def alpha(self, a): if self.value is None: raise ValueError a = Color.crimp(a) self.value &= ~0xFF self.value |= a @property def red(self): return (self.value >> 24) & 0xFF if self.value is not None else None @red.setter def red(self, r): if self.value is None: raise ValueError r = Color.crimp(r) self.value &= ~0xFF000000 self.value |= r << 24 @property def green(self): return (self.value >> 16) & 0xFF if self.value is not None else None @green.setter def green(self, g): if self.value is None: raise ValueError g = Color.crimp(g) self.value &= ~0xFF0000 self.value |= g << 16 @property def blue(self): return (self.value >> 8) & 0xFF if self.value is not None else None @blue.setter def blue(self, b): if self.value is None: raise ValueError b = Color.crimp(b) self.value &= ~0xFF00 self.value |= b << 8 @property def hexa(self): if self.value is None: return None return "#%02x%02x%02x%02x" % (self.red, self.green, self.blue, self.alpha) @property def hexrgb(self): if self.value is None: return None return "#%02x%02x%02x" % (self.red, self.green, self.blue) @property def hex(self): if self.value is None: return None if self.alpha == 0xFF: return self.hexrgb else: return self.hexa @property def hue(self): if self.value is None: return None r = self.red / 255.0 g = self.green / 255.0 b = self.blue / 255.0 var_min = min(r, g, b) var_max = max(r, g, b) delta_max = var_max - var_min if delta_max == 0: return 0 dr = (((var_max - r) / 6.0) + delta_max / 2.0) / delta_max dg = (((var_max - g) / 6.0) + delta_max / 2.0) / delta_max db = (((var_max - b) / 6.0) + delta_max / 2.0) / delta_max if r == var_max: h = db - dg elif g == var_max: h = (1.0 / 3.0) + dr - db else: # db == max_v h = (2.0 / 3.0) + dg - dr if h < 0: h += 1 if h > 1: h -= 1 return Angle.turns(h).as_degrees @hue.setter def hue(self, v): if self.value is None: raise ValueError h, s, l = self.hsl self.hsl = v, s, l @property def saturation(self): if self.value is None: return None r = self.red / 255.0 g = self.green / 255.0 b = self.blue / 255.0 min_v = min(r, g, b) max_v = max(r, g, b) delta = max_v - min_v if max_v == min_v: return 0.0 if (max_v + min_v) < 1: return delta / (max_v + min_v) else: return delta / (2.0 - max_v - min_v) @saturation.setter def saturation(self, v): if self.value is None: raise ValueError h, s, l = self.hsl self.hsl = h, v, l @property def lightness(self): if self.value is None: return None r = self.red / 255.0 g = self.green / 255.0 b = self.blue / 255.0 min_v = min(r, g, b) max_v = max(r, g, b) return (max_v + min_v) / 2.0 @lightness.setter def lightness(self, v): if self.value is None: raise ValueError h, s, l = self.hsl self.hsl = h, s, v @property def intensity(self): if self.value is None: return None r = self.red g = self.green b = self.blue return (r + b + g) / 768.0 @property def brightness(self): if self.value is None: return None r = self.red g = self.green b = self.blue cmax = max(r, g, b) return cmax / 255.0 @property def blackness(self): if self.value is None: return None return 1.0 - self.brightness @property def luminance(self): if self.value is None: return None r = self.red / 255.0 g = self.green / 255.0 b = self.blue / 255.0 return r * 0.3 + g * 0.59 + b * 0.11 @property def luma(self): if self.value is None: return None r = self.red / 255.0 g = self.green / 255.0 b = self.blue / 255.0 return r * 0.2126 + g * 0.7152 + b * 0.0722 @staticmethod def over(c1, c2): """ Porter Duff Alpha compositing operation over. Returns c1 over c2. This is the standard painter algorithm. """ if isinstance(c1, str): c1 = Color.parse(c1) elif isinstance(c1, int): c1 = Color(c1) if isinstance(c2, str): c2 = Color.parse(c2) elif isinstance(c2, int): c2 = Color(c2) r1 = c1.red g1 = c1.green b1 = c1.blue a1 = c1.alpha if a1 == 255: return c1.value if a1 == 0: return c2.value r2 = c2.red g2 = c2.green b2 = c2.blue a2 = c2.alpha q = 255.0 - a1 sr = r1 * a1 * 255.0 + r2 * a2 * q sg = g1 * a1 * 255.0 + g2 * a2 * q sb = b1 * a1 * 255.0 + b2 * a2 * q sa = a1 * 255.0 + a2 * q sr /= sa sg /= sa sb /= sa sa /= 255.0 * 255.0 return Color.rgb_to_int(sr, sg, sb, sa) @staticmethod def distance(c1, c2): return sqrt(Color.distance_sq(c1, c2)) @staticmethod def distance_sq(c1, c2): """ Function returns the square of colordistance. The square of the color distance will always be closer than the square of another color distance. Rather than naive Euclidean distance we use Compuphase's Redmean color distance. https://www.compuphase.com/cmetric.htm It's computationally simple, and empirical tests finds it to be on par with LabDE2000. :param c1: first color :param c2: second color :return: square of color distance """ if isinstance(c1, str): c1 = Color(c1) elif isinstance(c1, int): c1 = Color(c1) if isinstance(c2, str): c2 = Color(c2) elif isinstance(c2, int): c2 = Color(c2) red_mean = int((c1.red + c2.red) / 2.0) r = c1.red - c2.red g = c1.green - c2.green b = c1.blue - c2.blue return ( (((512 + red_mean) * r * r) >> 8) + (4 * g * g) + (((767 - red_mean) * b * b) >> 8) ) @staticmethod def crimp(v): if v > 255: return 255 if v < 0: return 0 return int(v) @property def hsl(self): if self.value is None: return None return self.hue, self.saturation, self.lightness @hsl.setter def hsl(self, value): if not isinstance(value, (tuple, list)): return h = value[0] s = value[1] l = value[2] self.value = Color.hsl_to_int(h, s, l, 1.0) def distance_to(self, other): return Color.distance(self, other) def blend(self, other, opacity=None): """ Blends the given color with the current color. """ if opacity is None: self.value = Color.over(other, self) else: color = Color(other) color.opacity = opacity self.value = Color.over(color, self) class Point: """Point is a general subscriptable point class with .x and .y as well as [0] and [1] For compatibility with regebro svg.path we accept complex numbers as points x + yj, and provide .real and .imag as properties. As well as float and integer values as (v,0) elements. With regard to SVG 7.15.1 defining SVGPoint this class provides for matrix transformations. Points are only positions in real Euclidean space. This class is not intended to interact with the Length class. """ def __init__(self, x, y=None): if x is not None and y is None: if isinstance(x, str): string_x, string_y = REGEX_COORD_PAIR.findall(x)[0] self.x = float(string_x) self.y = float(string_y) return try: # Try .x .y self.y = x.y self.x = x.x return except AttributeError: pass try: # try subscription. self.y = x[1] self.x = x[0] return except TypeError: pass try: # try .imag .real complex values. self.y = x.imag self.x = x.real return except AttributeError: # Unknown. raise TypeError self.x = x self.y = y def __key(self): return (self.x, self.y) def __hash__(self): return hash(self.__key()) def __eq__(self, other): if other is None: return False try: if not isinstance(other, Point): other = Point(other) except Exception: return NotImplemented return abs(self.x - other.x) <= ERROR and abs(self.y - other.y) <= ERROR def __ne__(self, other): return not self == other def __len__(self): return 2 def __getitem__(self, item): if item == 0: return self.x elif item == 1: return self.y else: raise IndexError def __setitem__(self, key, value): if key == 0: self.x = value elif key == 1: self.y = value else: raise IndexError def __repr__(self): x_str = Length.str(self.x) y_str = Length.str(self.y) return "Point(%s,%s)" % (x_str, y_str) def __copy__(self): return Point(self.x, self.y) def __str__(self): try: x_str = "%.12G" % self.x except TypeError: return self.__repr__() if "." in x_str: x_str = x_str.rstrip("0").rstrip(".") y_str = "%.12G" % self.y if "." in y_str: y_str = y_str.rstrip("0").rstrip(".") return "%s,%s" % (x_str, y_str) def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): v = other.point_in_matrix_space(self) self.x = v.x self.y = v.y return self try: c = complex(self) * complex(other.x, other.y) self.x = c.real self.y = c.imag return self except AttributeError: pass try: c = complex(self) * complex(other[0], other[1]) self.x = c.real self.y = c.imag return self except (TypeError, IndexError): pass try: c = complex(self) * complex(other.real, other.imag) self.x = c.real self.y = c.imag return self except AttributeError: pass try: self.x *= other self.y *= other return self except Exception: return NotImplemented def __mul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): return other.point_in_matrix_space(self) try: return Point(complex(self) * complex(other.x, other.y)) except AttributeError: pass try: return Point(complex(self) * complex(other[0], other[1])) except (TypeError, IndexError): pass try: return Point(complex(self) * complex(other.real, other.imag)) except AttributeError: pass try: return Point(self.x * other, self.y * other) except Exception: return NotImplemented __rmul__ = __mul__ def __iadd__(self, other): try: self.x += other.x self.y += other.y return self except AttributeError: pass try: self.y += other[1] self.x += other[0] return self except (TypeError, IndexError): pass try: self.x += other.real self.y += other.imag return self except AttributeError: pass try: self.x += other return self except Exception: return NotImplemented def __add__(self, other): try: x = self.x + other.x y = self.y + other.y return Point(x, y) except AttributeError: pass try: y = self.y + other[1] x = self.x + other[0] return Point(x, y) except (TypeError, IndexError): pass try: x = self.x + other.real y = self.y + other.imag return Point(x, y) except AttributeError: pass if isinstance(other, (float, int)): x = self.x + other return Point(x, self.y) return NotImplemented __radd__ = __add__ def __isub__(self, other): try: self.x -= other.x self.y -= other.y return self except AttributeError: pass try: self.y -= other[1] self.x -= other[0] return self except (TypeError, IndexError): pass try: self.x -= other.real self.y -= other.imag return self except AttributeError: pass try: self.x -= other return self except Exception: return NotImplemented def __sub__(self, other): try: x = self.x - other.x y = self.y - other.y return Point(x, y) except AttributeError: pass try: y = self.y - other[1] x = self.x - other[0] return Point(x, y) except (TypeError, IndexError): pass try: x = self.x - other.real y = self.y - other.imag return Point(x, y) except AttributeError: pass if isinstance(other, (float, int)): x = self.x - other return Point(x, self.y) return NotImplemented def __rsub__(self, other): try: x = other.x - self.x y = other.y - self.y return Point(x, y) except AttributeError: pass try: y = other[1] - self.y x = other[0] - self.x return Point(x, y) except (TypeError, IndexError): pass try: x = other.real - self.x y = other.imag - self.y return Point(x, y) except AttributeError: pass if isinstance(other, (float, int)): x = other - self.x return Point(x, self.y) return NotImplemented def __complex__(self): return self.x + self.y * 1j def __abs__(self): return hypot(self.x, self.y) def __pow__(self, other): r_raised = abs(self) ** other argz_multiplied = self.argz() * other real_part = round(r_raised * cos(argz_multiplied)) imag_part = round(r_raised * sin(argz_multiplied)) return self.__class__(real_part, imag_part) def conjugate(self): return self.__class__(self.real, -self.imag) def argz(self): return atan(self.imag / self.real) @property def real(self): """Emulate svg.path use of complex numbers""" return self.x @property def imag(self): """Emulate svg.path use of complex numbers""" return self.y def matrix_transform(self, matrix): self *= matrix return self def move_towards(self, p2, amount=1): if not isinstance(p2, Point): p2 = Point(p2) self += amount * (p2 - self) def distance_to(self, p2): return abs(self - p2) def angle_to(self, p2): p = p2 - self return Angle.radians(atan2(p.y, p.x)) def polar_to(self, angle, distance): q = Point.polar(self, angle, distance) self.x = q.x self.y = q.y return self def reflected_across(self, p): return p + (p - self) @staticmethod def orientation(p, q, r): """Determine the clockwise, linear, or counterclockwise orientation of the given points""" val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]) if val == 0: return 0 elif val > 0: return 1 else: return 2 @staticmethod def convex_hull(pts): if len(pts) == 0: return points = sorted(set(pts), key=lambda p: p[0]) first_point_on_hull = points[0] point_on_hull = first_point_on_hull while True: yield point_on_hull endpoint = point_on_hull for t in points: if ( point_on_hull is endpoint or Point.orientation(point_on_hull, t, endpoint) == 2 ): endpoint = t point_on_hull = endpoint if first_point_on_hull is point_on_hull: break @staticmethod def distance(p1, p2): dx = p1[0] - p2[0] dy = p1[1] - p2[1] dx *= dx dy *= dy return sqrt(dx + dy) @staticmethod def polar(p1, angle, r): dx = cos(angle) * r dy = sin(angle) * r return Point(p1[0] + dx, p1[1] + dy) @staticmethod def angle(p1, p2): return Angle.radians(atan2(p2[1] - p1[1], p2[0] - p1[0])) @staticmethod def towards(p1, p2, amount): tx = amount * (p2[0] - p1[0]) + p1[0] ty = amount * (p2[1] - p1[1]) + p1[1] return Point(tx, ty) class Angle(float): """CSS Angle defines as used in SVG/CSS""" def __repr__(self): return "Angle(%.12f)" % self def __copy__(self): return Angle(self) def __eq__(self, other): # Python 2 c1 = abs((self % tau) - (other % tau)) <= 1e-11 return c1 def normalized(self): return Angle(self % tau) @classmethod def parse(cls, angle_string): if not isinstance(angle_string, str): return angle_string = angle_string.lower() if angle_string.endswith("deg"): return Angle.degrees(float(angle_string[:-3])) if angle_string.endswith("grad"): return Angle.gradians(float(angle_string[:-4])) if angle_string.endswith( "rad" ): # Must be after 'grad' since 'grad' ends with 'rad' too. return Angle.radians(float(angle_string[:-3])) if angle_string.endswith("turn"): return Angle.turns(float(angle_string[:-4])) if angle_string.endswith("%"): return Angle.turns(float(angle_string[:-1]) / 100.0) return Angle.degrees(float(angle_string)) @classmethod def radians(cls, radians): return cls(radians) @classmethod def degrees(cls, degrees): return cls(tau * degrees / 360.0) @classmethod def gradians(cls, gradians): return cls(tau * gradians / 400.0) @classmethod def turns(cls, turns): return cls(tau * turns) @property def as_radians(self): return self @property def as_degrees(self): return self * 360.0 / tau @property def as_positive_degrees(self): v = self.as_degrees while v < 0: v += 360.0 return v @property def as_gradians(self): return self * 400.0 / tau @property def as_turns(self): return self / tau def is_orthogonal(self): return (self % (tau / 4.0)) == 0 class Matrix: """ " Provides svg matrix interfacing. SVG 7.15.3 defines the matrix form as: [a c e] [b d f] While e and f are defined as floats, they can be for limited periods defined as a Length. With regard to CSS, it's reasonable to perform operations like 'transform(20cm, 20cm)' and expect these to be treated consistently. Performing other matrix operations in a consistent way. However, render must be called to change these parameters into float locations prior to any operation which might be used to transform a point or polyline or path object. """ def __init__(self, *components, **kwargs): self.a = 1.0 self.b = 0.0 self.c = 0.0 self.d = 1.0 self.e = 0.0 self.f = 0.0 len_args = len(components) if len_args == 0: pass elif len_args == 1: m = components[0] if isinstance(m, str): self.parse(m) self.render(**kwargs) else: self.a = m[0] self.b = m[1] self.c = m[2] self.d = m[3] self.e = m[4] self.f = m[5] else: self.a = components[0] self.b = components[1] self.c = components[2] self.d = components[3] self.e = components[4] self.f = components[5] self.render(**kwargs) def __ne__(self, other): return not self.__eq__(other) def __eq__(self, other): if other is None: return False if isinstance(other, str): other = Matrix(other) if not isinstance(other, Matrix): return False if abs(self.a - other.a) > 1e-12: return False if abs(self.b - other.b) > 1e-12: return False if abs(self.c - other.c) > 1e-12: return False if abs(self.d - other.d) > 1e-12: return False if self.e != other.e and abs(self.e - other.e) > 1e-12: return False if self.f != other.f and abs(self.f - other.f) > 1e-12: return False return True def __len__(self): return 6 def __invert__(self): m = self.__copy__() return m.inverse() def __matmul__(self, other): m = copy(self) m.__imatmul__(other) return m def __rmatmul__(self, other): m = copy(other) m.__imatmul__(self) return m def __imatmul__(self, other): if isinstance(other, str): other = Matrix(other) self.a, self.b, self.c, self.d, self.e, self.f = Matrix.matrix_multiply( self, other ) return self __mul__ = __matmul__ __rmul__ = __rmatmul__ __imul__ = __imatmul__ def __getitem__(self, item): if item == 0: return float(self.a) elif item == 1: return float(self.b) elif item == 2: return float(self.c) elif item == 3: return float(self.d) elif item == 4: return self.e elif item == 5: return self.f def __setitem__(self, key, value): if key == 0: self.a = value elif key == 1: self.b = value elif key == 2: self.c = value elif key == 3: self.d = value elif key == 4: self.e = value elif key == 5: self.f = value def __repr__(self): return "Matrix(%s, %s, %s, %s, %s, %s)" % ( Length.str(self.a), Length.str(self.b), Length.str(self.c), Length.str(self.d), Length.str(self.e), Length.str(self.f), ) def __copy__(self): return Matrix(self.a, self.b, self.c, self.d, self.e, self.f) def __str__(self): """ Many of SVG's graphics operations utilize 2x3: :returns string representation of matrix. """ return "[%3f, %3f,\n %3f, %3f, %s, %s]" % ( self.a, self.c, self.b, self.d, self.e, self.f, ) def parse(self, transform_str): """Parses the svg transform string. Transforms from SVG 1.1 have a smaller complete set of operations. Whereas in SVG 2.0 they gain the CSS transforms and the additional functions and parsing that go with that. This parse is compatible with SVG 1.1 and the SVG 2.0 which includes the CSS 2d superset. CSS transforms have scalex() scaley() translatex(), translatey(), and skew() (deprecated). 2D CSS angles haves units: "deg" tau / 360, "rad" tau/tau, "grad" tau/400, "turn" tau. 2D CSS distances have length/percentages: "px", "cm", "mm", "in", "pt", etc. (+|-)?d+% In the case of percentages there must be a known height and width to properly create a matrix out of that. """ if not transform_str: return if not isinstance(transform_str, str): raise TypeError("Must provide a string to parse") for sub_element in REGEX_TRANSFORM_TEMPLATE.findall(transform_str.lower()): name = sub_element[0] params = tuple(REGEX_TRANSFORM_PARAMETER.findall(sub_element[1])) params = [mag + units for mag, units in params] if SVG_TRANSFORM_MATRIX == name: params = map(float, params) self.pre_cat(*params) elif SVG_TRANSFORM_TRANSLATE == name: try: x_param = Length(params[0]).value() except IndexError: continue try: y_param = Length(params[1]).value() self.pre_translate(x_param, y_param) except IndexError: self.pre_translate(x_param) elif SVG_TRANSFORM_TRANSLATE_X == name: self.pre_translate(Length(params[0]).value(), 0) elif SVG_TRANSFORM_TRANSLATE_Y == name: self.pre_translate(0, Length(params[0]).value()) elif SVG_TRANSFORM_SCALE == name: params = map(float, params) self.pre_scale(*params) elif SVG_TRANSFORM_SCALE_X == name: self.pre_scale(float(params[0]), 1) elif SVG_TRANSFORM_SCALE_Y == name: self.pre_scale(1, float(params[0])) elif SVG_TRANSFORM_ROTATE == name: angle = Angle.parse(params[0]) try: x_param = Length(params[1]).value() except IndexError: self.pre_rotate(angle) continue try: y_param = Length(params[2]).value() self.pre_rotate(angle, x_param, y_param) except IndexError: self.pre_rotate(angle, x_param) elif SVG_TRANSFORM_SKEW == name: angle_a = Angle.parse(params[0]) try: angle_b = Angle.parse(params[1]) except IndexError: # this isn't valid. continue try: x_param = Length(params[2]).value() except IndexError: self.pre_skew(angle_a, angle_b) continue try: y_param = Length(params[3]).value() self.pre_skew(angle_a, angle_b, x_param, y_param) except IndexError: self.pre_skew(angle_a, angle_b, x_param) elif SVG_TRANSFORM_SKEW_X == name: angle_a = Angle.parse(params[0]) try: x_param = Length(params[1]).value() except IndexError: self.pre_skew_x(angle_a) continue try: y_param = Length(params[2]).value() self.pre_skew_x(angle_a, x_param, y_param) except IndexError: self.pre_skew_x(angle_a, x_param) elif SVG_TRANSFORM_SKEW_Y == name: angle_b = Angle.parse(params[0]) try: x_param = Length(params[1]).value() except IndexError: self.pre_skew_y(angle_b) continue try: y_param = Length(params[2]).value() self.pre_skew_y(angle_b, x_param, y_param) except IndexError: self.pre_skew_y(angle_b, x_param) return self def render( self, ppi=None, relative_length=None, width=None, height=None, font_size=None, font_height=None, viewbox=None, **kwargs, ): """ Provides values to turn trans_x and trans_y values into user units floats rather than Lengths by giving the required information to perform the conversions. """ if isinstance(self.e, Length): if width is None and relative_length is not None: width = relative_length self.e = self.e.value( ppi=ppi, relative_length=width, font_size=font_size, font_height=font_height, viewbox=viewbox, ) if isinstance(self.f, Length): if height is None and relative_length is not None: height = relative_length self.f = self.f.value( ppi=ppi, relative_length=height, font_size=font_size, font_height=font_height, viewbox=viewbox, ) return self @property def determinant(self): return self.a * self.d - self.c * self.b def value_trans_x(self): return self.e def value_trans_y(self): return self.f def value_scale_x(self): return float(self.a) def value_scale_y(self): return float(self.d) def value_skew_x(self): return float(self.b) def value_skew_y(self): return float(self.c) def reset(self): """Resets matrix to identity.""" self.a = 1.0 self.b = 0.0 self.c = 0.0 self.d = 1.0 self.e = 0.0 self.f = 0.0 def inverse(self): """ SVG Matrix: [a c e] [b d f] """ m00 = self.a m01 = self.c m02 = self.e m10 = self.b m11 = self.d m12 = self.f determinant = m00 * m11 - m01 * m10 inverse_determinant = 1.0 / determinant self.a = m11 * inverse_determinant self.c = -m01 * inverse_determinant self.b = -m10 * inverse_determinant self.d = m00 * inverse_determinant self.e = (m01 * m12 - m02 * m11) * inverse_determinant self.f = (m10 * m02 - m00 * m12) * inverse_determinant return self def vector(self): """ provide the matrix suitable for multiplying vectors. This will be the matrix with the same rotation and scale aspects but with no translation. This matrix is for multiplying vector elements where the position doesn't matter but the scaling and rotation do. :return: """ return Matrix(self.a, self.b, self.c, self.d, 0.0, 0.0) def is_identity(self): return ( self.a == 1 and self.b == 0 and self.c == 0 and self.d == 1 and self.e == 0 and self.f == 0 ) def post_cat(self, *components): mx = Matrix(*components) self.__imatmul__(mx) def post_scale(self, sx=1.0, sy=None, x=0.0, y=0.0): if sy is None: sy = sx if x is None: x = 0.0 if y is None: y = 0.0 if x == 0 and y == 0: self.post_cat(Matrix.scale(sx, sy)) else: self.post_translate(-x, -y) self.post_scale(sx, sy) self.post_translate(x, y) def post_scale_x(self, sx=1.0, x=0.0, y=0.0): self.post_scale(sx, 1, x, y) def post_scale_y(self, sy=1.0, x=0.0, y=0.0): self.post_scale(1, sy, x, y) def post_translate(self, tx=0.0, ty=0.0): self.post_cat(Matrix.translate(tx, ty)) def post_translate_x(self, tx=0.0): self.post_translate(tx, 0.0) def post_translate_y(self, ty=0.0): self.post_translate(0.0, ty) def post_rotate(self, angle, x=0.0, y=0.0): if x is None: x = 0.0 if y is None: y = 0.0 if x == 0 and y == 0: self.post_cat(Matrix.rotate(angle)) # self %= self.get_rotate(theta) else: matrix = Matrix() matrix.post_translate(-x, -y) matrix.post_cat(Matrix.rotate(angle)) matrix.post_translate(x, y) self.post_cat(matrix) def post_skew(self, angle_a=0.0, angle_b=0.0, x=0.0, y=0.0): if x is None: x = 0 if y is None: y = 0 if x == 0 and y == 0: self.post_cat(Matrix.skew(angle_a, angle_b)) else: self.post_translate(-x, -y) self.post_skew(angle_a, angle_b) self.post_translate(x, y) def post_skew_x(self, angle_a=0.0, x=0.0, y=0.0): self.post_skew(angle_a, 0.0, x, y) def post_skew_y(self, angle_b=0.0, x=0.0, y=0.0): self.post_skew(0.0, angle_b, x, y) def pre_cat(self, *components): mx = Matrix(*components) self.a, self.b, self.c, self.d, self.e, self.f = Matrix.matrix_multiply( mx, self ) def pre_scale(self, sx=1.0, sy=None, x=0.0, y=0.0): if sy is None: sy = sx if x is None: x = 0.0 if y is None: y = 0.0 if x == 0 and y == 0: self.pre_cat(Matrix.scale(sx, sy)) else: self.pre_translate(x, y) self.pre_scale(sx, sy) self.pre_translate(-x, -y) def pre_scale_x(self, sx=1.0, x=0.0, y=0.0): self.pre_scale(sx, 1, x, y) def pre_scale_y(self, sy=1.0, x=0.0, y=0.0): self.pre_scale(1, sy, x, y) def pre_translate(self, tx=0.0, ty=0.0): self.pre_cat(Matrix.translate(tx, ty)) def pre_translate_x(self, tx=0.0): self.pre_translate(tx, 0.0) def pre_translate_y(self, ty=0.0): self.pre_translate(0.0, ty) def pre_rotate(self, angle, x=0.0, y=0.0): if x is None: x = 0 if y is None: y = 0 if x == 0 and y == 0: self.pre_cat(Matrix.rotate(angle)) else: self.pre_translate(x, y) self.pre_rotate(angle) self.pre_translate(-x, -y) def pre_skew(self, angle_a=0.0, angle_b=0.0, x=0.0, y=0.0): if x is None: x = 0 if y is None: y = 0 if x == 0 and y == 0: self.pre_cat(Matrix.skew(angle_a, angle_b)) else: self.pre_translate(x, y) self.pre_skew(angle_a, angle_b) self.pre_translate(-x, -y) def pre_skew_x(self, angle_a=0.0, x=0.0, y=0.0): self.pre_skew(angle_a, 0, x, y) def pre_skew_y(self, angle_b=0.0, x=0.0, y=0.0): self.pre_skew(0.0, angle_b, x, y) def point_in_inverse_space(self, v0): inverse = Matrix(self) inverse.inverse() return inverse.point_in_matrix_space(v0) def point_in_matrix_space(self, v0): return Point( v0[0] * self.a + v0[1] * self.c + 1 * self.e, v0[0] * self.b + v0[1] * self.d + 1 * self.f, ) def transform_point(self, v): nx = v[0] * self.a + v[1] * self.c + 1 * self.e ny = v[0] * self.b + v[1] * self.d + 1 * self.f v[0] = nx v[1] = ny return v def transform_vector(self, v): """ Applies the transformation without the translation. """ nx = v[0] * self.a + v[1] * self.c ny = v[0] * self.b + v[1] * self.d v[0] = nx v[1] = ny return v @classmethod def scale(cls, sx=1.0, sy=None): if sy is None: sy = sx return cls(sx, 0, 0, sy, 0, 0) @classmethod def scale_x(cls, sx=1.0): return cls.scale(sx, 1.0) @classmethod def scale_y(cls, sy=1.0): return cls.scale(1.0, sy) @classmethod def translate(cls, tx=0.0, ty=0.0): """SVG Matrix: [a c e] [b d f] """ return cls(1.0, 0.0, 0.0, 1.0, tx, ty) @classmethod def translate_x(cls, tx=0.0): return cls.translate(tx, 0) @classmethod def translate_y(cls, ty=0.0): return cls.translate(0.0, ty) @classmethod def rotate(cls, angle=0.0): ct = cos(angle) st = sin(angle) return cls(ct, st, -st, ct, 0.0, 0.0) @classmethod def skew(cls, angle_a=0.0, angle_b=0.0): aa = tan(angle_a) bb = tan(angle_b) return cls(1.0, bb, aa, 1.0, 0.0, 0.0) @classmethod def skew_x(cls, angle=0.0): return cls.skew(angle, 0.0) @classmethod def skew_y(cls, angle=0.0): return cls.skew(0.0, angle) @classmethod def identity(cls): """ 1, 0, 0, 0, 1, 0, """ return cls() @staticmethod def matrix_multiply(m, s): """ [a c e] [a c e] [a b 0] [b d f] % [b d f] = [c d 0] [0 0 1] [0 0 1] [e f 1] :param m: matrix operand :param s: matrix operand :return: multiplied matrix. """ r0 = ( s.a * m.a + s.c * m.b + s.e * 0, s.a * m.c + s.c * m.d + s.e * 0, s.a * m.e + s.c * m.f + s.e * 1, ) r1 = ( s.b * m.a + s.d * m.b + s.f * 0, s.b * m.c + s.d * m.d + s.f * 0, s.b * m.e + s.d * m.f + s.f * 1, ) return float(r0[0]), float(r1[0]), float(r0[1]), float(r1[1]), r0[2], r1[2] class Viewbox: def __init__(self, *args, **kwargs): """ Viewbox controls the scaling between the drawing size view that is observing that drawing. :param viewbox: either values or viewbox attribute or a Viewbox object :param preserveAspectRatio or preserve_aspect_ratio: preserveAspectRatio """ self.x = None self.y = None self.width = None self.height = None self.preserve_aspect_ratio = None if args and len(args) <= 2: viewbox = args[0] if isinstance(viewbox, dict): self.property_by_values(viewbox) elif isinstance(viewbox, Viewbox): self.property_by_object(viewbox) else: self.set_viewbox(viewbox) if len(args) == 2: self.preserve_aspect_ratio = args[1] elif len(args) == 4: self.x = float(args[0]) self.y = float(args[1]) self.width = float(args[2]) self.height = float(args[3]) if kwargs: self.property_by_values(dict(kwargs)) def __eq__(self, other): if not isinstance(other, Viewbox): return False if self.x != other.x: return False if self.y != other.y: return False if self.width != other.width: return False if self.height != other.height: return False return self.preserve_aspect_ratio == other.preserve_aspect_ratio def __str__(self): return "%s %s %s %s" % ( Length.str(self.x), Length.str(self.y), Length.str(self.width), Length.str(self.height), ) def __repr__(self): values = [] if self.x is not None: values.append("%s=%s" % (SVG_ATTR_X, Length.str(self.x))) if self.y is not None: values.append("%s=%s" % (SVG_ATTR_Y, Length.str(self.y))) if self.width is not None: values.append("%s=%s" % (SVG_ATTR_WIDTH, Length.str(self.width))) if self.height is not None: values.append("%s=%s" % (SVG_ATTR_HEIGHT, Length.str(self.height))) if self.preserve_aspect_ratio is not None: values.append( "%s='%s'" % (SVG_ATTR_PRESERVEASPECTRATIO, self.preserve_aspect_ratio) ) params = ", ".join(values) return "Viewbox(%s)" % params def property_by_object(self, obj): self.x = obj.x self.y = obj.y self.width = obj.width self.height = obj.height self.preserve_aspect_ratio = obj.preserve_aspect_ratio def property_by_values(self, values): if SVG_ATTR_VIEWBOX in values: self.set_viewbox(values[SVG_ATTR_VIEWBOX]) if SVG_ATTR_X in values: self.x = values[SVG_ATTR_X] if SVG_ATTR_Y in values: self.y = values[SVG_ATTR_Y] if SVG_ATTR_WIDTH in values: self.width = values[SVG_ATTR_WIDTH] if SVG_ATTR_HEIGHT in values: self.height = values[SVG_ATTR_HEIGHT] if "preserve_aspect_ratio" in values: self.preserve_aspect_ratio = values["preserve_aspect_ratio"] if SVG_ATTR_PRESERVEASPECTRATIO in values: self.preserve_aspect_ratio = values[SVG_ATTR_PRESERVEASPECTRATIO] def set_viewbox(self, viewbox): if viewbox is not None: dims = list(REGEX_FLOAT.findall(viewbox)) try: self.x = float(dims[0]) self.y = float(dims[1]) self.width = float(dims[2]) self.height = float(dims[3]) except IndexError: pass def transform(self, element): return Viewbox.viewbox_transform( element.x, element.y, element.width, element.height, self.x, self.y, self.width, self.height, self.preserve_aspect_ratio, ) @staticmethod def viewbox_transform( e_x, e_y, e_width, e_height, vb_x, vb_y, vb_width, vb_height, aspect ): """ SVG 1.1 7.2, SVG 2.0 8.2 equivalent transform of an SVG viewport. With regards to https://github.com/w3c/svgwg/issues/215 use 8.2 version. It creates transform commands equal to that viewport expected. Let e-x, e-y, e-width, e-height be the position and size of the element respectively. Let vb-x, vb-y, vb-width, vb-height be the min-x, min-y, width and height values of the viewBox attribute respectively. Let align be the align value of preserveAspectRatio, or 'xMidYMid' if preserveAspectRatio is not defined. Let meetOrSlice be the meetOrSlice value of preserveAspectRatio, or 'meet' if preserveAspectRatio is not defined or if meetOrSlice is missing from this value. :param e_x: element_x value :param e_y: element_y value :param e_width: element_width value :param e_height: element_height value :param vb_x: viewbox_x value :param vb_y: viewbox_y value :param vb_width: viewbox_width value :param vb_height: viewbox_height value :param aspect: preserve aspect ratio value :return: string of the SVG transform commands to account for the viewbox. """ if ( e_x is None or e_y is None or e_width is None or e_height is None or vb_x is None or vb_y is None or vb_width is None or vb_height is None ): return "" if aspect is not None: aspect_slice = aspect.split(" ") try: align = aspect_slice[0] except IndexError: align = "xMidyMid" try: meet_or_slice = aspect_slice[1] except IndexError: meet_or_slice = "meet" else: align = "xMidyMid" meet_or_slice = "meet" # Initialize scale-x to e-width/vb-width. scale_x = e_width / vb_width # Initialize scale-y to e-height/vb-height. scale_y = e_height / vb_height # If align is not 'none' and meetOrSlice is 'meet', set the larger of scale-x and scale-y to the smaller. if align != SVG_VALUE_NONE and meet_or_slice == "meet": scale_x = scale_y = min(scale_x, scale_y) # Otherwise, if align is not 'none' and meetOrSlice is 'slice', set the smaller of scale-x and scale-y to the larger elif align != SVG_VALUE_NONE and meet_or_slice == "slice": scale_x = scale_y = max(scale_x, scale_y) # Initialize translate-x to e-x - (vb-x * scale-x). translate_x = e_x - (vb_x * scale_x) # Initialize translate-y to e-y - (vb-y * scale-y) translate_y = e_y - (vb_y * scale_y) # If align contains 'xMid', add (e-width - vb-width * scale-x) / 2 to translate-x. align = align.lower() if "xmid" in align: translate_x += (e_width - vb_width * scale_x) / 2.0 # If align contains 'xMax', add (e-width - vb-width * scale-x) to translate-x. if "xmax" in align: translate_x += e_width - vb_width * scale_x # If align contains 'yMid', add (e-height - vb-height * scale-y) / 2 to translate-y. if "ymid" in align: translate_y += (e_height - vb_height * scale_y) / 2.0 # If align contains 'yMax', add (e-height - vb-height * scale-y) to translate-y. if "ymax" in align: translate_y += e_height - vb_height * scale_y # The transform applied to content contained by the element is given by: # translate(translate-x, translate-y) scale(scale-x, scale-y) if isinstance(scale_x, Length) or isinstance(scale_y, Length): raise ValueError if translate_x == 0 and translate_y == 0: if scale_x == 1 and scale_y == 1: return "" # Nothing happens. else: return "scale(%s, %s)" % (Length.str(scale_x), Length.str(scale_y)) else: if scale_x == 1 and scale_y == 1: return "translate(%s, %s)" % ( Length.str(translate_x), Length.str(translate_y), ) else: return "translate(%s, %s) scale(%s, %s)" % ( Length.str(translate_x), Length.str(translate_y), Length.str(scale_x), Length.str(scale_y), ) class SVGElement(object): """ Any element within the SVG namespace. if args[0] is a dict or SVGElement class the value is used to seed the values. Else, the values consist of the kwargs used. The priority is such that kwargs will overwrite any previously set value. If additional args exist these will be passed to property_by_args """ def __init__(self, *args, **kwargs): self.id = None self.values = None if len(args) >= 1: s = args[0] if isinstance(s, dict): args = args[1:] self.values = dict(s) self.values.update(kwargs) elif isinstance(s, SVGElement): args = args[1:] self.property_by_object(s) self.property_by_args(*args) return if self.values is None: self.values = dict(kwargs) self.property_by_values(self.values) if len(args) != 0: self.property_by_args(*args) def property_by_args(self, *args): pass def property_by_object(self, obj): self.id = obj.id self.values = dict(obj.values) def property_by_values(self, values): self.id = values.get(SVG_ATTR_ID) def render(self, **kwargs): """ Render changes any length/percent values or attributes into real usable limits if given the information required to change such parameters. :param kwargs: various other properties to be rendered with. :return: """ pass def set(self, key, value): self.values[key] = value return self class Transformable: """Any element that is transformable and has a transform property.""" def __init__(self, *args, **kwargs): self._length = None self._lengths = None self.transform = None self.apply = None def property_by_object(self, s): self.transform = Matrix(s.transform) self.apply = s.apply def property_by_values(self, values): self.transform = Matrix(values.get(SVG_ATTR_TRANSFORM, "")) self.apply = bool(values.get("apply", True)) def __mul__(self, other): if isinstance(other, (Matrix, str)): n = copy(self) n *= other return n return NotImplemented __rmul__ = __mul__ def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): self.transform *= other return self def __abs__(self): """ The absolute value is taken to be the actual shape transformed. :return: transformed version of the given shape. """ m = copy(self) m.reify() return m def reify(self): """ Realizes the transform to the attributes. Such that the attributes become actualized and the transform simplifies towards the identity matrix. In many cases it will become the identity matrix. In other cases the transformed shape cannot be represented through the properties alone. And shall keep those parts of the transform required preserve equivalency. The default method will be called by submethods but will only scale properties like stroke_width which should scale with the transform. """ self._lengths = None self._length = None def render(self, **kwargs): """ Renders the transformable by performing any required length conversion operations into pixels. The element will be the pixel-length form. """ if self.transform is not None: self.transform.render(**kwargs) return self def bbox(self, transformed=True, with_stroke=False): """ Returns the bounding box of the given object. :param transformed: whether this is the transformed bounds or default. :param with_stroke: should the stroke-width be included in the bounds. :return: bounding box of the given element """ raise NotImplementedError @property def rotation(self): if not self.apply: return Angle.degrees(0) prx = Point(1, 0) prx *= self.transform origin = Point(0, 0) origin *= self.transform return origin.angle_to(prx) class GraphicObject: """Any drawn element.""" def __init__(self, *args, **kwargs): self.stroke = None self.fill = None self.stroke_width = None def property_by_object(self, s): self.fill = Color(s.fill) if s.fill is not None else None self.stroke = Color(s.stroke) if s.stroke is not None else None self.stroke_width = ( Length(s.stroke_width).value() if s.stroke_width is not None else None ) def property_by_values(self, values): stroke = values.get(SVG_ATTR_STROKE) self.stroke = Color(stroke) if stroke is not None else None stroke_opacity = values.get("stroke_opacity") stroke_opacity = values.get(SVG_ATTR_STROKE_OPACITY, stroke_opacity) if ( stroke_opacity is not None and self.stroke is not None and self.stroke.value is not None ): try: self.stroke.opacity = float(stroke_opacity) except ValueError: pass fill = values.get(SVG_ATTR_FILL) self.fill = Color(fill) if fill is not None else None fill_opacity = values.get("fill_opacity") fill_opacity = values.get(SVG_ATTR_FILL_OPACITY, fill_opacity) if ( fill_opacity is not None and self.fill is not None and self.fill.value is not None ): try: self.fill.opacity = float(fill_opacity) except ValueError: pass self.stroke_width = Length(values.get("stroke_width", 1.0)).value() self.stroke_width = Length( values.get(SVG_ATTR_STROKE_WIDTH, self.stroke_width) ).value() def render(self, **kwargs): if isinstance(self.stroke_width, Length): width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass self.stroke_width = self.stroke_width.value( relative_length=sqrt(width * width + height * height), **kwargs ) # A percentage stroke_width is always computed as a percentage of the normalized viewBox diagonal length. def reify(self): """ Realizes the transform to the attributes. Such that the attributes become actualized and the transform simplifies towards the identity matrix. In many cases it will become the identity matrix. In other cases the transformed shape cannot be represented through the properties alone. And shall keep those parts of the transform required preserve equivalency. """ self.stroke_width = self.implicit_stroke_width return self @property def implicit_stroke_width(self): try: if not self.apply: return self.stroke_width if self.stroke_width is not None: if ( hasattr(self, "values") and SVG_ATTR_VECTOR_EFFECT in self.values and SVG_VALUE_NON_SCALING_STROKE in self.values[SVG_ATTR_VECTOR_EFFECT] ): return self.stroke_width # we are not to scale the stroke. width = self.stroke_width det = self.transform.determinant return width * sqrt(abs(det)) except AttributeError: return self.stroke_width def is_degenerate(self): return False class Shape(SVGElement, GraphicObject, Transformable): """ SVG Shapes are several SVG items defined in SVG 1.1 9.1 https://www.w3.org/TR/SVG11/shapes.html These shapes are circle, ellipse, line, polyline, polygon, and path. All shapes have methods: d(relative, transform): provides path_d string for the shape. reify(): Applies transform of the shape to modify the shape attributes. render(): Ensure that the shape properties have real space values. bbox(transformed): Provides the bounding box for the given shape. All shapes must implement: __repr__(), with a call to _repr_shape() __copy__() All shapes have attributes: id: SVG ID attributes. (SVGElement) transform: SVG Matrix to apply to this shape. (Transformable) apply: Determine whether transform should be applied. (Transformable) fill: SVG color of the shape fill. (GraphicObject) stroke: SVG color of the shape stroke. (GraphicObject) stroke_width: Stroke width of the stroke. (GraphicObject) """ def __init__(self, *args, **kwargs): self._strict = True Transformable.__init__(self, *args, **kwargs) GraphicObject.__init__(self, *args, **kwargs) SVGElement.__init__( self, *args, **kwargs ) # Must go last, triggers, by_object, by_value, by_arg functions. def property_by_object(self, s): SVGElement.property_by_object(self, s) Transformable.property_by_object(self, s) GraphicObject.property_by_object(self, s) def property_by_values(self, values): SVGElement.property_by_values(self, values) Transformable.property_by_values(self, values) GraphicObject.property_by_values(self, values) def render(self, **kwargs): SVGElement.render(self, **kwargs) Transformable.render(self, **kwargs) GraphicObject.render(self, **kwargs) def __eq__(self, other): if not isinstance(other, Shape): return NotImplemented if self.fill != other.fill or self.stroke != other.stroke: return False first = self if not isinstance(first, Path): first = Path(first) second = other if not isinstance(second, Path): second = Path(second) return first == second def __ne__(self, other): if not isinstance(other, Shape): return NotImplemented return not self == other def __iadd__(self, other): if isinstance(other, Shape): return Path(self) + Path(other) return NotImplemented __add__ = __iadd__ def __matmul__(self, other): m = copy(self) m.__imatmul__(other) return m def __rmatmul__(self, other): m = copy(other) m.__imatmul__(self) return m def __imatmul__(self, other): """ The % operation with a matrix works much like multiplication except that it automatically reifies the shape. """ if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): self.transform *= other self.reify() return self def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH, segments=None): """ Calculate the length values for the segments of the Shape. :param error: error permitted for length calculations. :param min_depth: minimum depth for the length calculation. :param segments: optional segments to use. :return: """ if segments is None: segments = self.segments(False) if self._length is not None: return lengths = [each.length(error=error, min_depth=min_depth) for each in segments] self._length = sum(lengths) if self._length == 0: self._lengths = lengths else: self._lengths = [each / self._length for each in lengths] def npoint(self, positions, error=ERROR): """ Find a points between 0 and 1 within the shape. Numpy acceleration allows points to be an array of floats. """ try: import numpy as np except ImportError: return [self.point(pos) for pos in positions] segments = self.segments(False) if len(segments) == 0: return None # Shortcuts if self._length is None: self._calc_lengths(error=error, segments=segments) xy = np.empty((len(positions), 2), dtype=float) if self._length == 0: i = int(round(positions * (len(segments) - 1))) point = segments[i].point(0.0) xy[:] = point return xy # Find which segment the point we search for is located on: segment_start = 0 for index, segment in enumerate(segments): segment_end = segment_start + self._lengths[index] position_subset = (segment_start <= positions) & (positions < segment_end) v0 = positions[position_subset] if not len(v0): continue # Nothing matched. d = segment_end - segment_start if d == 0: # This segment is 0 length. segment_pos = 0.0 else: segment_pos = (v0 - segment_start) / d c = segment.npoint(segment_pos) xy[position_subset] = c[:] segment_start = segment_end # the loop above will miss position == 1 xy[positions == 1] = np.array(list(segments[-1].end)) return xy def point(self, position, error=ERROR): """ Find a point between 0 and 1 within the Shape, going through the shape with regard to position. :param position: value between 0 and 1 within the shape. :param error: Length error permitted. :return: Point at the given location. """ segments = self.segments(False) if len(segments) == 0: return None # Shortcuts try: if position <= 0.0: return segments[0].point(position) if position >= 1.0: return segments[-1].point(position) except ValueError: return self.npoint([position], error=error)[0] if self._length is None: self._calc_lengths(error=error, segments=segments) if self._length == 0: i = int(round(position * (len(segments) - 1))) return segments[i].point(0.0) # Find which segment the point we search for is located on: segment_start = 0 segment_pos = 0 segment = segments[0] for index, segment in enumerate(segments): segment_end = segment_start + self._lengths[index] if segment_end >= position: # This is the segment! How far in on the segment is the point? segment_pos = (position - segment_start) / (segment_end - segment_start) break segment_start = segment_end return segment.point(segment_pos) def length(self, error=ERROR, min_depth=MIN_DEPTH): self._calc_lengths(error, min_depth) return self._length def segments(self, transformed=True): """ Returns PathSegments which correctly produce this shape. This should be implemented by subclasses. """ raise NotImplementedError def d(self, relative=False, transformed=True): """ Returns the path_d string of the shape. :param relative: Returns path_d in relative form. :param transformed: Return path_d, with applied transform. :return: path_d string """ return Path(self.segments(transformed=transformed)).d(relative=relative) def bbox(self, transformed=True, with_stroke=False): """ Get the bounding box for the given shape. :param transformed: whether this is the transformed bounds or default. :param with_stroke: should the stroke-width be included in the bounds. :return: bounding box of the given element """ bbs = [ seg.bbox() for seg in self.segments(transformed=transformed) if not isinstance(Close, Move) ] try: xmins, ymins, xmaxs, ymaxs = list(zip(*bbs)) except ValueError: return None # No bounding box items existed. So no bounding box. if ( with_stroke and self.stroke_width is not None and not (self.stroke is None or self.stroke.value is None) ): if transformed: delta = float(self.implicit_stroke_width) / 2.0 else: delta = float(self.stroke_width) / 2.0 else: delta = 0.0 return ( min(xmins) - delta, min(ymins) - delta, max(xmaxs) + delta, max(ymaxs) + delta, ) def _init_shape(self, *args): """ Generic SVG parsing of args. In those cases where the shape accepts finite elements we can process the last four elements of the shape with this code. This will happen in simpleline, roundshape, and rect. It will not happen in polyshape or paths since these can accept infinite arguments. """ arg_length = len(args) if arg_length >= 1: if args[0] is not None: self.transform = Matrix(args[0]) if arg_length >= 2: if args[1] is not None: self.stroke = Color(args[1]) if arg_length >= 3: if args[2] is not None: self.fill = Color(args[2]) if arg_length >= 4: if args[3] is not None: self.apply = bool(args[3]) def _repr_shape(self, values): """ Generic pieces of repr shape. """ # Cannot use SVG_ATTR_* for some attributes in repr because they contain hyphens if self.stroke is not None and self.stroke.rgb is not None: values.append("%s='%s'" % (SVG_ATTR_STROKE, self.stroke.hexrgb)) if self.stroke.opacity != 1.0: values.append("%s=%s" % ("stroke_opacity", str(self.stroke.opacity))) if self.fill is not None and self.fill.rgb is not None: values.append("%s='%s'" % (SVG_ATTR_FILL, self.fill.hexrgb)) if self.fill.opacity != 1.0: values.append("%s=%s" % ("fill_opacity", str(self.fill.opacity))) if self.stroke_width is not None and self.stroke_width != 1.0: values.append("stroke_width=%s" % str(self.stroke_width)) if not self.transform.is_identity(): values.append("%s=%s" % (SVG_ATTR_TRANSFORM, repr(self.transform))) if self.apply is not None and not self.apply: values.append("apply=%s" % self.apply) if self.id is not None: values.append("%s='%s'" % (SVG_ATTR_ID, self.id)) def _str_shape(self, values): """ Generic pieces of str shape. """ if self.stroke is not None and self.stroke.rgb is not None: values.append("%s='%s'" % (SVG_ATTR_STROKE, self.stroke.hexrgb)) if self.stroke.opacity != 1.0: values.append( "%s=%s" % (SVG_ATTR_STROKE_OPACITY, str(self.stroke.opacity)) ) if self.fill is not None and self.fill.rgb is not None: values.append("%s='%s'" % (SVG_ATTR_FILL, self.fill.hexrgb)) if self.fill.opacity != 1.0: values.append("%s=%s" % (SVG_ATTR_FILL_OPACITY, str(self.fill.opacity))) if self.stroke_width is not None and self.stroke_width != 1.0: values.append("%s=%s" % (SVG_ATTR_STROKE_WIDTH, str(self.stroke_width))) if not self.transform.is_identity(): values.append("%s=%s" % (SVG_ATTR_TRANSFORM, repr(self.transform))) if self.apply is not None and not self.apply: values.append("apply=%s" % self.apply) if self.id is not None: values.append("%s='%s'" % (SVG_ATTR_ID, self.id)) def _name(self): return self.__class__.__name__ class PathSegment: """ Path Segments are the base class for all the segment within a Path. These are defined in SVG 1.1 8.3 and SVG 2.0 9.3 https://www.w3.org/TR/SVG11/paths.html#PathData https://www.w3.org/TR/SVG2/paths.html#PathElement These segments define a 1:1 relationship with the path_d or path data attribute, denoted in SVG by the 'd' attribute. These are moveto, closepath, lineto, and the curves which are cubic bezier curves, quadratic bezier curves, and elliptical arc. These are classed as Move, Close, Line, CubicBezier, QuadraticBezier, and Arc. And in path_d are denoted as M, Z, L, C, Q, A. There are lowercase versions of these commands. And for C, and Q there are S and T which are smooth versions. For lines there are also V and H commands which denote vertical and horizontal versions of the line command. The major difference between paths in 1.1 and 2.0 is the use of Z to truncate a command to close. "M0,0C 0,100 100,0 z is valid in 2.0 since the last z replaces the 0,0. These are read by svg.elements but they are not written. """ def __init__(self, **kwargs): try: self.relative = bool(kwargs["relative"]) except (KeyError, ValueError): self.relative = False try: self.smooth = bool(kwargs["smooth"]) except (KeyError, ValueError): self.smooth = True self.start = None self.end = None def __repr__(self): values = [] s = self.start if s is not None: values.append("start=%s" % repr(s)) e = self.end if e is not None: values.append("end=%s" % repr(e)) return "%s(%s)" % (self.__class__.__name__, ", ".join(values)) def __mul__(self, other): if isinstance(other, (Matrix, str)): n = copy(self) n *= other return n return NotImplemented __rmul__ = __mul__ def __iadd__(self, other): if isinstance(other, PathSegment): path = Path(self, other) return path elif isinstance(other, str): path = Path(self) + other return path return NotImplemented __add__ = __iadd__ def __str__(self): """ This defines an individual path segment string. Since this isn't part of a Path it appends a pseudo-Move command to correctly provide the starting position. :return: string representation of the object. """ d = self.d() if self.start is not None: if self.relative: return "m %s %s" % (self.start, d) else: return "M %s %s" % (self.start, d) return d def __iter__(self): self.n = -1 return self def __next__(self): self.n += 1 try: val = self[self.n] if val is None: self.n += 1 val = self[self.n] return val except IndexError: raise StopIteration next = __next__ @staticmethod def segment_length( curve, start=0.0, end=1.0, start_point=None, end_point=None, error=ERROR, min_depth=MIN_DEPTH, depth=0, ): """Recursively approximates the length by straight lines""" if start_point is None: start_point = curve.point(start) if end_point is None: end_point = curve.point(end) mid = (start + end) / 2.0 mid_point = curve.point(mid) length = abs(end_point - start_point) first_half = abs(mid_point - start_point) second_half = abs(end_point - mid_point) length2 = first_half + second_half if (length2 - length > error) or (depth < min_depth): # Calculate the length of each segment: depth += 1 return PathSegment.segment_length( curve, start, mid, start_point, mid_point, error, min_depth, depth ) + PathSegment.segment_length( curve, mid, end, mid_point, end_point, error, min_depth, depth ) # This is accurate enough. return length2 def _line_length(self, start=0.0, end=1.0, error=ERROR, min_depth=MIN_DEPTH): return PathSegment.segment_length( self, start, end, error=error, min_depth=min_depth ) def bbox(self): """returns the bounding box for the segment. xmin, ymin, xmax, ymax """ xs = [p.x for p in self if p is not None] ys = [p.y for p in self if p is not None] xmin = min(xs) xmax = max(xs) ymin = min(ys) ymax = max(ys) return xmin, ymin, xmax, ymax def reverse(self): """ Reverses the current path segment. """ end = self.end self.end = self.start self.start = end def point(self, position): """ Returns the point at a given amount through the path segment. :param position: t value between 0 and 1 :return: Point instance """ return Point(self.npoint([position])[0]) def npoint(self, positions): """ Returns the points at given positions along the path segment :param positions: N-sized sequence of t value between 0 and 1 :return: N-sized sequence of 2-sized sequence of float """ return [self.end] * len(positions) def length(self, error=ERROR, min_depth=MIN_DEPTH): """ Returns the length of this path segment. :param error: :param min_depth: :return: """ return 0 def d(self, current_point=None, relative=None, smooth=None): """Returns the fragment path_d value for the current path segment. For a relative segment the current_point must be provided. If it is omitted then only an absolute segment can be returned.""" raise NotImplementedError class Move(PathSegment): """Represents move commands. Moves to a new location without any path distance. Paths that consist of only move commands, are valid. Move serve to make discontinuous paths into continuous linked paths segments with non-drawn sections. """ def __init__(self, *args, **kwargs): """ Move commands most importantly go to a place. So if one location is given, that's the end point. If two locations are given then first is the start location. For many Move commands it is not necessary to have an original start location. The start point provides a linked locations for some elements that may require it. If known it can be provided. Move(p) where p is the End point. Move(s,e) where s is the Start point, e is the End point. Move(p, start=s) where p is End point, s is the Start point. Move(p, end=e) where p is the Start point, e is the End point. Move(start=s, end=e) where s is the Start point, e is the End point. """ PathSegment.__init__(self, **kwargs) self.end = None self.start = None if len(args) == 0: if "end" in kwargs: self.end = kwargs["end"] if "start" in kwargs: self.start = kwargs["start"] elif len(args) == 1: if len(kwargs) == 0: self.end = args[0] else: if "end" in kwargs: self.start = args[0] self.end = kwargs["end"] elif "start" in kwargs: self.start = kwargs["start"] self.end = args[0] elif len(args) == 2: self.start = args[0] self.end = args[1] if self.start is not None: self.start = Point(self.start) if self.end is not None: self.end = Point(self.end) def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): if self.start is not None: self.start *= other if self.end is not None: self.end *= other return self def __copy__(self): return Move(self.start, self.end, relative=self.relative) def __eq__(self, other): if not isinstance(other, Move): return NotImplemented return self.start == other.start and self.end == other.end def __ne__(self, other): if not isinstance(other, Move): return NotImplemented return not self == other def __len__(self): return 2 def __getitem__(self, item): if item == 0: return self.start elif item == 1: return self.end else: raise IndexError def d(self, current_point=None, relative=None, smooth=None): if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "M %s" % self.end return "m %s" % (self.end - current_point) class Curve(PathSegment): """Represents curve commands""" def __init__(self, start=None, end=None, **kwargs): PathSegment.__init__(self, **kwargs) self.start = Point(start) if start is not None else None self.end = Point(end) if end is not None else None class Linear(PathSegment): """Represents line commands.""" def __init__(self, start=None, end=None, **kwargs): PathSegment.__init__(self, **kwargs) self.start = Point(start) if start is not None else None self.end = Point(end) if end is not None else None def __copy__(self): return self.__class__(self.start, self.end, relative=self.relative) def __eq__(self, other): if not isinstance(other, self.__class__): return NotImplemented return self.start == other.start and self.end == other.end def __ne__(self, other): if not isinstance(other, self.__class__): return NotImplemented return not self == other def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): if self.start is not None: self.start *= other if self.end is not None: self.end *= other return self def __len__(self): return 2 def __getitem__(self, item): if item == 0: return self.start elif item == 1: return self.end else: raise IndexError def npoint(self, positions): try: import numpy as np xy = np.empty(shape=(len(positions), 2), dtype=float) xy[:, 0] = np.interp(positions, [0, 1], [self.start.x, self.end.x]) xy[:, 1] = np.interp(positions, [0, 1], [self.start.y, self.end.y]) return xy except ImportError: return [Point.towards(self.start, self.end, pos) for pos in positions] def length(self, error=None, min_depth=None): if self.start is not None and self.end is not None: return Point.distance(self.end, self.start) else: return 0 def closest_segment_point(self, p, respect_bounds=True): """Gives the point on the line closest to the given point.""" a = self.start b = self.end v_ap_x = p[0] - a.x v_ap_y = p[1] - a.y v_ab_x = b.x - a.x v_ab_y = b.y - a.y sq_distance_ab = v_ab_x * v_ab_x + v_ab_y * v_ab_y ab_ap_product = v_ab_x * v_ap_x + v_ab_y * v_ap_y if sq_distance_ab == 0: return 0 # Line is point. amount = ab_ap_product / float(sq_distance_ab) if respect_bounds: if amount > 1: amount = 1 if amount < 0: amount = 0 return self.point(amount) def d(self, current_point=None, relative=None, smooth=None): raise NotImplementedError class Close(Linear): """Represents close commands. If this exists at the end of the shape then the shape is closed. the methodology of a single flag close fails in a couple ways. You can have multi-part shapes which can close or not close several times. """ def d(self, current_point=None, relative=None, smooth=None): if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "Z" else: return "z" class Line(Linear): """Represents line commands.""" def d(self, current_point=None, relative=None, smooth=None): if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "L %s" % self.end else: return "l %s" % (self.end - current_point) class QuadraticBezier(Curve): """Represents Quadratic Bezier commands.""" def __init__(self, start, control, end, **kwargs): Curve.__init__(self, start, end, **kwargs) self.control = Point(control) if control is not None else None def __repr__(self): return "QuadraticBezier(start=%s, control=%s, end=%s)" % ( repr(self.start), repr(self.control), repr(self.end), ) def __copy__(self): return QuadraticBezier( self.start, self.control, self.end, relative=self.relative, smooth=self.smooth, ) def __eq__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return ( self.start == other.start and self.end == other.end and self.control == other.control ) def __ne__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return not self == other def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): if self.start is not None: self.start *= other if self.control is not None: self.control *= other if self.end is not None: self.end *= other return self def __len__(self): return 3 def __getitem__(self, item): if item == 0: return self.start elif item == 1: return self.control elif item == 2: return self.end raise IndexError def npoint(self, positions): """Calculate the x,y position at a certain position of the path. `pos` may be a float or a NumPy array.""" x0, y0 = self.start x1, y1 = self.control x2, y2 = self.end def _compute_point(position): # compute factors n_pos = 1 - position pos_2 = position * position n_pos_2 = n_pos * n_pos n_pos_pos = n_pos * position return ( n_pos_2 * x0 + 2 * n_pos_pos * x1 + pos_2 * x2, n_pos_2 * y0 + 2 * n_pos_pos * y1 + pos_2 * y2, ) try: import numpy as np xy = np.empty(shape=(len(positions), 2)) xy[:, 0], xy[:, 1] = _compute_point(np.array(positions)) return xy except ImportError: return [Point(*_compute_point(position)) for position in positions] def bbox(self): """ Returns the bounding box for the quadratic bezier curve. """ n = self.start.x - self.control.x d = self.start.x - 2 * self.control.x + self.end.x if d != 0: t = n / float(d) else: t = 0.5 if 0 < t < 1: x_values = [self.start.x, self.end.x, self.point(t).x] else: x_values = [self.start.x, self.end.x] n = self.start.y - self.control.y d = self.start.y - 2 * self.control.y + self.end.y if d != 0: t = n / float(d) else: t = 0.5 if 0 < t < 1: y_values = [self.start.y, self.end.y, self.point(t).y] else: y_values = [self.start.y, self.end.y] return min(x_values), min(y_values), max(x_values), max(y_values) def length(self, error=None, min_depth=None): """Calculate the length of the path up to a certain position""" a = self.start - 2 * self.control + self.end b = 2 * (self.control - self.start) try: # For an explanation of this case, see # http://www.malczak.info/blog/quadratic-bezier-curve-length/ A = 4 * (a.real * a.real + a.imag * a.imag) B = 4 * (a.real * b.real + a.imag * b.imag) C = b.real * b.real + b.imag * b.imag Sabc = 2 * sqrt(A + B + C) A2 = sqrt(A) A32 = 2 * A * A2 C2 = 2 * sqrt(C) BA = B / A2 s = ( A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B * B) * log((2 * A2 + BA + Sabc) / (BA + C2)) ) / (4 * A32) except (ZeroDivisionError, ValueError): # a_dot_b = a.real * b.real + a.imag * b.imag if abs(a) < 1e-10: s = abs(b) else: k = abs(b) / abs(a) if k >= 2: s = abs(b) - abs(a) else: s = abs(a) * (k * k / 2 - k + 1) return s def is_smooth_from(self, previous): """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, QuadraticBezier): return self.start == previous.end and (self.control - self.start) == ( previous.end - previous.control ) else: return self.control == self.start def d(self, current_point=None, relative=None, smooth=None): if (smooth is None and self.smooth) or (smooth is not None and smooth): if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "T %s" % self.end else: return "t %s" % (self.end - current_point) else: if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "Q %s %s" % (self.control, self.end) else: return "q %s %s" % ( self.control - current_point, self.end - current_point, ) class CubicBezier(Curve): """Represents Cubic Bezier commands.""" def __init__(self, start, control1, control2, end, **kwargs): Curve.__init__(self, start, end, **kwargs) self.control1 = Point(control1) if control1 is not None else None self.control2 = Point(control2) if control1 is not None else None def __repr__(self): return "CubicBezier(start=%s, control1=%s, control2=%s, end=%s)" % ( repr(self.start), repr(self.control1), repr(self.control2), repr(self.end), ) def __copy__(self): return CubicBezier( self.start, self.control1, self.control2, self.end, relative=self.relative, smooth=self.smooth, ) def __eq__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return ( self.start == other.start and self.end == other.end and self.control1 == other.control1 and self.control2 == other.control2 ) def __ne__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return not self == other def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): if self.start is not None: self.start *= other if self.control1 is not None: self.control1 *= other if self.control2 is not None: self.control2 *= other if self.end is not None: self.end *= other return self def __len__(self): return 4 def __getitem__(self, item): if item == 0: return self.start elif item == 1: return self.control1 elif item == 2: return self.control2 elif item == 3: return self.end else: raise IndexError def reverse(self): PathSegment.reverse(self) c2 = self.control2 self.control2 = self.control1 self.control1 = c2 def npoint(self, positions): """Calculate the x,y position at a certain position of the path. `pos` may be a float or a NumPy array.""" x0, y0 = self.start x1, y1 = self.control1 x2, y2 = self.control2 x3, y3 = self.end def _compute_point(position): # compute factors pos_3 = position * position * position n_pos = 1 - position n_pos_3 = n_pos * n_pos * n_pos pos_2_n_pos = position * position * n_pos n_pos_2_pos = n_pos * n_pos * position return ( n_pos_3 * x0 + 3 * (n_pos_2_pos * x1 + pos_2_n_pos * x2) + pos_3 * x3, n_pos_3 * y0 + 3 * (n_pos_2_pos * y1 + pos_2_n_pos * y2) + pos_3 * y3, ) try: import numpy as np xy = np.empty(shape=(len(positions), 2)) xy[:, 0], xy[:, 1] = _compute_point(np.array(positions)) return xy except ImportError: return [Point(*_compute_point(position)) for position in positions] def bbox(self): """returns the tight fitting bounding box of the bezier curve. Code by: https://github.com/mathandy/svgpathtools """ xmin, xmax = self._real_minmax(0) ymin, ymax = self._real_minmax(1) return xmin, ymin, xmax, ymax def _real_minmax(self, v): """returns the minimum and maximum for a real cubic bezier, with a non-zero denom Code by: https://github.com/mathandy/svgpathtools """ local_extremizers = [0, 1] a = [c[v] for c in self] denom = a[0] - 3 * a[1] + 3 * a[2] - a[3] if abs(denom) >= 1e-12: delta = ( a[1] * a[1] - (a[0] + a[1]) * a[2] + a[2] * a[2] + (a[0] - a[1]) * a[3] ) if delta >= 0: # otherwise no local extrema sqdelta = sqrt(delta) tau = a[0] - 2 * a[1] + a[2] r1 = (tau + sqdelta) / denom r2 = (tau - sqdelta) / denom if 0 < r1 < 1: local_extremizers.append(r1) if 0 < r2 < 1: local_extremizers.append(r2) else: local_extremizers.append(0.5) local_extrema = [self.point(t)[v] for t in local_extremizers] return min(local_extrema), max(local_extrema) def _length_scipy(self, error=ERROR): from scipy.integrate import quad p0 = complex(*self.start) p1 = complex(*self.control1) p2 = complex(*self.control2) p3 = complex(*self.end) def _abs_derivative(t): return abs( 3 * (p1 - p0) * (1 - t) ** 2 + 6 * (p2 - p1) * (1 - t) * t + 3 * (p3 - p2) * t ** 2 ) return quad(_abs_derivative, 0.0, 1.0, epsabs=error, limit=1000)[0] def _length_default(self, error=ERROR, min_depth=MIN_DEPTH): return self._line_length(0, 1, error, min_depth) def length(self, error=ERROR, min_depth=MIN_DEPTH): """Calculate the length of the path up to a certain position""" try: return self._length_scipy(error) except: # Fallback on any failure return self._length_default(error, min_depth) def is_smooth_from(self, previous): """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, CubicBezier): return self.start == previous.end and (self.control1 - self.start) == ( previous.end - previous.control2 ) else: return self.control1 == self.start def d(self, current_point=None, relative=None, smooth=None): if (smooth is None and self.smooth) or (smooth is not None and smooth): if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "S %s %s" % (self.control2, self.end) else: return "s %s %s" % ( self.control2 - current_point, self.end - current_point, ) else: if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "C %s %s %s" % (self.control1, self.control2, self.end) else: return "c %s %s %s" % ( self.control1 - current_point, self.control2 - current_point, self.end - current_point, ) class Arc(Curve): def __init__(self, *args, **kwargs): """ Represents Arc commands. Arc objects can take different parameters to create arcs. Since we expect taking in SVG parameters. We accept SVG parameterization which is: start, rx, ry, rotation, arc_flag, sweep_flag, end. To do matrix transitions, the native parameterization is start, end, center, prx, pry, sweep 'start, end, center, prx, pry' are points and sweep amount is a t value in tau radians. If points are modified by an affine transformation, the arc is transformed. There is a special case for when the scale factor inverts, it inverts the sweep. Note: t-values are not angles from center in elliptical arcs. These are the same thing in circular arcs. But, here t is a parameterization around the ellipse, as if it were a circle. The position on the arc is (a * cos(t), b * sin(t)). If r-major was 0 for example. The positions would all fall on the x-axis. And the angle from center would all be either 0 or tau/2. However, since t is the parameterization we can conceptualize it as a position on a circle which is then scaled and rotated by a matrix. prx is the point at t 0 in the ellipse. pry is the point at t tau/4 in the ellipse. prx -> center -> pry should form a right triangle. The rotation can be defined as the angle from center to prx. Since prx is located at t(0) its deviation can only be the result of a rotation. Sweep is a value in t. The sweep angle can be a value greater than tau and less than -tau. However if this is the case, conversion back to Path.d() is expected to fail. We can denote these arc events but not as a single command. start_t + sweep = end_t """ Curve.__init__(self, **kwargs) self.center = None self.prx = None self.pry = None self.sweep = None if len(args) == 6 and isinstance(args[1], complex): self._svg_complex_parameterize(*args) return elif len(kwargs) == 6 and "rotation" in kwargs: self._svg_complex_parameterize(**kwargs) return elif len(args) == 7: # This is an svg parameterized call. # A: rx ry x-axis-rotation large-arc-flag sweep-flag x y self._svg_parameterize( args[0], args[1], args[2], args[3], args[4], args[5], args[6] ) return if ( "left" in kwargs and "right" in kwargs and "top" in kwargs and "bottom" in kwargs ): left = kwargs["left"] right = kwargs["right"] top = kwargs["top"] bottom = kwargs["bottom"] self.center = Point((left + right) / 2.0, (top + bottom) / 2.0) rx = (right - left) / 2.0 ry = (bottom - top) / 2.0 self.prx = Point(self.center.x + rx, self.center.y) self.pry = Point(self.center.x, self.center.y + ry) len_args = len(args) if len_args > 0: if args[0] is not None: self.start = Point(args[0]) if len_args > 1: if args[1] is not None: self.end = Point(args[1]) if len_args > 2: if args[2] is not None: self.center = Point(args[2]) if len_args > 3: if args[3] is not None: self.prx = Point(args[3]) if len_args > 4: if args[4] is not None: self.pry = Point(args[4]) if len_args > 5: self.sweep = args[5] return # The args gave us everything. if "start" in kwargs: self.start = Point(kwargs["start"]) if "end" in kwargs: self.end = Point(kwargs["end"]) if "center" in kwargs: self.center = Point(kwargs["center"]) if "prx" in kwargs: self.prx = Point(kwargs["prx"]) if "pry" in kwargs: self.pry = Point(kwargs["pry"]) if "sweep" in kwargs: self.sweep = kwargs["sweep"] cw = True # Clockwise default. (sometimes needed) if self.start is not None and self.end is not None and self.center is None: # Start and end, but no center. # Solutions require a radius, a control point, or a bulge control = None sagitta = None if "bulge" in kwargs: bulge = float(kwargs["bulge"]) sagitta = bulge * self.start.distance_to(self.end) / 2.0 elif "sagitta" in kwargs: sagitta = float(kwargs["sagitta"]) if sagitta is not None: control = Point.towards(self.start, self.end, 0.5) angle = self.start.angle_to(self.end) control = control.polar_to(angle - tau / 4.0, sagitta) if "control" in kwargs: # Control is any additional point on the arc. control = Point(kwargs["control"]) if control is not None: delta_a = control - self.start delta_b = self.end - control if abs(delta_a.x) > 1e-12: slope_a = delta_a.y / delta_a.x else: slope_a = float("inf") if abs(delta_b.x) > 1e-12: slope_b = delta_b.y / delta_b.x else: slope_b = float("inf") ab_mid = Point.towards(self.start, control, 0.5) bc_mid = Point.towards(control, self.end, 0.5) if abs(delta_a.y) < 1e-12: # slope_a == 0 cx = ab_mid.x if abs(delta_b.x) < 1e-12: # slope_b == inf cy = bc_mid.y else: cy = bc_mid.y + (bc_mid.x - cx) / slope_b elif abs(delta_b.y) < 1e-12: # slope_b == 0 cx = bc_mid.x if abs(delta_a.y) < 1e-12: # slope_a == inf cy = ab_mid.y else: cy = ab_mid.y + (ab_mid.x - cx) / slope_a elif abs(delta_a.x) < 1e-12: # slope_a == inf cy = ab_mid.y cx = slope_b * (bc_mid.y - cy) + bc_mid.x elif abs(delta_b.x) < 1e-12: # slope_b == inf cy = bc_mid.y cx = slope_a * (ab_mid.y - cy) + ab_mid.x elif abs(slope_a - slope_b) < 1e-12: cx = ab_mid.x cy = ab_mid.y else: cx = ( slope_a * slope_b * (ab_mid.y - bc_mid.y) - slope_a * bc_mid.x + slope_b * ab_mid.x ) / (slope_b - slope_a) cy = ab_mid.y - (cx - ab_mid.x) / slope_a self.center = Point(cx, cy) cw = bool(Point.orientation(self.start, control, self.end) == 2) elif "r" in kwargs: r = kwargs["r"] mid = Point( (self.start.x + self.end.x) / 2.0, (self.start.y + self.end.y) / 2.0 ) q = Point.distance(self.start, self.end) hq = q / 2.0 if r < hq: kwargs["r"] = r = hq # Correct potential math domain error. self.center = Point( mid.x + sqrt(r * r - hq * hq) * (self.start.y - self.end.y) / q, mid.y + sqrt(r * r - hq * hq) * (self.end.x - self.start.x) / q, ) cw = bool(Point.orientation(self.start, self.center, self.end) == 1) if "ccw" in kwargs and kwargs["ccw"] and cw or not cw: # ccw arg exists, is true, and we found the cw center, or we didn't find the cw center. self.center = Point( mid.x - sqrt(r * r - hq * hq) * (self.start.y - self.end.y) / q, mid.y - sqrt(r * r - hq * hq) * (self.end.x - self.start.x) / q, ) elif "rx" in kwargs and "ry" in kwargs: # This formulation will assume p1 and p2 are both axis aligned. # rx = kwargs["rx"] # ry = kwargs["ry"] # We will assume rx == abs(self.start.x - self.end.x) self.sweep = tau / 4.0 self.center = Point(self.start.x, self.end.y) cw = bool(Point.orientation(self.start, self.center, self.end) == 1) if "scooped" in kwargs and kwargs["scooped"]: self.sweep = -self.sweep cw = not cw if ("ccw" in kwargs and kwargs["ccw"] and cw) or not cw: self.center = Point(self.end.x, self.start.y) if self.center is None: raise ValueError("Not enough values to solve for center.") if "r" in kwargs: r = kwargs["r"] if self.prx is None: self.prx = Point(self.center.x + r, self.center.y) if self.pry is None: self.pry = Point(self.center.x, self.center.y + r) if "rx" in kwargs: rx = kwargs["rx"] if self.prx is None: if "rotation" in kwargs: theta = kwargs["rotation"] self.prx = Point.polar(self.center, theta, rx) else: self.prx = Point(self.center.x + rx, self.center.y) if "ry" in kwargs: ry = kwargs["ry"] if self.pry is None: if "rotation" in kwargs: theta = kwargs["rotation"] theta += tau / 4.0 self.pry = Point.polar(self.center, theta, ry) else: self.pry = Point(self.center.x, self.center.y + ry) if self.start is not None and (self.prx is None or self.pry is None): radius_s = Point.distance(self.center, self.start) self.prx = Point(self.center.x + radius_s, self.center.y) self.pry = Point(self.center.x, self.center.y + radius_s) if self.end is not None and (self.prx is None or self.pry is None): radius_e = Point.distance(self.center, self.end) self.prx = Point(self.center.x + radius_e, self.center.y) self.pry = Point(self.center.x, self.center.y + radius_e) if self.sweep is None and self.start is not None and self.end is not None: start_t = self.get_start_t() end_t = self.get_end_t() self.sweep = end_t - start_t if "ccw" in kwargs: cw = not bool(kwargs["ccw"]) if cw and self.sweep < 0: self.sweep += tau if not cw and self.sweep > 0: self.sweep -= tau if self.sweep is not None and self.start is not None and self.end is None: start_t = self.get_start_t() end_t = start_t + self.sweep self.end = self.point_at_t(end_t) if self.sweep is not None and self.start is None and self.end is not None: end_t = self.get_end_t() start_t = end_t - self.sweep self.end = self.point_at_t(start_t) def __repr__(self): return "Arc(start=%s, end=%s, center=%s, prx=%s, pry=%s, sweep=%s)" % ( repr(self.start), repr(self.end), repr(self.center), repr(self.prx), repr(self.pry), self.sweep, ) def __copy__(self): return Arc( self.start, self.end, self.center, self.prx, self.pry, self.sweep, relative=self.relative, ) def __eq__(self, other): if not isinstance(other, Arc): return NotImplemented return ( self.start == other.start and self.end == other.end and self.prx == other.prx and self.pry == other.pry and self.center == other.center and self.sweep == other.sweep ) def __ne__(self, other): if not isinstance(other, Arc): return NotImplemented return not self == other def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): if self.start is not None: self.start *= other if self.center is not None: self.center *= other if self.end is not None: self.end *= other if self.prx is not None: self.prx *= other if self.pry is not None: self.pry *= other if other.value_scale_x() < 0: self.sweep = -self.sweep if other.value_scale_y() < 0: self.sweep = -self.sweep return self def __len__(self): return 5 def __getitem__(self, item): if item == 0: return self.start elif item == 1: return self.end elif item == 2: return self.center elif item == 3: return self.prx elif item == 4: return self.pry raise IndexError @property def theta(self): """legacy property""" return Angle.radians(self.get_start_t()).as_positive_degrees @property def delta(self): """legacy property""" return Angle.radians(self.sweep).as_degrees def reverse(self): PathSegment.reverse(self) self.sweep = -self.sweep def npoint(self, positions): try: import numpy as np return self._points_numpy(np.array(positions)) except ImportError: if self.start == self.end and self.sweep == 0: # This is equivalent of omitting the segment return [self.start] * len(positions) start_t = self.get_start_t() return [ self.start if pos == 0 else self.end if pos == 1 else self.point_at_t(start_t + self.sweep * pos) for pos in positions ] def _points_numpy(self, positions): """Vectorized version of `point()`. :param positions: 1D numpy array of float in [0, 1] :return: 1D numpy array of complex """ import numpy as np xy = np.empty((len(positions), 2), dtype=float) if self.start == self.end and self.sweep == 0: xy[:, 0], xy[:, 1] = self.start else: t = self.get_start_t() + self.sweep * positions rotation = self.get_rotation() a = self.rx b = self.ry cx = self.center.x cy = self.center.y cos_rot = cos(rotation) sin_rot = sin(rotation) cos_t = np.cos(t) sin_t = np.sin(t) xy[:, 0] = cx + a * cos_t * cos_rot - b * sin_t * sin_rot xy[:, 1] = cy + a * cos_t * sin_rot + b * sin_t * cos_rot # ensure clean endings xy[positions == 0, :] = list(self.start) xy[positions == 1, :] = list(self.end) return xy def _integral_length(self): def ellipse_part_integral(t1, t2, a, b, n=100000): # function to integrate def f(t): sint = sin(t) return sqrt(1 - (1 - (a * a) / (b * b)) * sint * sint) start = min(t1, t2) seg_len = abs(t1 - t2) / n return b * sum(f(start + seg_len * i) * seg_len for i in range(1, n + 1)) start_angle = self.get_start_t() end_angle = start_angle + self.sweep return ellipse_part_integral(start_angle, end_angle, self.rx, self.ry) def _exact_length(self): """scipy is not a dependency. However, if scipy exists this function will find the exact arc length. By default .length() delegates to here and on failure uses the fallback method.""" from scipy.special import ellipeinc a = self.rx b = self.ry adb = a / b m = 1 - adb * adb phi = self.get_start_t() d1 = ellipeinc(phi, m) phi = phi + self.sweep d2 = ellipeinc(phi, m) return b * abs(d2 - d1) def length(self, error=ERROR, min_depth=MIN_DEPTH): """The length of an elliptical arc segment requires numerical integration, and in that case it's simpler to just do a geometric approximation, as for cubic bezier curves. """ if self.sweep == 0: return 0 if self.start == self.end and self.sweep == 0: # This is equivalent of omitting the segment return 0 a = self.rx b = self.ry d = abs(a - b) if d < ERROR: # This is a circle. return abs(self.rx * self.sweep) try: return self._exact_length() except: # Fallback on any failure return self._line_length(error=error, min_depth=min_depth) def _svg_complex_parameterize( self, start, radius, rotation, arc_flag, sweep_flag, end ): """Parameterization with complex radius and having rotation factors.""" self._svg_parameterize( Point(start), radius.real, radius.imag, rotation, arc_flag, sweep_flag, Point(end), ) def _svg_parameterize( self, start, rx, ry, rotation, large_arc_flag, sweep_flag, end ): """Conversion from svg parameterization, our chosen native native form. http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes""" large_arc_flag = bool(large_arc_flag) sweep_flag = bool(sweep_flag) start = Point(start) self.start = start end = Point(end) self.end = end if start == end or rx == 0 or ry == 0: # If start is equal to end, there are infinite number of circles so these void out. # We still permit this kind of arc, but SVG parameterization cannot be used to achieve it. self.sweep = 0 self.prx = Point(start) self.pry = Point(start) self.center = Point(start) return cosr = cos(radians(rotation)) sinr = sin(radians(rotation)) dx = (start.real - end.real) / 2 dy = (start.imag - end.imag) / 2 x1prim = cosr * dx + sinr * dy x1prim_sq = x1prim * x1prim y1prim = -sinr * dx + cosr * dy y1prim_sq = y1prim * y1prim rx_sq = rx * rx ry_sq = ry * ry # Correct out of range radii radius_check = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq) if radius_check > 1: rx *= sqrt(radius_check) ry *= sqrt(radius_check) rx_sq = rx * rx ry_sq = ry * ry t1 = rx_sq * y1prim_sq t2 = ry_sq * x1prim_sq c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2))) if large_arc_flag == sweep_flag: c = -c cxprim = c * rx * y1prim / ry cyprim = -c * ry * x1prim / rx center = Point( (cosr * cxprim - sinr * cyprim) + ((start.real + end.real) / 2), (sinr * cxprim + cosr * cyprim) + ((start.imag + end.imag) / 2), ) ux = (x1prim - cxprim) / rx uy = (y1prim - cyprim) / ry vx = (-x1prim - cxprim) / rx vy = (-y1prim - cyprim) / ry n = sqrt(ux * ux + uy * uy) p = ux # theta = degrees(acos(p / n)) # if uy < 0: # theta = -theta # theta = theta % 360 n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) p = ux * vx + uy * vy d = p / n # In certain cases the above calculation can through inaccuracies # become just slightly out of range, f ex -1.0000000000000002. if d > 1.0: d = 1.0 elif d < -1.0: d = -1.0 delta = degrees(acos(d)) if (ux * vy - uy * vx) < 0: delta = -delta delta = delta % 360 if not sweep_flag: delta -= 360 # built parameters, delta, theta, center rotate_matrix = Matrix() rotate_matrix.post_rotate( Angle.degrees(rotation).as_radians, center.x, center.y ) self.center = center self.prx = Point(center.x + rx, center.y) self.pry = Point(center.x, center.y + ry) self.prx.matrix_transform(rotate_matrix) self.pry.matrix_transform(rotate_matrix) self.sweep = Angle.degrees(delta).as_radians def as_quad_curves(self, arc_required=None): if arc_required is None: sweep_limit = tau / 12.0 arc_required = int(ceil(abs(self.sweep) / sweep_limit)) if arc_required == 0: return t_slice = self.sweep / float(arc_required) current_t = self.get_start_t() p_start = self.start theta = self.get_rotation() cos_theta = cos(theta) sin_theta = sin(theta) a = self.rx b = self.ry cx = self.center.x cy = self.center.y for i in range(0, arc_required): next_t = current_t + t_slice mid_t = (next_t + current_t) / 2 p_end = self.point_at_t(next_t) if i == arc_required - 1: p_end = self.end cos_mid_t = cos(mid_t) sin_mid_t = sin(mid_t) alpha = (4.0 - cos(t_slice)) / 3.0 px = cx + alpha * (a * cos_mid_t * cos_theta - b * sin_mid_t * sin_theta) py = cy + alpha * (a * cos_mid_t * sin_theta + b * sin_mid_t * cos_theta) yield QuadraticBezier(p_start, (px, py), p_end) p_start = p_end current_t = next_t def as_cubic_curves(self, arc_required=None): if arc_required is None: sweep_limit = tau / 12.0 arc_required = int(ceil(abs(self.sweep) / sweep_limit)) if arc_required == 0: return t_slice = self.sweep / float(arc_required) theta = self.get_rotation() rx = self.rx ry = self.ry p_start = self.start current_t = self.get_start_t() x0 = self.center.x y0 = self.center.y cos_theta = cos(theta) sin_theta = sin(theta) for i in range(0, arc_required): next_t = current_t + t_slice alpha = sin(t_slice) * (sqrt(4 + 3 * pow(tan(t_slice / 2.0), 2)) - 1) / 3.0 cos_start_t = cos(current_t) sin_start_t = sin(current_t) ePrimen1x = -rx * cos_theta * sin_start_t - ry * sin_theta * cos_start_t ePrimen1y = -rx * sin_theta * sin_start_t + ry * cos_theta * cos_start_t cos_end_t = cos(next_t) sin_end_t = sin(next_t) p2En2x = x0 + rx * cos_end_t * cos_theta - ry * sin_end_t * sin_theta p2En2y = y0 + rx * cos_end_t * sin_theta + ry * sin_end_t * cos_theta p_end = (p2En2x, p2En2y) if i == arc_required - 1: p_end = self.end ePrimen2x = -rx * cos_theta * sin_end_t - ry * sin_theta * cos_end_t ePrimen2y = -rx * sin_theta * sin_end_t + ry * cos_theta * cos_end_t p_c1 = (p_start[0] + alpha * ePrimen1x, p_start[1] + alpha * ePrimen1y) p_c2 = (p_end[0] - alpha * ePrimen2x, p_end[1] - alpha * ePrimen2y) yield CubicBezier(p_start, p_c1, p_c2, p_end) p_start = Point(p_end) current_t = next_t def is_circular(self): a = self.rx b = self.ry return a == b @property def radius(self): """Legacy complex radius property Point will work like a complex for legacy reasons. """ return Point(self.rx, self.ry) @property def rx(self): return Point.distance(self.center, self.prx) @property def ry(self): return Point.distance(self.center, self.pry) def get_rotation(self): return Point.angle(self.center, self.prx) def get_start_angle(self): """ :return: Angle from the center point to start point. """ return self.angle_at_point(self.start) def get_end_angle(self): """ :return: Angle from the center point to end point. """ return self.angle_at_point(self.end) def get_start_t(self): """ start t value in the ellipse. :return: t parameter of start point. """ return self.t_at_point(self.point_at_angle(self.get_start_angle())) def get_end_t(self): """ end t value in the ellipse. :return: t parameter of start point. """ return self.t_at_point(self.point_at_angle(self.get_end_angle())) def point_at_angle(self, angle): """ find the point on the ellipse from the center at the given angle. Note: For non-circular arcs this is different than point(t). :param angle: angle from center to find point :return: point found """ angle -= self.get_rotation() a = self.rx b = self.ry if a == b: return self.point_at_t(angle) t = atan2(a * tan(angle), b) tau_1_4 = tau / 4.0 tau_3_4 = 3 * tau_1_4 if tau_3_4 >= abs(angle) % tau > tau_1_4: t += tau / 2.0 return self.point_at_t(t) def angle_at_point(self, p): """ find the angle to the point. :param p: point :return: angle to given point. """ return self.center.angle_to(p) def t_at_point(self, p): """ find the t parameter to at the point. :param p: point :return: t parameter to the given point. """ angle = self.angle_at_point(p) angle -= self.get_rotation() a = self.rx b = self.ry t = atan2(a * tan(angle), b) tau_1_4 = tau / 4.0 tau_3_4 = 3 * tau_1_4 if tau_3_4 >= abs(angle) % tau > tau_1_4: t += tau / 2.0 return t def point_at_t(self, t): """ find the point that corresponds to given value t. Where t=0 is the first point and t=tau is the final point. In the case of a circle: t = angle. :param t: :return: """ rotation = self.get_rotation() a = self.rx b = self.ry cx = self.center.x cy = self.center.y cos_rot = cos(rotation) sin_rot = sin(rotation) cos_t = cos(t) sin_t = sin(t) px = cx + a * cos_t * cos_rot - b * sin_t * sin_rot py = cy + a * cos_t * sin_rot + b * sin_t * cos_rot return Point(px, py) def get_ellipse(self): return Ellipse(self.center, self.rx, self.ry, self.get_rotation()) def bbox(self): """Find the bounding box of a arc. Code from: https://github.com/mathandy/svgpathtools """ if self.sweep == 0: return self.start.x, self.start.y, self.end.x, self.end.y phi = self.get_rotation().as_radians if cos(phi) == 0: atan_x = tau / 4.0 atan_y = 0 elif sin(phi) == 0: atan_x = 0 atan_y = tau / 4.0 else: rx, ry = self.rx, self.ry atan_x = atan(-(ry / rx) * tan(phi)) atan_y = atan((ry / rx) / tan(phi)) def angle_inv(ang, k): # inverse of angle from Arc.derivative() return ((ang + (tau / 2.0) * k) * (360 / tau) - self.theta) / self.delta xtrema = [self.start.x, self.end.x] ytrema = [self.start.y, self.end.y] for k in range(-4, 5): tx = angle_inv(atan_x, k) ty = angle_inv(atan_y, k) if 0 <= tx <= 1: xtrema.append(self.point(tx).x) if 0 <= ty <= 1: ytrema.append(self.point(ty).y) return min(xtrema), min(ytrema), max(xtrema), max(ytrema) def d(self, current_point=None, relative=None, smooth=None): if ( current_point is None or (relative is None and self.relative) or (relative is not None and not relative) ): return "A %G,%G %G %d,%d %s" % ( self.rx, self.ry, self.get_rotation().as_degrees, int(abs(self.sweep) > (tau / 2.0)), int(self.sweep >= 0), self.end, ) else: return "a %G,%G %G %d,%d %s" % ( self.rx, self.ry, self.get_rotation().as_degrees, int(abs(self.sweep) > (tau / 2.0)), int(self.sweep >= 0), self.end - current_point, ) class Path(Shape, MutableSequence): """ A Path is a Mutable sequence of path segments It is a generalized shape which can map out all the other shapes. Each PathSegment object maps a particular command. Each one exists only once in each path and every point contained within the object is also unique. We attempt to internally maintain some validity. Each end point should link to the following segments start point. And each close point should connect from the preceding segments endpoint to the last Move command. These are soft checks made only at the time of addition and some manipulations. Modifying the points of the segments can and will cause path invalidity. Some SVG invalid operations are permitted such as arcs longer than tau radians or beginning sequences without a move. The expectation is that these will eventually be used as part of a valid path so these fragment paths are permitted. In some cases these invalid paths will still have consistent path_d values, in other cases, there will be no valid methods to reproduce these. Instantiation formats: Path("d-string", keywords) Path(pathsegment1,...) Path(d="d-string", other keywords) """ def __init__(self, *args, **kwargs): Shape.__init__(self, *args, **kwargs) self._length = None self._lengths = None self._segments = list() if len(args) != 1: for segment in args: if not isinstance(segment, PathSegment): raise ValueError( "Object not PathSegment when instantiating a Path: %s" % segment.__class__.__name__ ) self._segments.extend(args) else: s = args[0] if isinstance(s, Subpath): self._segments.extend(s.segments(transformed=False)) Shape.__init__(self, s._path) elif isinstance(s, Shape): self._segments.extend(s.segments(transformed=False)) elif isinstance(s, str): self._segments = list() self.parse(s) elif isinstance(s, tuple): # We have no guarantee of the validity of the source data self._segments.extend(s) self.validate_connections() elif isinstance(s, list): # We have no guarantee of the validity of the source data self._segments.extend(s) self.validate_connections() elif isinstance(s, PathSegment): self._segments.append(s) if SVG_ATTR_DATA in self.values: # Not sure what the purpose of pathd_loaded is. # It is only set and checked here and you cannot have "d" attribute more than once anyway if not self.values.get("pathd_loaded", False): self.parse(self.values[SVG_ATTR_DATA]) self.values["pathd_loaded"] = True def __copy__(self): path = Path(self) segs = path._segments for i in range(0, len(segs)): segs[i] = copy(segs[i]) return path def __getitem__(self, index): return self._segments[index] def _validate_subpath(self, index): """ensure the subpath containing this index is valid.""" if index < 0 or index + 1 >= len(self._segments): return # This connection doesn't exist. for j in range(index, len(self._segments)): close_search = self._segments[j] if isinstance(close_search, Move): return # Not a closed path, subpath is valid. if isinstance(close_search, Close): for k in range(index, -1, -1): move_search = self._segments[k] if isinstance(move_search, Move): self._segments[j].end = Point(move_search.end) return self._segments[j].end = Point(self._segments[0].end) return def _validate_move(self, index): """ensure the next closed point from this index points to a valid location.""" for i in range(index + 1, len(self._segments)): segment = self._segments[i] if isinstance(segment, Move): return # Not a closed path, the move is valid. if isinstance(segment, Close): segment.end = Point(self._segments[index].end) return def _validate_close(self, index): """ensure the close element at this position correctly links to the previous move""" for i in range(index, -1, -1): segment = self._segments[i] if isinstance(segment, Move): self._segments[index].end = Point(segment.end) return self._segments[index].end = ( Point(self._segments[0].end) if self._segments[0].end is not None else None ) # If move is never found, just the end point of the first element. Unless that's not a thing. def _validate_connection(self, index, prefer_second=False): """ Validates the connection at the index. Connection 0 is the connection between getitem(0) and getitem(1) prefer_second is for those cases where failing the connection requires replacing a existing value. It will prefer the authority of right side, second value. """ if index < 0 or index + 1 >= len(self._segments): return # This connection doesn't exist. first = self._segments[index] second = self._segments[index + 1] if first.end is not None and second.start is None: second.start = Point(first.end) elif first.end is None and second.start is not None: first.end = Point(second.start) elif first.end != second.start: # The two values exist but are not equal. One must replace the other. if prefer_second: first.end = Point(second.start) else: second.start = Point(first.end) def __setitem__(self, index, new_element): if isinstance(new_element, str): new_element = Path(new_element) if len(new_element) == 0: return new_element = new_element.segments() if isinstance(index, int): if len(new_element) > 1: raise ValueError # Cannot insert multiple items into a single space. Requires slice. new_element = new_element[0] self._segments[index] = new_element self._length = None self._lengths = None if isinstance(index, slice): self.validate_connections() else: self._validate_connection(index - 1) self._validate_connection(index) if isinstance(new_element, Move): self._validate_move(index) if isinstance(new_element, Close): self._validate_close(index) def __delitem__(self, index): original_element = self._segments[index] del self._segments[index] self._length = None if isinstance(index, slice): self.validate_connections() else: self._validate_connection(index - 1) if isinstance(original_element, (Close, Move)): self._validate_subpath(index) def __iadd__(self, other): if isinstance(other, str): self.parse(other) elif isinstance(other, (Path, Subpath)): self.extend(map(copy, list(other))) elif isinstance(other, Shape): self.parse(other.d()) elif isinstance(other, PathSegment): self.append(other) else: return NotImplemented return self def __add__(self, other): if isinstance(other, (str, Path, Subpath, Shape, PathSegment)): n = copy(self) n += other return n return NotImplemented def __radd__(self, other): if isinstance(other, str): path = Path(other) path.extend(map(copy, self._segments)) return path elif isinstance(other, PathSegment): path = copy(self) path.insert(0, other) return path else: return NotImplemented def __len__(self): return len(self._segments) def __str__(self): return self.d() def __repr__(self): values = [] if len(self) > 0: values.append(", ".join(repr(x) for x in self._segments)) self._repr_shape(values) params = ", ".join(values) return "%s(%s)" % (self.__class__.__name__, params) def __eq__(self, other): if isinstance(other, str): return self.__eq__(Path(other)) if not isinstance(other, Path): return NotImplemented if len(self) != len(other): return False p = abs(self) q = abs(other) for s, o in zip(q._segments, p._segments): if not s == o: return False if p.stroke_width != q.stroke_width: return False return True def __ne__(self, other): if not isinstance(other, (Path, str)): return NotImplemented return not self == other def parse(self, pathdef): """Parses the SVG path.""" tokens = SVGLexicalParser() tokens.parse(self, pathdef) def validate_connections(self): """ Force validate all connections. This will scan path connections and link any adjacent elements together by replacing any None points or causing the start position of the next element to equal the end position of the previous. This should only be needed when combining paths and elements together. Close elements are always connected to the last Move element or to the end position of the first element in the list. The start element of the first segment may or may not be None. """ zpoint = None last_segment = None for segment in self._segments: if zpoint is None or isinstance(segment, Move): zpoint = segment.end if last_segment is not None: if segment.start is None and last_segment.end is not None: segment.start = Point(last_segment.end) elif last_segment.end is None and segment.start is not None: last_segment.end = Point(segment.start) elif last_segment.end != segment.start: segment.start = Point(last_segment.end) if ( isinstance(segment, Close) and zpoint is not None and segment.end != zpoint ): segment.end = Point(zpoint) last_segment = segment def _is_valid(self): """ Checks validation of all connections. Paths are valid if all end points match the start of the next point and all close commands return to the last valid move command. This does not check for incongruent path validity. Path fragments without initial moves double closed paths, may all pass this check. """ zpoint = None last_segment = None for segment in self._segments: if zpoint is None or isinstance(segment, Move): zpoint = segment.end if last_segment is not None: if segment.start is None: return False elif last_segment.end is None: return False elif last_segment.end != segment.start: return False if ( isinstance(segment, Close) and zpoint is not None and segment.end != zpoint ): return False last_segment = segment return True @property def first_point(self): """First point along the Path. This is the start point of the first segment unless it starts with a Move command with a None start in which case first point is that Move's destination.""" if len(self._segments) == 0: return None if self._segments[0].start is not None: return Point(self._segments[0].start) return ( Point(self._segments[0].end) if self._segments[0].end is not None else None ) @property def current_point(self): if len(self._segments) == 0: return None return ( Point(self._segments[-1].end) if self._segments[-1].end is not None else None ) @property def z_point(self): """ Z is the destination of the last Move. It can mean, but doesn't necessarily mean the first_point in the path. This behavior of Z is defined in svg spec: http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand """ end_pos = None for segment in reversed(self._segments): if isinstance(segment, Move): end_pos = segment.end break if end_pos is None: try: end_pos = self._segments[0].end except IndexError: pass # entire path is "z". return end_pos @property def smooth_point(self): """Returns the smoothing control point for the smooth commands. With regards to the SVG standard if the last command was a curve the smooth control point is the reflection of the previous control point. If the last command was not a curve, the smooth_point is coincident with the current. https://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands """ if len(self._segments) == 0: return None start_pos = self.current_point last_segment = self._segments[-1] if isinstance(last_segment, QuadraticBezier): previous_control = last_segment.control return previous_control.reflected_across(start_pos) elif isinstance(last_segment, CubicBezier): previous_control = last_segment.control2 return previous_control.reflected_across(start_pos) return start_pos def start(self): pass def end(self): pass def move(self, *points, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point end_pos = points[0] if end_pos in ("z", "Z"): end_pos = self.z_point segment = Move(start_pos, end_pos) segment.relative = relative self.append(segment) if len(points) > 1: self.line(*points[1:], relative=relative) return self def line(self, *points, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point end_pos = points[0] if end_pos in ("z", "Z"): end_pos = self.z_point segment = Line(start_pos, end_pos) segment.relative = relative self.append(segment) if len(points) > 1: self.line(*points[1:]) return self def vertical(self, *y_points, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point if relative: segment = Line(start_pos, Point(start_pos.x, start_pos.y + y_points[0])) else: segment = Line(start_pos, Point(start_pos.x, y_points[0])) segment.relative = relative self.append(segment) if len(y_points) > 1: self.vertical(*y_points[1:], relative=relative) return self def horizontal(self, *x_points, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point if relative: segment = Line(start_pos, Point(start_pos.x + x_points[0], start_pos.y)) segment.relative = relative else: segment = Line(start_pos, Point(x_points[0], start_pos.y)) segment.relative = relative self.append(segment) if len(x_points) > 1: self.horizontal(*x_points[1:], relative=relative) return self def smooth_quad(self, *points, **kwargs): """Smooth curve. First control point is the "reflection" of the second control point in the previous path.""" relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point control1 = self.smooth_point end_pos = points[0] if end_pos in ("z", "Z"): end_pos = self.z_point segment = QuadraticBezier(start_pos, control1, end_pos) segment.relative = relative segment.smooth = True self.append(segment) if len(points) > 1: self.smooth_quad(*points[1:]) return self def quad(self, *points, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point control = points[0] if control in ("z", "Z"): control = self.z_point end_pos = points[1] if end_pos in ("z", "Z"): end_pos = self.z_point segment = QuadraticBezier(start_pos, control, end_pos) segment.relative = relative segment.smooth = False self.append(segment) if len(points) > 2: self.quad(*points[2:]) return self def smooth_cubic(self, *points, **kwargs): """Smooth curve. First control point is the "reflection" of the second control point in the previous path.""" relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point control1 = self.smooth_point control2 = points[0] if control2 in ("z", "Z"): control2 = self.z_point end_pos = points[1] if end_pos in ("z", "Z"): end_pos = self.z_point segment = CubicBezier(start_pos, control1, control2, end_pos) segment.relative = relative segment.smooth = True self.append(segment) if len(points) > 2: self.smooth_cubic(*points[2:]) return self def cubic(self, *points, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point control1 = points[0] if control1 in ("z", "Z"): control1 = self.z_point control2 = points[1] if control2 in ("z", "Z"): control2 = self.z_point end_pos = points[2] if end_pos in ("z", "Z"): end_pos = self.z_point segment = CubicBezier(start_pos, control1, control2, end_pos) segment.relative = relative segment.smooth = False self.append(segment) if len(points) > 3: self.cubic(*points[3:]) return self def arc(self, *arc_args, **kwargs): relative = kwargs["relative"] if "relative" in kwargs else False start_pos = self.current_point rx = arc_args[0] ry = arc_args[1] if rx < 0: rx = abs(rx) if ry < 0: ry = abs(ry) rotation = arc_args[2] arc = arc_args[3] sweep = arc_args[4] end_pos = arc_args[5] if end_pos in ("z", "Z"): end_pos = self.z_point segment = Arc(start_pos, rx, ry, rotation, arc, sweep, end_pos) segment.relative = relative self.append(segment) if len(arc_args) > 6: self.arc(*arc_args[6:]) return self def closed(self, relative=False): start_pos = self.current_point end_pos = self.z_point segment = Close(start_pos, end_pos) segment.relative = relative self.append(segment) return self def append(self, value): if isinstance(value, str): value = Path(value) if len(value) == 0: return if len(value) > 1: self.extend(value) return value = value[0] self._length = None index = len(self._segments) - 1 self._segments.append(value) self._validate_connection(index) if isinstance(value, Close): self._validate_close(index + 1) def insert(self, index, value): if isinstance(value, str): value = Path(value) if len(value) == 0: return value = value[0] self._length = None self._segments.insert(index, value) self._validate_connection(index - 1) self._validate_connection(index) if isinstance(value, Move): self._validate_move(index) if isinstance(value, Close): self._validate_close(index) def extend(self, iterable): if isinstance(iterable, str): iterable = Path(iterable) self._length = None index = len(self._segments) - 1 self._segments.extend(iterable) self._validate_connection(index) self._validate_subpath(index) def direct_close(self): """Forces close operations to be zero length by introducing a direct line to operation just before any non-zero length close. This is helpful because for some operations like reverse() because the close must located at the very end of the path sequence. But, if it's in effect a line-to and close, the line-to would need to start the sequence. But, for some operations this won't matter since it will still result in a closed shape with reversed ordering. But, if the final point in the sequence must exactly switch with the first point in the sequence. The close segments must be direct and zero length. """ if len(self._segments) == 0: return for i in range(len(self._segments) - 1, -1, -1): segment = self._segments[i] if isinstance(segment, Close): if segment.length() != 0: line = Line(segment.start, segment.end) segment.start = Point(segment.end) self.insert(i, line) return self def reverse(self): if len(self._segments) == 0: return prepoint = self._segments[0].start self._segments[0].start = None p = Path() subpaths = list(self.as_subpaths()) for subpath in subpaths: subpath.reverse() for subpath in reversed(subpaths): p += subpath self._segments = p._segments self._segments[0].start = prepoint return self def subpath(self, index): subpaths = list(self.as_subpaths()) return subpaths[index] def count_subpaths(self): subpaths = list(self.as_subpaths()) return len(subpaths) def as_subpaths(self): last = 0 for current, seg in enumerate(self): if current != last and isinstance(seg, Move): yield Subpath(self, last, current - 1) last = current yield Subpath(self, last, len(self) - 1) def as_points(self): """Returns the list of defining points within path""" for seg in self: for p in seg: if not isinstance(p, Point): yield Point(p) else: yield p def reify(self): """ Realizes the transform to the shape properties. Path objects reify perfectly. """ GraphicObject.reify(self) Transformable.reify(self) if isinstance(self.transform, Matrix): for e in self._segments: e *= self.transform self.transform.reset() return self @staticmethod def svg_d(segments, relative=None, smooth=None): if len(segments) == 0: return "" parts = [] previous_segment = None p = Point(0) if smooth is None: override_smooth = False smooth_set_value = True else: override_smooth = True smooth_set_value = bool(smooth) if relative is not None: for segment in segments: if isinstance(segment, (Move, Line, Arc, Close)): parts.append(segment.d(p, relative=relative)) elif isinstance(segment, (CubicBezier, QuadraticBezier)): if (override_smooth and smooth_set_value) or ( not override_smooth and segment.smooth ): parts.append( segment.d( p, relative=relative, smooth=segment.is_smooth_from(previous_segment), ) ) else: parts.append(segment.d(p, relative=relative, smooth=False)) previous_segment = segment p = previous_segment.end else: for segment in segments: if isinstance(segment, (Move, Line, Arc, Close)): parts.append(segment.d(p, relative=segment.relative)) elif isinstance(segment, (CubicBezier, QuadraticBezier)): if (override_smooth and smooth_set_value) or ( not override_smooth and segment.smooth ): parts.append( segment.d( p, relative=segment.relative, smooth=segment.is_smooth_from(previous_segment), ) ) else: parts.append( segment.d(p, relative=segment.relative, smooth=False) ) previous_segment = segment p = previous_segment.end return " ".join(parts) def d(self, relative=None, transformed=True, smooth=None): path = self if transformed: path = abs(path) return Path.svg_d(path._segments, relative=relative, smooth=smooth) def segments(self, transformed=True): if transformed and not self.transform.is_identity(): return [s * self.transform for s in self._segments] return self._segments def approximate_arcs_with_cubics(self, error=0.1): """ Iterates through this path and replaces any Arcs with cubic bezier curves. """ sweep_limit = tau * error for s in range(len(self) - 1, -1, -1): segment = self[s] if isinstance(segment, Arc): arc_required = int(ceil(abs(segment.sweep) / sweep_limit)) self[s : s + 1] = list(segment.as_cubic_curves(arc_required)) def approximate_arcs_with_quads(self, error=0.1): """ Iterates through this path and replaces any Arcs with quadratic bezier curves. """ sweep_limit = tau * error for s in range(len(self) - 1, -1, -1): segment = self[s] if isinstance(segment, Arc): arc_required = int(ceil(abs(segment.sweep) / sweep_limit)) self[s : s + 1] = list(segment.as_quad_curves(arc_required)) class Rect(Shape): """ SVG Rect shapes are defined in SVG2 10.2 https://www.w3.org/TR/SVG2/shapes.html#RectElement These have geometric properties x, y, width, height, rx, ry Geometric properties can be Length values. Rect(x, y, width, height) Rect(x, y, width, height, rx, ry) Rect(x, y, width, height, rx, ry, matrix) Rect(x, y, width, height, rx, ry, matrix, stroke, fill) Rect(dict): dictionary values read from svg. """ def __init__(self, *args, **kwargs): self.x = None self.y = None self.width = None self.height = None self.rx = None self.ry = None Shape.__init__(self, *args, **kwargs) self._validate_rect() def property_by_object(self, s): Shape.property_by_object(self, s) self.x = s.x self.y = s.y self.width = s.width self.height = s.height self.rx = s.rx self.ry = s.ry self._validate_rect() def property_by_values(self, values): Shape.property_by_values(self, values) self.x = Length(values.get(SVG_ATTR_X, 0)).value() self.y = Length(values.get(SVG_ATTR_Y, 0)).value() self.width = Length(values.get(SVG_ATTR_WIDTH, 1)).value() self.height = Length(values.get(SVG_ATTR_HEIGHT, 1)).value() self.rx = Length(values.get(SVG_ATTR_RADIUS_X, None)).value() self.ry = Length(values.get(SVG_ATTR_RADIUS_Y, None)).value() def property_by_args(self, *args): arg_length = len(args) if arg_length >= 1: self.x = Length(args[0]).value() if arg_length >= 2: self.y = Length(args[1]).value() if arg_length >= 3: self.width = Length(args[2]).value() if arg_length >= 4: self.height = Length(args[3]).value() if arg_length >= 5: self.rx = Length(args[4]).value() if arg_length >= 6: self.ry = Length(args[5]).value() if arg_length >= 7: self._init_shape(*args[6:]) def _validate_rect(self): """None is 'auto' for values.""" rx = self.rx ry = self.ry if rx is None and ry is None: rx = ry = 0 if rx is not None and ry is None: rx = Length(rx).value(relative_length=self.width) ry = rx elif ry is not None and rx is None: ry = Length(ry).value(relative_length=self.height) rx = ry elif rx is not None and ry is not None: rx = Length(rx).value(relative_length=self.width) ry = Length(ry).value(relative_length=self.height) if rx == 0 or ry == 0: rx = ry = 0 else: try: rx = min(rx, self.width / 2.0) ry = min(ry, self.height / 2.0) except ValueError: pass # If width is in inches and rx is in units, this is unsolvable without knowing the ppi self.rx = rx self.ry = ry def _attrs(self, values): if self.x != 0: values.append("%s=%s" % (SVG_ATTR_X, Length.str(self.x))) if self.y != 0: values.append("%s=%s" % (SVG_ATTR_Y, Length.str(self.y))) if self.width != 0: values.append("%s=%s" % (SVG_ATTR_WIDTH, Length.str(self.width))) if self.height != 0: values.append("%s=%s" % (SVG_ATTR_HEIGHT, Length.str(self.height))) if self.rx != 0: values.append("%s=%s" % (SVG_ATTR_RADIUS_X, Length.str(self.rx))) if self.ry != 0: values.append("%s=%s" % (SVG_ATTR_RADIUS_Y, Length.str(self.ry))) def __repr__(self): values = [] self._attrs(values) self._repr_shape(values) params = ", ".join(values) return "Rect(%s)" % params def __str__(self): values = [] self._attrs(values) self._str_shape(values) params = ", ".join(values) return "Rect(%s)" % params def __copy__(self): return Rect(self) @property def implicit_position(self): if not self.apply: return Point(self.x, self.y) point = Point(self.x, self.y) point *= self.transform return point @property def implicit_x(self): if not self.apply: return self.x return self.implicit_position[0] @property def implicit_y(self): if not self.apply: return self.y return self.implicit_position[1] @property def implicit_width(self): if not self.apply: return self.width p = Point(self.width, 0) p *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(p) @property def implicit_height(self): if not self.apply: return self.height p = Point(0, self.height) p *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(p) @property def implicit_rx(self): if not self.apply: return self.rx p = Point(self.rx, 0) p *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(p) @property def implicit_ry(self): if not self.apply: return self.ry p = Point(0, self.ry) p *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(p) def segments(self, transformed=True): """ Rect decomposition is given in SVG 2.0 10.2 Rect: * perform an absolute moveto operation to location (x,y); * perform an absolute horizontal lineto with parameter x+width; * perform an absolute vertical lineto parameter y+height; * perform an absolute horizontal lineto parameter x; * ( close the path) Rounded Rect: rx and ry are used as the equivalent parameters to the elliptical arc command, the x-axis-rotation and large-arc-flag are set to zero, the sweep-flag is set to one * perform an absolute moveto operation to location (x+rx,y); * perform an absolute horizontal lineto with parameter x+width-rx; * perform an absolute elliptical arc operation to coordinate (x+width,y+ry) * perform an absolute vertical lineto parameter y+height-ry; * perform an absolute elliptical arc operation to coordinate (x+width-rx,y+height) * perform an absolute horizontal lineto parameter x+rx; * perform an absolute elliptical arc operation to coordinate (x,y+height-ry) * perform an absolute vertical lineto parameter y+ry * perform an absolute elliptical arc operation with a segment-completing close path operation :param transformed: provide the reified version. :return: path_d of shape. """ scooped = False x = self.x y = self.y width = self.width height = self.height if self.is_degenerate(): return () # a computed value of zero for either dimension disables rendering. rx = self.rx ry = self.ry if not self._strict: if rx < 0 < width and ry < 0 < height: scooped = True rx = abs(rx) ry = abs(ry) if rx < 0 < width or ry < 0 < height: rx = 0 ry = 0 if rx == ry == 0: segments = ( Move(None, (x, y)), Line((x, y), (x + width, y)), Line((x + width, y), (x + width, y + height)), Line((x + width, y + height), (x, y + height)), Close((x, y + height), (x, y)), ) else: segments = ( Move(None, (x + rx, y)), Line((x + rx, y), (x + width - rx, y)), Arc( (x + width - rx, y), (x + width, y + ry), rx=rx, ry=ry, scooped=scooped, ), Line((x + width, y + ry), (x + width, y + height - ry)), Arc( (x + width, y + height - ry), (x + width - rx, y + height), rx=rx, ry=ry, scooped=scooped, ), Line((x + width - rx, y + height), (x + rx, y + height)), Arc( (x + rx, y + height), (x, y + height - ry), rx=rx, ry=ry, scooped=scooped, ), Line((x, y + height - ry), (x, y + ry)), Arc((x, y + ry), (x + rx, y), rx=rx, ry=ry, scooped=scooped), Close((x + rx, y), (x + rx, y)), ) if not transformed or self.transform.is_identity(): return segments else: return [s * self.transform for s in segments] def reify(self): """ Realizes the transform to the shape properties. If the realized shape can be properly represented as a rectangle with an identity matrix it will be, otherwise the properties will approximate the implied values. Skewed and Rotated rectangles cannot be reified. """ scale_x = self.transform.value_scale_x() scale_y = self.transform.value_scale_y() if scale_x * scale_y < 0: return self # No reification of negative values, gives negative dims. translate_x = self.transform.value_trans_x() translate_y = self.transform.value_trans_y() if ( self.transform.value_skew_x() == 0 and self.transform.value_skew_y() == 0 and scale_x != 0 and scale_y != 0 ): GraphicObject.reify(self) Transformable.reify(self) self.x *= scale_x self.y *= scale_y self.x += translate_x self.y += translate_y self.transform *= Matrix.translate(-translate_x, -translate_y) self.rx = scale_x * self.rx self.ry = scale_y * self.ry self.width = scale_x * self.width self.height = scale_y * self.height self.transform *= Matrix.scale(1.0 / scale_x, 1.0 / scale_y) return self def render(self, **kwargs): Shape.render(self, **kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass if isinstance(self.x, Length): self.x = self.x.value(relative_length=width, **kwargs) if isinstance(self.y, Length): self.y = self.y.value(relative_length=height, **kwargs) if isinstance(self.width, Length): self.width = self.width.value(relative_length=width, **kwargs) if isinstance(self.height, Length): self.height = self.height.value(relative_length=height, **kwargs) if isinstance(self.rx, Length): self.rx = self.rx.value(relative_length=width, **kwargs) if isinstance(self.ry, Length): self.ry = self.ry.value(relative_length=height, **kwargs) return self def is_degenerate(self): return ( self.width == 0 or self.height == 0 or self.width is None or self.height is None ) class _RoundShape(Shape): def __init__(self, *args, **kwargs): self.cx = None self.cy = None self.rx = None self.ry = None Shape.__init__(self, *args, **kwargs) def property_by_object(self, s): Shape.property_by_object(self, s) self.cx = s.cx self.cy = s.cy self.rx = s.rx self.ry = s.ry def property_by_values(self, values): Shape.property_by_values(self, values) self.cx = Length(values.get(SVG_ATTR_CENTER_X)).value() self.cy = Length(values.get(SVG_ATTR_CENTER_Y)).value() self.rx = Length(values.get(SVG_ATTR_RADIUS_X)).value() self.ry = Length(values.get(SVG_ATTR_RADIUS_Y)).value() r = Length(values.get(SVG_ATTR_RADIUS, None)).value() if r is not None: self.rx = r self.ry = r else: if self.rx is None: self.rx = 1 if self.ry is None: self.ry = 1 center = values.get("center", None) if center is not None: self.cx, self.cy = Point(center) if self.cx is None: self.cx = 0 if self.cy is None: self.cy = 0 def property_by_args(self, *args): arg_length = len(args) if arg_length >= 1: self.cx = Length(args[0]).value() if arg_length >= 2: self.cy = Length(args[1]).value() if arg_length >= 3: self.rx = Length(args[2]).value() if arg_length >= 4: self.ry = Length(args[3]).value() else: self.ry = self.rx if arg_length >= 5: self._init_shape(*args[4:]) def _attrs(self, values): if self.cx is not None: values.append("%s=%s" % (SVG_ATTR_CENTER_X, Length.str(self.cx))) if self.cy is not None: values.append("%s=%s" % (SVG_ATTR_CENTER_Y, Length.str(self.cy))) if self.rx == self.ry or self.ry is None: values.append("%s=%s" % (SVG_ATTR_RADIUS, Length.str(self.rx))) else: values.append("%s=%s" % (SVG_ATTR_RADIUS_X, Length.str(self.rx))) values.append("%s=%s" % (SVG_ATTR_RADIUS_Y, Length.str(self.ry))) def __repr__(self): values = [] self._attrs(values) self._repr_shape(values) params = ", ".join(values) return "%s(%s)" % (self.__class__.__name__, params) def __str__(self): values = [] self._attrs(values) self._str_shape(values) params = ", ".join(values) return "%s(%s)" % (self.__class__.__name__, params) @property def implicit_rx(self): if not self.apply: return self.rx prx = Point(self.rx, 0) prx *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(prx) @property def implicit_ry(self): if not self.apply: return self.ry pry = Point(0, self.ry) pry *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(pry) implicit_r = implicit_rx @property def implicit_center(self): center = Point(self.cx, self.cy) if not self.apply: return center center *= self.transform return center def segments(self, transformed=True): """ SVG path decomposition is given in SVG 2.0 10.3, 10.4. A move-to command to the point cx+rx,cy; arc to cx,cy+ry; arc to cx-rx,cy; arc to cx,cy-ry; arc with a segment-completing close path operation. Converts the parameters from an ellipse or a circle to a string for a Path object d-attribute""" original = self.apply self.apply = transformed path = Path() steps = 4 step_size = tau / steps if ( transformed and self.transform.value_scale_x() * self.transform.value_scale_y() < 0 ): step_size = -step_size t_start = 0 t_end = step_size # zero for either dimension, or a computed value of auto for both dimensions, disables rendering of the element. rx = self.implicit_rx ry = self.implicit_ry if self.is_degenerate(): return () center = self.implicit_center path.move((self.point_at_t(0))) for i in range(steps): path += Arc( self.point_at_t(t_start), self.point_at_t(t_end), center, rx=rx, ry=ry, rotation=self.rotation, sweep=step_size, ) t_start = t_end t_end += step_size path.closed() self.apply = original return path.segments(transformed) def reify(self): """ Realizes the transform to the shape properties. Skewed and Rotated roundshapes cannot be reified. """ scale_x = self.transform.value_scale_x() scale_y = self.transform.value_scale_y() if scale_y * scale_x < 0: return self # No reification of flipped values. translate_x = self.transform.value_trans_x() translate_y = self.transform.value_trans_y() if ( self.transform.value_skew_x() == 0 and self.transform.value_skew_y() == 0 and scale_x != 0 and scale_y != 0 ): GraphicObject.reify(self) Transformable.reify(self) self.cx *= scale_x self.cy *= scale_y self.cx += translate_x self.cy += translate_y self.transform *= Matrix.translate(-translate_x, -translate_y) self.rx = scale_x * self.rx self.ry = scale_y * self.ry self.transform *= Matrix.scale(1.0 / scale_x, 1.0 / scale_y) return self def render(self, **kwargs): Shape.render(self, **kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass if isinstance(self.cx, Length): self.cx = self.cx.value(relative_length=width, **kwargs) if isinstance(self.cy, Length): self.cy = self.cy.value(relative_length=height, **kwargs) if isinstance(self.rx, Length): self.rx = self.rx.value(relative_length=width, **kwargs) if isinstance(self.ry, Length): self.ry = self.ry.value(relative_length=height, **kwargs) return self def is_degenerate(self): rx = self.implicit_rx ry = self.implicit_ry return rx == 0 or ry == 0 def unit_matrix(self): """ return the unit matrix which could would transform the unit circle into this ellipse. One of the valid parameterizations for ellipses is that they are all affine transforms of the unit circle. This provides exactly such a matrix. :return: matrix """ m = Matrix() m.post_scale(self.implicit_rx, self.implicit_ry) m.post_rotate(self.rotation) center = self.implicit_center m.post_translate(center.x, center.y) return m def arc_t(self, t0, t1): """ return the arc found between the given values of t on the ellipse. :param t0: t start :param t1: t end :return: arc """ return Arc( self.point_at_t(t0), self.point_at_t(t1), self.implicit_center, rx=self.implicit_rx, ry=self.implicit_ry, rotation=self.rotation, sweep=t1 - t0, ) def arc_angle(self, a0, a1, ccw=None): """ return the arc found between the given angles on the ellipse. :param a0: start angle :param a1: end angle :param ccw: optional flag to force clockwise or counter-clockwise arc-angles, default is smaller angle :return: arc """ if ccw is None: ccw = a0 > a1 return Arc( self.point_at_angle(a0), self.point_at_angle(a1), self.implicit_center, rx=self.implicit_rx, ry=self.implicit_ry, rotation=self.rotation, ccw=ccw, ) def point_at_angle(self, angle): """ find the point on the ellipse from the center at the given angle. Note: For non-circular arcs this is different than point(t). :param angle: angle from center to find point :return: point found """ a = self.implicit_rx b = self.implicit_ry if a == b: return self.point_at_t(angle) angle -= self.rotation t = atan2(a * tan(angle), b) tau_1_4 = tau / 4.0 tau_3_4 = 3 * tau_1_4 if tau_3_4 >= abs(angle) % tau > tau_1_4: t += tau / 2.0 return self.point_at_t(t) def angle_at_point(self, p): """ find the angle to the point. :param p: point :return: angle to given point. """ if self.apply and not self.transform.is_identity(): return self.implicit_center.angle_to(p) else: center = Point(self.cx, self.cy) return center.angle_to(p) def t_at_point(self, p): """ find the t parameter to at the point. :param p: point :return: t parameter to the given point. """ angle = self.angle_at_point(p) angle -= self.rotation a = self.implicit_rx b = self.implicit_ry t = atan2(a * tan(angle), b) tau_1_4 = tau / 4.0 tau_3_4 = 3 * tau_1_4 if tau_3_4 >= abs(angle) % tau > tau_1_4: t += tau / 2.0 return t def point_at_t(self, t): """ find the point that corresponds to given value t. Where t=0 is the first point and t=tau is the final point. In the case of a circle: t = angle. :param t: :return: """ rotation = self.rotation a = self.implicit_rx b = self.implicit_ry center = self.implicit_center cx = center.x cy = center.y cos_theta = cos(rotation) sin_theta = sin(rotation) cos_t = cos(t) sin_t = sin(t) px = cx + a * cos_t * cos_theta - b * sin_t * sin_theta py = cy + a * cos_t * sin_theta + b * sin_t * cos_theta return Point(px, py) def point(self, position, error=ERROR): """ find the point that corresponds to given value [0,1]. Where t=0 is the first point and t=1 is the final point. :param position: position value between 0,1 where value equals the amount through the shape :param error: error permitted in determining point value (unused for this shape) :return: point at t """ return self.point_at_t(tau * position) def _ramanujan_length(self): a = self.implicit_rx b = self.implicit_ry if b > a: a, b = b, a h = ((a - b) * (a - b)) / ((a + b) * (a + b)) return pi * (a + b) * (1 + (3 * h / (10 + sqrt(4 - 3 * h)))) class Ellipse(_RoundShape): """ SVG Ellipse shapes are defined in SVG2 10.4 https://www.w3.org/TR/SVG2/shapes.html#EllipseElement These have geometric properties cx, cy, rx, ry """ def __init__(self, *args, **kwargs): _RoundShape.__init__(self, *args, **kwargs) def __copy__(self): return Ellipse(self) def _name(self): return self.__class__.__name__ class Circle(_RoundShape): """ SVG Circle shapes are defined in SVG2 10.3 https://www.w3.org/TR/SVG2/shapes.html#CircleElement These have geometric properties cx, cy, r """ def __init__(self, *args, **kwargs): _RoundShape.__init__(self, *args, **kwargs) def __copy__(self): return Circle(self) def _name(self): return self.__class__.__name__ class SimpleLine(Shape): """ SVG Line shapes are defined in SVG2 10.5 https://www.w3.org/TR/SVG2/shapes.html#LineElement These have geometric properties x1, y1, x2, y2 These are called Line in SVG but that name is already used for Line(PathSegment) """ def __init__(self, *args, **kwargs): self.x1 = None self.y1 = None self.x2 = None self.y2 = None Shape.__init__(self, *args, **kwargs) def property_by_object(self, s): Shape.property_by_object(self, s) self.x1 = s.x1 self.y1 = s.y1 self.x2 = s.x2 self.y2 = s.y2 def property_by_values(self, values): Shape.property_by_values(self, values) self.x1 = Length(values.get(SVG_ATTR_X1, 0)).value() self.y1 = Length(values.get(SVG_ATTR_Y1, 0)).value() self.x2 = Length(values.get(SVG_ATTR_X2, 0)).value() self.y2 = Length(values.get(SVG_ATTR_Y2, 0)).value() def property_by_args(self, *args): arg_length = len(args) if arg_length >= 1: self.x1 = Length(args[0]).value() if arg_length >= 2: self.y1 = Length(args[1]).value() if arg_length >= 3: self.x2 = Length(args[2]).value() if arg_length >= 4: self.y2 = Length(args[3]).value() self._init_shape(*args[4:]) def _attrs(self, values): if self.x1 is not None: values.append("%s=%s" % (SVG_ATTR_X1, str(self.x1))) if self.y1 is not None: values.append("%s=%s" % (SVG_ATTR_Y1, str(self.y1))) if self.x2 is not None: values.append("%s=%s" % (SVG_ATTR_X2, str(self.x2))) if self.y2 is not None: values.append("%s=%s" % (SVG_ATTR_Y2, str(self.y2))) def __repr__(self): values = [] self._attrs(values) self._repr_shape(values) params = ", ".join(values) return "SimpleLine(%s)" % params def __str__(self): values = [] self._attrs(values) self._str_shape(values) params = ", ".join(values) return "SimpleLine(%s)" % params def __copy__(self): return SimpleLine(self) @property def implicit_x1(self): point = Point(self.x1, self.y1) point *= self.transform return point.x @property def implicit_y1(self): point = Point(self.x1, self.y1) point *= self.transform return point.y @property def implicit_x2(self): point = Point(self.x2, self.y2) point *= self.transform return point.x @property def implicit_y2(self): point = Point(self.x2, self.y2) point *= self.transform return point.y def segments(self, transformed=True): """ SVG path decomposition is given in SVG 2.0 10.5. perform an absolute moveto operation to absolute location (x1,y1) perform an absolute lineto operation to absolute location (x2,y2) :returns Path_d path for line. """ start = Point(self.x1, self.y1) end = Point(self.x2, self.y2) if transformed: start *= self.transform end *= self.transform return Move(None, start), Line(start, end) def reify(self): """ Realizes the transform to the shape properties. SimpleLines are perfectly reified. """ GraphicObject.reify(self) Transformable.reify(self) matrix = self.transform p = Point(self.x1, self.y1) p *= matrix self.x1 = p.x self.y1 = p.y p = Point(self.x2, self.y2) p *= matrix self.x2 = p.x self.y2 = p.y matrix.reset() return self def render(self, **kwargs): Shape.render(self, **kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass if isinstance(self.x1, Length): self.x1 = self.x1.value(relative_length=width, **kwargs) if isinstance(self.y1, Length): self.y1 = self.y1.value(relative_length=height, **kwargs) if isinstance(self.x2, Length): self.x2 = self.x2.value(relative_length=width, **kwargs) if isinstance(self.y2, Length): self.y2 = self.y2.value(relative_length=height, **kwargs) return self class _Polyshape(Shape): """Base form of Polygon and Polyline since the objects are nearly the same.""" def __init__(self, *args, **kwargs): self.points = list() Shape.__init__(self, *args, **kwargs) def property_by_object(self, s): Shape.property_by_object(self, s) self._init_points(s.points) def property_by_values(self, values): Shape.property_by_values(self, values) self._init_points(values) def property_by_args(self, *args): self._init_points(args) def _init_points(self, points): if len(self.points) != 0: return if points is None: self.points = list() return if isinstance(points, dict): if SVG_ATTR_POINTS in points: points = points[SVG_ATTR_POINTS] else: self.points = list() return try: if len(points) == 1: points = points[0] except TypeError: pass if isinstance(points, str): findall = REGEX_COORD_PAIR.findall(points) self.points = [Point(float(j), float(k)) for j, k in findall] elif isinstance(points, (list, tuple)): if len(points) == 0: self.points = list() else: first_point = points[0] if isinstance(first_point, (float, int)): self.points = list(map(Point, zip(*[iter(points)] * 2))) elif isinstance(first_point, (list, tuple, complex, str, Point)): self.points = list(map(Point, points)) else: self.points = list() def __repr__(self): values = [] if self.points is not None: s = " ".join(map(str, self.points)) values.append("%s='%s'" % (SVG_ATTR_POINTS, s)) self._repr_shape(values) params = ", ".join(values) return "%s(%s)" % (self.__class__.__name__, params) def __str__(self): values = [] if self.points is not None: s = " ".join(map(str, self.points)) values.append("%s='%s'" % (SVG_ATTR_POINTS, s)) self._str_shape(values) params = ", ".join(values) return "%s(%s)" % (self.__class__.__name__, params) def __len__(self): return len(self.points) def __getitem__(self, item): return self.points[item] def segments(self, transformed=True): """ Polyline and Polygon decomposition is given in SVG2. 10.6 and 10.7 * perform an absolute moveto operation to the first coordinate pair in the list of points * for each subsequent coordinate pair, perform an absolute lineto operation to that coordinate pair. * (Polygon-only) perform a closepath command Note: For a polygon/polyline made from n points, the resulting path will be composed of n lines (even if some of these lines have length zero). """ if self.transform.is_identity() or not transformed: points = self.points else: points = list(map(self.transform.point_in_matrix_space, self.points)) if self.is_degenerate(): return [] segments = [Move(None, points[0])] last = points[0] for i in range(1, len(points)): current = points[i] segments.append(Line(last, current)) last = current if isinstance(self, Polygon): segments.append(Close(last, points[0])) return segments def reify(self): """ Realizes the transform to the shape properties. Polyshapes are perfectly reified. """ GraphicObject.reify(self) Transformable.reify(self) matrix = self.transform for p in self: p *= matrix matrix.reset() return self def is_degenerate(self): return len(self.points) == 0 class Polyline(_Polyshape): """ SVG Polyline shapes are defined in SVG2 10.6 https://www.w3.org/TR/SVG2/shapes.html#PolylineElement These have geometric properties points """ def __init__(self, *args, **kwargs): _Polyshape.__init__(self, *args, **kwargs) def __copy__(self): return Polyline(self) def _name(self): return self.__class__.__name__ class Polygon(_Polyshape): """ SVG Polygon shapes are defined in SVG2 10.7 https://www.w3.org/TR/SVG2/shapes.html#PolygonElement These have geometric properties points """ def __init__(self, *args, **kwargs): _Polyshape.__init__(self, *args, **kwargs) def __copy__(self): return Polygon(self) def _name(self): return self.__class__.__name__ class Subpath: """ Subpath is a Path-backed window implementation. It does not store a list of segments but rather stores a Path, start position, end position. When a function is called on a subpath, the result of those events is performed on the backing Path. When the backing Path is modified the behavior is undefined.""" def __init__(self, path, start, end): self._path = path self._start = start self._end = end def __copy__(self): return Subpath(Path(self._path), self._start, self._end) def __getitem__(self, index): return self._path[self.index_to_path_index(index)] def __setitem__(self, index, value): self._path[self.index_to_path_index(index)] = value def __delitem__(self, index): del self._path[self.index_to_path_index(index)] self._end -= 1 def __iadd__(self, other): if isinstance(other, str): p = Path(other) self._path[self._end : self._end] = p elif isinstance(other, Path): p = copy(other) self._path[self._end : self._end] = p elif isinstance(other, PathSegment): self._path.insert(self._end, other) else: return NotImplemented return self def __add__(self, other): if isinstance(other, (str, Path, PathSegment)): n = copy(self) n += other return n return NotImplemented def __radd__(self, other): if isinstance(other, str): path = Path(other) path.extend(map(copy, self._path)) return path elif isinstance(other, PathSegment): path = Path(self) path.insert(0, other) return path else: return NotImplemented def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): for e in self: e *= other return self def __mul__(self, other): if isinstance(other, (Matrix, str)): n = copy(self) n *= other return n return NotImplemented __rmul__ = __mul__ def __iter__(self): class Iterator: def __init__(self, subpath): self.n = subpath._start - 1 self.subpath = subpath def __next__(self): self.n += 1 try: if self.n > self.subpath._end: raise StopIteration return self.subpath._path[self.n] except IndexError: raise StopIteration next = __next__ return Iterator(self) def __len__(self): return self._end - self._start + 1 def __str__(self): return self.d() def __repr__(self): return "Path(%s)" % (", ".join(repr(x) for x in self)) def __eq__(self, other): if isinstance(other, str): return self.__eq__(Path(other)) if not isinstance(other, (Path, Subpath)): return NotImplemented if len(self) != len(other): return False for s, o in zip(self, other): if not s == o: return False return True def __ne__(self, other): if not isinstance(other, (Path, Subpath, str)): return NotImplemented return not self == other def segments(self, transformed=True): path = self._path if transformed: return [ s * path.transform for s in path._segments[self._start : self._end + 1] ] return path._segments[self._start : self._end + 1] def _numeric_index(self, index): if index < 0: return self._end + index + 1 else: return self._start + index def index_to_path_index(self, index): if isinstance(index, slice): start = index.start stop = index.stop step = index.step if start is None: start = 0 start = self._numeric_index(start) if stop is None: stop = len(self) stop = self._numeric_index(stop) return slice(start, stop, step) return self._numeric_index(index) def bbox(self, transformed=True, with_stroke=False): """returns a bounding box for the subpath""" if transformed: return Path(self).bbox(transformed=transformed, with_stroke=with_stroke) segments = self._path._segments[self._start : self._end + 1] bbs = [seg.bbox() for seg in segments if not isinstance(Close, Move)] try: xmins, ymins, xmaxs, ymaxs = list(zip(*bbs)) except ValueError: return None # No bounding box items existed. So no bounding box. if ( with_stroke and self._path.stroke_width is not None and not (self._path.stroke is None or self._path.stroke.value is None) ): delta = float(self._path.stroke_width) / 2.0 else: delta = 0.0 return ( min(xmins) - delta, min(ymins) - delta, max(xmaxs) + delta, max(ymaxs) + delta, ) def d(self, relative=None, smooth=None): segments = self._path._segments[self._start : self._end + 1] return Path.svg_d(segments, relative=relative, smooth=None) def _reverse_segments(self, start, end): """Reverses segments between the given indexes in the subpath space.""" segments = self._path._segments # must avoid path validation. s = self.index_to_path_index(start) e = self.index_to_path_index(end) while s <= e: start_segment = segments[s] end_segment = segments[e] start_segment.reverse() if start_segment is not end_segment: end_segment.reverse() segments[s] = end_segment segments[e] = start_segment s += 1 e -= 1 start = self.index_to_path_index(start) end = self.index_to_path_index(end) self._path._validate_connection(start - 1, prefer_second=True) self._path._validate_connection(end) def reverse(self): size = len(self) if size == 0: return start = 0 end = size - 1 if isinstance(self[-1], Close): end -= 1 if isinstance( self[0], Move ): # Move remains in place but references next element. start += 1 self._reverse_segments(start, end) if size > 1: if isinstance(self[0], Move): self[0].end = Point(self[1].start) last = self[-1] if isinstance(last, Close): last.reverse() if last.start != self[-2].end: last.start = Point(self[-2].end) if last.end != self[0].end: last.end = Point(self[0].end) return self class Group(SVGElement, Transformable, list): """ Group Container element can have children. SVG 2.0 are defined in: 5.2. Grouping: the g element """ def __init__(self, *args, **kwargs): Transformable.__init__(self, *args, **kwargs) list.__init__(self) if len(args) >= 1: s = args[0] if isinstance(s, Group): self.extend(list(map(copy, s))) SVGElement.__init__(self, *args, **kwargs) def __imul__(self, other): if isinstance(other, str): other = Matrix(other) if isinstance(other, Matrix): self.transform *= other for e in self: e *= other return self def render(self, **kwargs): Transformable.render(self, **kwargs) def __copy__(self): return Group(self) def property_by_object(self, s): Transformable.property_by_object(self, s) SVGElement.property_by_object(self, s) def property_by_values(self, values): Transformable.property_by_values(self, values) SVGElement.property_by_values(self, values) def select(self, conditional=None): """ Finds all flattened subobjects of this group for which the conditional returns true. :param conditional: function taking element and returns True to include or False if exclude """ if conditional is None: for subitem in self: yield subitem if isinstance(subitem, Group): for s in subitem.select(conditional): yield s else: for subitem in self: if conditional(subitem): yield subitem if isinstance(subitem, Group): for s in subitem.select(conditional): yield s def reify(self): Transformable.reify(self) @staticmethod def union_bbox(elements, transformed=True, with_stroke=False): """ Returns the union of the bounding boxes for the elements within the iterator. :param transformed: Should the children of this object be properly transformed. :param with_stroke: should the stroke-width be included in the bounds of the elements :return: union of all bounding boxes of elements within the iterable. """ boundary_points = [] for e in elements: if not hasattr(e, "bbox"): continue box = e.bbox(transformed=False, with_stroke=with_stroke) if box is None: continue top_left = (box[0], box[1]) top_right = (box[2], box[1]) bottom_left = (box[0], box[3]) bottom_right = (box[2], box[3]) if transformed: top_left = e.transform.point_in_matrix_space(top_left) top_right = e.transform.point_in_matrix_space(top_right) bottom_left = e.transform.point_in_matrix_space(bottom_left) bottom_right = e.transform.point_in_matrix_space(bottom_right) boundary_points.append(top_left) boundary_points.append(top_right) boundary_points.append(bottom_left) boundary_points.append(bottom_right) if len(boundary_points) == 0: return None xmin = min([e[0] for e in boundary_points]) ymin = min([e[1] for e in boundary_points]) xmax = max([e[0] for e in boundary_points]) ymax = max([e[1] for e in boundary_points]) return xmin, ymin, xmax, ymax def bbox(self, transformed=True, with_stroke=False): """ Returns the bounding box of the given object. In the case of groups this is the union of all the bounding boxes of all bound children. Setting transformed to false, may yield unexpected results if subitems are transformed in non-uniform ways. :param transformed: bounding box of the properly transformed children. :param with_stroke: should the stroke-width be included in the bounds. :return: bounding box of the given element """ return Group.union_bbox( self.select(), transformed=transformed, with_stroke=with_stroke, ) class ClipPath(SVGElement, list): """ clipPath elements are defined in svg 14.3.5 https://www.w3.org/TR/SVG11/masking.html#ClipPathElement Clip paths conceptually define a 1 bit mask for images these are usually defined within def blocks and do not render themselves but rather are attached by IRI references to the """ def __init__(self, *args, **kwargs): list.__init__(self) self.unit_type = SVG_UNIT_TYPE_USERSPACEONUSE SVGElement.__init__(self, *args, **kwargs) def property_by_object(self, s): SVGElement.property_by_object(self, s) self.unit_type = s.unit_type def property_by_values(self, values): SVGElement.property_by_values(self, values) self.unit_type = self.values.get( SVG_ATTR_CLIP_UNIT_TYPE, SVG_UNIT_TYPE_USERSPACEONUSE ) class Pattern(SVGElement, list): def __init__(self, *args, **kwargs): self.viewbox = None self.preserve_aspect_ratio = None self.x = None self.y = None self.width = None self.height = None self.href = None self.pattern_content_units = None # UserSpaceOnUse default self.pattern_transform = None self.pattern_units = None SVGElement.__init__(self, *args, **kwargs) def __int__(self): return 0 @property def viewbox_transform(self): if self.viewbox is None: return "" return self.viewbox.transform(self) def property_by_object(self, s): SVGElement.property_by_object(self, s) self.viewbox = s.viewbox self.preserve_aspect_ratio = s.preserve_aspect_ratio self.x = s.x self.y = s.y self.width = s.width self.height = s.height self.href = s.href self.pattern_content_units = s.pattern_contents_units self.pattern_transform = ( Matrix(s.pattern_transform) if s.pattern_transform is not None else None ) self.pattern_units = s.pattern_units def property_by_values(self, values): SVGElement.property_by_values(self, values) if XLINK_HREF in values: self.href = values[XLINK_HREF] elif SVG_HREF in values: self.href = values[SVG_HREF] viewbox = values.get(SVG_ATTR_VIEWBOX) if viewbox is not None: self.viewbox = Viewbox(viewbox) if SVG_ATTR_PRESERVEASPECTRATIO in values: self.preserve_aspect_ratio = values[SVG_ATTR_PRESERVEASPECTRATIO] self.x = Length(values.get(SVG_ATTR_X, 0)).value() self.y = Length(values.get(SVG_ATTR_Y, 0)).value() self.width = Length(values.get(SVG_ATTR_WIDTH, "100%")).value() self.height = Length(values.get(SVG_ATTR_HEIGHT, "100%")).value() if SVG_ATTR_PATTERN_CONTENT_UNITS in values: self.pattern_content_units = values[SVG_ATTR_PATTERN_CONTENT_UNITS] if SVG_ATTR_PATTERN_TRANSFORM in values: self.pattern_transform = Matrix(values[SVG_ATTR_PATTERN_TRANSFORM]) if SVG_ATTR_PATTERN_UNITS in values: self.pattern_units = values[SVG_ATTR_PATTERN_UNITS] def render(self, **kwargs): if self.pattern_transform is not None: self.pattern_transform.render(**kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass if isinstance(self.x, Length): self.x = self.x.value(relative_length=width, **kwargs) if isinstance(self.y, Length): self.y = self.y.value(relative_length=height, **kwargs) if isinstance(self.width, Length): self.width = self.width.value(relative_length=width, **kwargs) if isinstance(self.height, Length): self.height = self.height.value(relative_length=height, **kwargs) return self class Text(SVGElement, GraphicObject, Transformable): """ SVG Text are defined in SVG 2.0 Chapter 11 No methods are implemented to perform a text to path conversion. However, if such a method exists the assumption is that the results will be placed in the .path attribute, and functions like bbox() will check if such a value exists. """ def __init__(self, *args, **kwargs): if len(args) >= 1: self.text = args[0] else: self.text = "" self.width = 0 self.height = 0 self.x = 0 self.y = 0 self.dx = 0 self.dy = 0 self.anchor = "start" # start, middle, end. self.font_family = "san-serif" self.font_size = 16.0 # 16 point font 'normal' self.font_weight = 400.0 # Thin=100, Normal=400, Bold=700 self.font_face = "" self.path = None Transformable.__init__(self, *args, **kwargs) GraphicObject.__init__(self, *args, **kwargs) SVGElement.__init__(self, *args, **kwargs) def __str__(self): values = list() values.append("'%s'" % self.text) values.append("%s='%s'" % (SVG_ATTR_FONT_FAMILY, self.font_family)) if self.font_face: values.append("%s=%s" % (SVG_ATTR_FONT_FACE, self.font_face)) values.append("%s=%d" % (SVG_ATTR_FONT_SIZE, self.font_size)) values.append("%s='%s'" % (SVG_ATTR_FONT_WEIGHT, str(self.font_weight))) values.append("%s='%s'" % (SVG_ATTR_TEXT_ANCHOR, self.anchor)) if self.x != 0 or self.y != 0: values.append("%s=%s" % (SVG_ATTR_X, self.x)) values.append("%s=%s" % (SVG_ATTR_Y, self.y)) if self.dx != 0 or self.dy != 0: values.append("%s=%s" % (SVG_ATTR_DX, self.dx)) values.append("%s=%s" % (SVG_ATTR_DY, self.dy)) if self.stroke is not None: values.append("%s='%s'" % (SVG_ATTR_STROKE, self.stroke)) if self.fill is not None: values.append("%s='%s'" % (SVG_ATTR_FILL, self.fill)) if self.stroke_width is not None and self.stroke_width != 1.0: values.append("%s=%s" % (SVG_ATTR_STROKE_WIDTH, str(self.stroke_width))) if not self.transform.is_identity(): values.append("%s=%s" % (SVG_ATTR_TRANSFORM, repr(self.transform))) if self.id is not None: values.append("%s='%s'" % (SVG_ATTR_ID, self.id)) return "Text(%s)" % (", ".join(values)) def __repr__(self): # Cannot use SVG_ATTR_FONT_* or SVG_ATTR_TEXT_ANCHOR for repr because they contain hyphens values = list() values.append("'%s'" % self.text) values.append("font_family='%s'" % self.font_family) if self.font_face: values.append("font_face=%s" % self.font_face) values.append("font_size=%d" % self.font_size) values.append("font_weight='%s'" % str(self.font_weight)) values.append("text_anchor='%s'" % self.anchor) if self.x != 0 or self.y != 0: values.append("%s=%s" % (SVG_ATTR_X, self.x)) values.append("%s=%s" % (SVG_ATTR_Y, self.y)) if self.dx != 0 or self.dy != 0: values.append("%s=%s" % (SVG_ATTR_DX, self.dx)) values.append("%s=%s" % (SVG_ATTR_DY, self.dy)) if self.stroke is not None: values.append("%s='%s'" % (SVG_ATTR_STROKE, self.stroke)) if self.fill is not None: values.append("%s='%s'" % (SVG_ATTR_FILL, self.fill)) if self.stroke_width is not None and self.stroke_width != 1.0: values.append( "stroke_width=%s" % str(self.stroke_width) ) # Cannot use SVG_ATTR_STROKE_WIDTH for repr because it contains a hyphen if not self.transform.is_identity(): values.append("%s=%s" % (SVG_ATTR_TRANSFORM, repr(self.transform))) if self.id is not None: values.append("%s='%s'" % (SVG_ATTR_ID, self.id)) return "Text(%s)" % (", ".join(values)) def __eq__(self, other): if not isinstance(other, Text): return NotImplemented if self.text != other.text: return False if self.width != other.width: return False if self.height != other.height: return False if self.x != other.x: return False if self.y != other.y: return False if self.dx != other.dx: return False if self.dy != other.dy: return False if self.anchor != other.anchor: return False if self.font_family != other.font_family: return False if self.font_size != other.font_size: return False if self.font_weight != other.font_weight: return False return self.font_face == other.font_face def __ne__(self, other): if not isinstance(other, Text): return NotImplemented return not self == other def property_by_object(self, s): Transformable.property_by_object(self, s) GraphicObject.property_by_object(self, s) SVGElement.property_by_object(self, s) self.text = s.text self.x = s.x self.y = s.y self.width = s.width self.height = s.height self.dx = s.dx self.dy = s.dy self.anchor = s.anchor self.font_family = s.font_family self.font_size = s.font_size self.font_weight = s.font_weight self.font_face = s.font_face def parse_font(self, font): """ CSS Fonts 3 has a shorthand font property which serves to provide a single location to define: `font-style`, `font-variant`, `font-weight`, `font-stretch`, `font-size`, `line-height`, and `font-family` font-style: normal | italic | oblique font-variant: normal | small-caps font-weight: normal | bold | bolder | lighter | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 font-stretch: normal | ultra-condensed | extra-condensed | condensed | semi-condensed | semi-expanded | expanded | extra-expanded | ultra-expanded font-size: | | line-height: '/' <`line-height`> font-family: [ | ] # generic-family: `serif`, `sans-serif`, `cursive`, `fantasy`, and `monospace` """ # https://www.w3.org/TR/css-fonts-3/#font-prop font_elements = list(*re.findall(REGEX_CSS_FONT, font)) font_style = font_elements[0] font_variant = font_elements[1] font_weight = font_elements[2] font_stretch = font_elements[3] font_size = font_elements[4] line_height = font_elements[5] font_face = font_elements[6] font_family = font_elements[7] if len(font_weight) > 0: self.font_weight = self.parse_font_weight(font_weight) if len(font_size) > 0: self.font_size = Length(font_size).value() if len(font_face) > 0: if font_face.endswith(","): font_face = font_face[:-1] self.font_face = font_face if len(font_family) > 0: self.font_family = font_family def parse_font_weight(self, weight): if weight == "bold": return 700 if weight == "normal": return 400 try: return int(weight) except KeyError: return 400 def property_by_values(self, values): Transformable.property_by_values(self, values) GraphicObject.property_by_values(self, values) SVGElement.property_by_values(self, values) self.anchor = values.get(SVG_ATTR_TEXT_ANCHOR, self.anchor) self.font_face = values.get("font_face") self.font_face = values.get(SVG_ATTR_FONT_FACE, self.font_face) self.font_family = values.get("font_family", self.font_family) self.font_family = values.get(SVG_ATTR_FONT_FAMILY, self.font_family) self.font_size = Length(values.get("font_size", self.font_size)).value() self.font_size = Length(values.get(SVG_ATTR_FONT_SIZE, self.font_size)).value() self.font_weight = values.get("font_weight", self.font_weight) self.font_weight = values.get(SVG_ATTR_FONT_WEIGHT, self.font_weight) self.font_weight = self.parse_font_weight(self.font_weight) self.anchor = values.get("text_anchor", self.anchor) self.anchor = values.get(SVG_ATTR_TEXT_ANCHOR, self.anchor) font = values.get(SVG_ATTR_FONT, None) if font is not None: self.parse_font(font) self.text = values.get(SVG_TAG_TEXT, self.text) self.x = Length(values.get(SVG_ATTR_X, self.x)).value() self.y = Length(values.get(SVG_ATTR_Y, self.y)).value() self.dx = Length(values.get(SVG_ATTR_DX, self.dx)).value() self.dy = Length(values.get(SVG_ATTR_DY, self.dy)).value() def reify(self): GraphicObject.reify(self) Transformable.reify(self) def render(self, **kwargs): GraphicObject.render(self, **kwargs) Transformable.render(self, **kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass if isinstance(self.x, Length): self.x = self.x.value(relative_length=width, **kwargs) if isinstance(self.y, Length): self.y = self.y.value(relative_length=height, **kwargs) if isinstance(self.dx, Length): self.dx = self.dx.value(relative_length=width, **kwargs) if isinstance(self.dy, Length): self.dy = self.dy.value(relative_length=height, **kwargs) return self def __copy__(self): return Text(self) def bbox(self, transformed=True, with_stroke=False): """ Get the bounding box for the given text object. :param transformed: whether this is the transformed bounds or default. :param with_stroke: should the stroke-width be included in the bounds. :return: bounding box of the given element """ if self.path is not None: return (self.path * self.transform).bbox( transformed=True, with_stroke=with_stroke, ) width = self.width height = self.height xmin = self.x ymin = self.y - height xmax = self.x + width ymax = self.y if not hasattr(self, "anchor") or self.anchor == "start": pass elif self.anchor == "middle": xmin -= width / 2 xmax -= width / 2 elif self.anchor == "end": xmin -= width xmax -= width if transformed: p0 = self.transform.transform_point([xmin, ymin]) p1 = self.transform.transform_point([xmin, ymax]) p2 = self.transform.transform_point([xmax, ymin]) p3 = self.transform.transform_point([xmax, ymax]) xmin = min(p0[0], p1[0], p2[0], p3[0]) ymin = min(p0[1], p1[1], p2[1], p3[1]) xmax = max(p0[0], p1[0], p2[0], p3[0]) ymax = max(p0[1], p1[1], p2[1], p3[1]) if ( with_stroke and self.stroke_width is not None and not (self.stroke is None or self.stroke.value is None) ): if transformed: delta = float(self.implicit_stroke_width) / 2.0 else: delta = float(self.stroke_width) / 2.0 else: delta = 0.0 return ( xmin - delta, ymin - delta, xmax + delta, ymax + delta, ) SVGText = Text class Image(SVGElement, GraphicObject, Transformable): """ SVG Images are defined in SVG 2.0 12.3 This class is called SVG Image rather than image as a guard against many Image objects which are quite useful and would be ideal for reading the linked or contained data. """ def __init__(self, *args, **kwargs): self.url = None self.data = None self.media_type = None self.viewbox = None self.preserve_aspect_ratio = None self.x = None self.y = None self.width = None self.height = None self.image = None self.image_width = None self.image_height = None Transformable.__init__(self, *args, **kwargs) GraphicObject.__init__(self, *args, **kwargs) SVGElement.__init__( self, *args, **kwargs ) # Dataurl requires this be processed first. if self.url is not None: match = REGEX_DATA_URL.match(self.url) if match: # Data URL self.media_type = match.group(1).split(";") self.data = match.group(2) if "base64" in self.media_type: from base64 import b64decode self.data = b64decode(self.data) else: from urllib.parse import unquote_to_bytes self.data = unquote_to_bytes(self.data) def __repr__(self): values = [] if self.x != 0: values.append("%s=%s" % (SVG_ATTR_X, Length.str(self.x))) if self.y != 0: values.append("%s=%s" % (SVG_ATTR_Y, Length.str(self.y))) if self.width != "100%": values.append("%s=%s" % (SVG_ATTR_WIDTH, Length.str(self.width))) if self.height != "100%": values.append("%s=%s" % (SVG_ATTR_HEIGHT, Length.str(self.height))) if self.preserve_aspect_ratio is not None: values.append( "%s=%s" % (SVG_ATTR_PRESERVEASPECTRATIO, self.preserve_aspect_ratio) ) if self.viewbox is not None: values.append("%s='%s'" % (SVG_ATTR_VIEWBOX, str(self.viewbox))) if self.url is not None: values.append("%s='%s'" % (SVG_HREF, self.url)) if not self.transform.is_identity(): values.append("transform=%s" % repr(self.transform)) params = ", ".join(values) return "Image(%s)" % params def __eq__(self, other): if not isinstance(other, Image): return NotImplemented if self.url != other.url: return False if self.data != other.data: return False if self.width != other.width: return False if self.height != other.height: return False if self.x != other.x: return False if self.y != other.y: return False if self.image != other.image: return False if self.viewbox != other.viewbox: return False return self.preserve_aspect_ratio == other.preserve_aspect_ratio def __ne__(self, other): if not isinstance(other, Image): return NotImplemented return not self == other def property_by_object(self, s): SVGElement.property_by_object(self, s) Transformable.property_by_object(self, s) GraphicObject.property_by_object(self, s) self.url = s.url self.data = s.data self.viewbox = s.viewbox self.preserve_aspect_ratio = s.preserve_aspect_ratio self.x = s.x self.y = s.y self.width = s.width self.height = s.height self.image = s.image self.image_width = s.image_width self.image_height = s.image_height def property_by_values(self, values): SVGElement.property_by_values(self, values) Transformable.property_by_values(self, values) GraphicObject.property_by_values(self, values) if XLINK_HREF in values: self.url = values[XLINK_HREF] elif SVG_HREF in values: self.url = values[SVG_HREF] viewbox = values.get(SVG_ATTR_VIEWBOX) if viewbox is not None: self.viewbox = Viewbox(viewbox) if SVG_ATTR_PRESERVEASPECTRATIO in values: if values[SVG_ATTR_PRESERVEASPECTRATIO] == SVG_VALUE_NONE: self.preserve_aspect_ratio = None else: self.preserve_aspect_ratio = values[SVG_ATTR_PRESERVEASPECTRATIO] self.x = Length(values.get(SVG_ATTR_X, 0)).value() self.y = Length(values.get(SVG_ATTR_Y, 0)).value() self.width = Length(values.get(SVG_ATTR_WIDTH, "100%")).value() self.height = Length(values.get(SVG_ATTR_HEIGHT, "100%")).value() if "image" in values: self.image = values["image"] self.image_width, self.image_height = self.image.size def render(self, **kwargs): GraphicObject.render(self, **kwargs) Transformable.render(self, **kwargs) SVGElement.render(self, **kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass if isinstance(self.x, Length): self.x = self.x.value(relative_length=width, **kwargs) if isinstance(self.y, Length): self.y = self.y.value(relative_length=height, **kwargs) if isinstance(self.width, Length): self.width = self.width.value(relative_length=width, **kwargs) if isinstance(self.height, Length): self.height = self.height.value(relative_length=height, **kwargs) return self def __copy__(self): """ Copy of Image. This will not copy the .image subobject in a deep manner since it's optional that that object will exist or not. As such if using PIL it would be required to either say self.image = self.image.copy() or call .load() again. """ return Image(self) @property def viewbox_transform(self): if self.viewbox is None: return "" return self.viewbox.transform(self) def load(self, directory=None): try: from PIL import Image as PILImage if self.data is not None: self.load_data() elif self.url is not None: self.load_file(directory) self.set_values_by_image() except ImportError: pass def load_data(self): try: # This code will not activate without PIL/Pillow installed. from PIL import Image as PILImage if self.data is not None: from io import BytesIO self.image = PILImage.open(BytesIO(self.data)) else: return except ImportError: # PIL/Pillow not found, decoding data is most we can do. pass def load_file(self, directory): try: # This code will not activate without PIL/Pillow installed. from PIL import Image as PILImage if self.url is not None: try: self.image = PILImage.open(self.url) except IOError: try: if directory is not None: from os.path import join relpath = join(directory, self.url) self.image = PILImage.open(relpath) except IOError: return except ImportError: # PIL/Pillow not found, decoding data is most we can do. pass def set_values_by_image(self): if self.image is None: return # No image to set values by. self.image_width = self.image.width self.image_height = self.image.height self.viewbox = Viewbox( "0 0 %d %d" % (self.image_width, self.image_height), self.preserve_aspect_ratio, ) self.render(width=self.image_width, height=self.image_height) self.transform = Matrix(self.viewbox_transform) * self.transform def bbox(self, transformed=True, with_stroke=False): """ Get the bounding box for the given image object :param transformed: whether this is the transformed bounds or default. :param with_stroke: There is no stroke for an image so with_stroke is ignored :return: bounding box of the given element """ if self.image_width is None or self.image_height is None: p = Point(0, 0) p *= self.transform return p.x, p.y, p.x, p.y width = self.image_width height = self.image_height if transformed: p = ( Point(0, 0) * self.transform, Point(width, 0) * self.transform, Point(width, height) * self.transform, Point(0, height) * self.transform, ) else: p = (Point(0, 0), Point(width, 0), Point(width, height), Point(0, height)) x_vals = list(s.x for s in p) y_vals = list(s.y for s in p) min_x = min(x_vals) min_y = min(y_vals) max_x = max(x_vals) max_y = max(y_vals) return min_x, min_y, max_x, max_y SVGImage = Image class Desc(SVGElement): def __init__(self, *args, **values): self.desc = None if values is None: values = dict() SVGElement.__init__(self, *args, **values) def __eq__(self, other): if not isinstance(other, Desc): return False return self.desc == other.desc def __repr__(self): return "%s('%s')" % (self.__class__.__name__, self.desc) def property_by_args(self, *args): if len(args) == 1: self.desc = args[0] def property_by_object(self, obj): SVGElement.property_by_object(self, obj) self.desc = obj.desc def property_by_values(self, values): SVGElement.property_by_values(self, values) if SVG_TAG_DESC in values: self.desc = values[SVG_TAG_DESC] SVGDesc = Desc class Title(SVGElement): def __init__(self, *args, **values): self.title = None if values is None: values = dict() SVGElement.__init__(self, *args, **values) def __eq__(self, other): if not isinstance(other, Title): return False return self.title == other.title def __repr__(self): return "%s('%s')" % (self.__class__.__name__, self.title) def property_by_args(self, *args): if len(args) == 1: self.title = args[0] def property_by_object(self, obj): SVGElement.property_by_object(self, obj) self.title = obj.title def property_by_values(self, values): SVGElement.property_by_values(self, values) if SVG_TAG_TITLE in values: self.title = values[SVG_TAG_TITLE] class SVG(Group): """ SVG Document and Parsing. SVG is the SVG main object and also the embedded SVGs within it. It's a subtype of Group. The SVG has a viewbox, and parsing methods which can be used if given a stream, path, or svg string. """ def __init__(self, *args, **kwargs): self.objects = {} self.x = None self.y = None self.width = None self.height = None self.viewbox = None Group.__init__(self, *args, **kwargs) @property def implicit_position(self): if not self.apply: return Point(self.x, self.y) point = Point(self.x, self.y) point *= self.transform return point @property def implicit_x(self): if not self.apply: return self.x return self.implicit_position[0] @property def implicit_y(self): if not self.apply: return self.y return self.implicit_position[1] @property def implicit_width(self): if not self.apply: return self.width p = Point(self.width, 0) p *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(p) @property def implicit_height(self): if not self.apply: return self.height p = Point(0, self.height) p *= self.transform origin = Point(0, 0) origin *= self.transform return origin.distance_to(p) def property_by_object(self, s): Group.property_by_object(self, s) self.x = s.x self.y = s.y self.width = s.width self.height = s.height self.viewbox = Viewbox(s.viewbox) if s.viewbox is not None else None def property_by_values(self, values): Group.property_by_values(self, values) self.x = Length(values.get(SVG_ATTR_X, 0)).value() self.y = Length(values.get(SVG_ATTR_Y, 0)).value() self.width = Length(values.get(SVG_ATTR_WIDTH, "100%")).value() self.height = Length(values.get(SVG_ATTR_HEIGHT, "100%")).value() viewbox = values.get(SVG_ATTR_VIEWBOX) par = values.get(SVG_ATTR_PRESERVEASPECTRATIO) self.viewbox = Viewbox(viewbox, par) if viewbox is not None else None def get_element_by_id(self, id): return self.objects.get(id) def get_element_by_url(self, url): for _id in REGEX_IRI.findall(url): return self.get_element_by_id(_id) def render(self, **kwargs): Group.render(self, **kwargs) width = kwargs.get("width", kwargs.get("relative_length")) height = kwargs.get("height", kwargs.get("relative_length")) try: del kwargs["relative_length"] except KeyError: pass self.width = Length(self.width).value(relative_length=width, **kwargs) self.height = Length(self.height).value(relative_length=height, **kwargs) self.x = Length(self.x).value(relative_length=width, **kwargs) self.y = Length(self.y).value(relative_length=height, **kwargs) def elements(self, conditional=None): if conditional is None or conditional(self): yield self for q in self.select(conditional): yield q @property def viewbox_transform(self): if self.viewbox is None: return "" return self.viewbox.transform(self) @staticmethod def _shadow_iter(tag, elem, children): yield tag, "start", elem try: for t, e, c in children: for shadow_tag, shadow_event, shadow_elem in SVG._shadow_iter(t, e, c): yield shadow_tag, shadow_event, shadow_elem except ValueError: """ Strictly speaking it is possible to reference use from other use objects. If this is an infinite loop we should not block the rendering. Just say we finished. See: W3C, struct-use-12-f """ pass yield tag, "end", elem @staticmethod def _use_structure_parse(source): """ SVG structure pass: parses the svg file such that it creates the structure implied by reused objects in a generalized context. Objects ids are read and put into an unparsed shadow tree. objects seamlessly contain their definitions. """ defs = {} parent = None # Define Root Node. children = list() for event, elem in iterparse(source, events=("start", "end", "start-ns")): try: tag = elem.tag if tag.startswith("{http://www.w3.org/2000/svg"): tag = tag[28:] # Removing namespace. http://www.w3.org/2000/svg: except AttributeError: yield None, event, elem continue if event == "start": attributes = elem.attrib # Create new node. siblings = children # Parent's children are now my siblings. parent = (parent, children) # parent is now previous node context children = list() # new node has no children. node = (tag, elem, children) # define this node. siblings.append(node) # siblings now includes this node. if SVG_TAG_USE == tag: url = None if XLINK_HREF in attributes: url = attributes[XLINK_HREF] if SVG_HREF in attributes: url = attributes[SVG_HREF] if url is not None: transform = False try: x = attributes[SVG_ATTR_X] del attributes[SVG_ATTR_X] transform = True except KeyError: x = "0" try: y = attributes[SVG_ATTR_Y] del attributes[SVG_ATTR_Y] transform = True except KeyError: y = "0" if transform: try: attributes[ SVG_ATTR_TRANSFORM ] = "%s translate(%s, %s)" % ( attributes[SVG_ATTR_TRANSFORM], x, y, ) except KeyError: attributes[SVG_ATTR_TRANSFORM] = "translate(%s, %s)" % ( x, y, ) yield tag, event, elem try: shadow_node = defs[url[1:]] children.append( shadow_node ) # Shadow children are children of the use. for n in SVG._shadow_iter(*shadow_node): yield n except KeyError: pass # Failed to find link. else: yield tag, event, elem if SVG_ATTR_ID in attributes: # If we have an ID, we save the node. defs[attributes[SVG_ATTR_ID]] = node # store node value in defs. elif event == "end": yield tag, event, elem # event is 'end', pop values. parent, children = parent # Parent is now node. @staticmethod def parse( source, reify=True, ppi=DEFAULT_PPI, width=None, height=None, color="black", transform=None, context=None, ): """ Parses the SVG file. All attributes are things which the SVG document itself could not be aware of, such as the real size of pixels and the size of the viewport (as opposed to the viewbox). :param source: Source svg file or stream. :param reify: Should the Geometry sized or have lazy matrices. :param ppi: How many physical pixels per inch are there in this view. :param width: The physical width of the viewport :param height: The physical height of the viewport :param color: the `currentColor` value from outside the current scope. :param transform: Any required transformations to be pre-applied to this document :param context: Any existing document context. :return: """ clip = 0 root = context styles = {} stack = [] values = { SVG_ATTR_COLOR: color, SVG_ATTR_FILL: "black", SVG_ATTR_STROKE: "none", } if transform is not None: values[SVG_ATTR_TRANSFORM] = transform for tag, event, elem in SVG._use_structure_parse(source): """ SVG element parsing parses the job compiling any parsed elements into their compiled object forms. """ if event == "start": stack.append((context, values)) if ( SVG_ATTR_DISPLAY in values and values[SVG_ATTR_DISPLAY].lower() == SVG_VALUE_NONE ): continue # Values has a display=none. Do not render anything. No Shadow Dom. current_values = values values = {} values.update(current_values) # copy of dictionary # Non-propagating values. if SVG_ATTR_PRESERVEASPECTRATIO in values: del values[SVG_ATTR_PRESERVEASPECTRATIO] if SVG_ATTR_VIEWBOX in values: del values[SVG_ATTR_VIEWBOX] if SVG_ATTR_ID in values: del values[SVG_ATTR_ID] if SVG_ATTR_CLIP_PATH in values: del values[SVG_ATTR_CLIP_PATH] attributes = elem.attrib # priority; lowest attributes[SVG_ATTR_TAG] = tag # Split any Style block elements into parts; priority medium style = "" if "*" in styles: # Select all. style += styles["*"] if tag in styles: # selector type style += styles[tag] if SVG_ATTR_ID in attributes: # Selector id #id svg_id = attributes[SVG_ATTR_ID] css_tag = "#%s" % svg_id if css_tag in styles: if len(style) != 0: style += ";" style += styles[css_tag] if SVG_ATTR_CLASS in attributes: # Selector class .class for svg_class in attributes[SVG_ATTR_CLASS].split(" "): css_tag = ".%s" % svg_class if css_tag in styles: if len(style) != 0: style += ";" style += styles[css_tag] css_tag = "%s.%s" % ( tag, svg_class, ) # Selector type/class type.class if css_tag in styles: if len(style) != 0: style += ";" style += styles[css_tag] # Split style element into parts; priority highest if SVG_ATTR_STYLE in attributes: style += attributes[SVG_ATTR_STYLE] # Process style tag left to right. for equate in style.split(";"): equal_item = equate.split(":") if len(equal_item) == 2: key = str(equal_item[0]).strip() value = str(equal_item[1]).strip() attributes[key] = value if ( SVG_ATTR_FILL in attributes and attributes[SVG_ATTR_FILL] == SVG_VALUE_CURRENT_COLOR ): if SVG_ATTR_COLOR in attributes: attributes[SVG_ATTR_FILL] = attributes[SVG_ATTR_COLOR] else: attributes[SVG_ATTR_FILL] = values[SVG_ATTR_COLOR] if ( SVG_ATTR_STROKE in attributes and attributes[SVG_ATTR_STROKE] == SVG_VALUE_CURRENT_COLOR ): if SVG_ATTR_COLOR in attributes: attributes[SVG_ATTR_STROKE] = attributes[SVG_ATTR_COLOR] else: attributes[SVG_ATTR_STROKE] = values[SVG_ATTR_COLOR] if SVG_ATTR_TRANSFORM in attributes: # If transform is already in values, append the new value. if SVG_ATTR_TRANSFORM in values: attributes[SVG_ATTR_TRANSFORM] = ( values[SVG_ATTR_TRANSFORM] + " " + attributes[SVG_ATTR_TRANSFORM] ) else: attributes[SVG_ATTR_TRANSFORM] = attributes[SVG_ATTR_TRANSFORM] # All class and attribute properties are compiled. values.update(attributes) values[SVG_STRUCT_ATTRIB] = attributes if ( SVG_ATTR_DISPLAY in values and values[SVG_ATTR_DISPLAY].lower() == SVG_VALUE_NONE ): continue # If the attributes flags our values to display=none, stop rendering. if SVG_NAME_TAG == tag: # The ordering for transformations on the SVG object are: # explicit transform, parent transforms, attribute transforms, viewport transforms s = SVG(values) if width is None: # If a dim was not provided but a viewbox was, use the viewbox dim as physical size, else 1000 width = ( s.viewbox.width if s.viewbox is not None else 1000 ) # 1000 default no information. if height is None: height = s.viewbox.height if s.viewbox is not None else 1000 s.render(ppi=ppi, width=width, height=height) height, width = s.width, s.height if s.viewbox is not None: try: if s.height == 0 or s.width == 0: return s viewport_transform = s.viewbox_transform except ZeroDivisionError: # The width or height was zero. # https://www.w3.org/TR/SVG11/struct.html#SVGElementWidthAttribute # "A value of zero disables rendering of the element." return s # No more parsing will be done. if SVG_ATTR_TRANSFORM in values: # transform on SVG element applied as if svg had parent with transform. values[SVG_ATTR_TRANSFORM] += " " + viewport_transform else: values[SVG_ATTR_TRANSFORM] = viewport_transform width, height = s.viewbox.width, s.viewbox.height if context is None: stack[-1] = (context, values) if context is not None: context.append(s) context = s if root is None: root = s elif SVG_TAG_GROUP == tag: s = Group(values) context.append(s) context = s s.render(ppi=ppi, width=width, height=height) elif SVG_TAG_DEFS == tag: s = Group(values) context = s # Non-Rendered s.render(ppi=ppi, width=width, height=height) elif SVG_TAG_CLIPPATH == tag: s = ClipPath(values) context = s # Non-Rendered s.render(ppi=ppi, width=width, height=height) clip += 1 elif SVG_TAG_PATTERN == tag: s = Pattern(values) context = s # Non-rendered s.render(ppi=ppi, width=width, height=height) elif tag in ( SVG_TAG_PATH, SVG_TAG_CIRCLE, SVG_TAG_ELLIPSE, SVG_TAG_LINE, # Shapes SVG_TAG_POLYLINE, SVG_TAG_POLYGON, SVG_TAG_RECT, SVG_TAG_IMAGE, ): try: if SVG_TAG_PATH == tag: s = Path(values) elif SVG_TAG_CIRCLE == tag: s = Circle(values) elif SVG_TAG_ELLIPSE == tag: s = Ellipse(values) elif SVG_TAG_LINE == tag: s = SimpleLine(values) elif SVG_TAG_POLYLINE == tag: s = Polyline(values) elif SVG_TAG_POLYGON == tag: s = Polygon(values) elif SVG_TAG_RECT == tag: s = Rect(values) else: # SVG_TAG_IMAGE == tag: s = Image(values) except ValueError: continue s.render(ppi=ppi, width=width, height=height) if reify: s.reify() if s.is_degenerate(): continue context.append(s) elif tag in ( SVG_TAG_STYLE, SVG_TAG_TEXT, SVG_TAG_DESC, SVG_TAG_TITLE, SVG_TAG_TSPAN, ): # ''') m = SVG.parse(q) poly = m[0][0][0] circ1 = m[0][0][1] circ2 = m[0][0][2] self.assertEqual(poly.fill, "black") self.assertEqual(poly.stroke, "none") self.assertEqual(circ1.fill, "none") self.assertEqual(circ1.stroke, "blue") self.assertEqual(circ2.fill, "none") self.assertEqual(circ2.stroke, "red") svgelements-1.6.8/test/test_cubic_bezier.py000066400000000000000000000031611417573325500211140ustar00rootroot00000000000000import unittest from random import * from svgelements import * def get_random_cubic_bezier(): return CubicBezier((random() * 50, random() * 50), (random() * 50, random() * 50), (random() * 50, random() * 50), (random() * 50, random() * 50)) class TestElementCubicBezierLength(unittest.TestCase): def test_cubic_bezier_length(self): n = 100 error = 0 for _ in range(n): b = get_random_cubic_bezier() l1 = b._length_scipy() l2 = b._length_default(error=1e-6) c = abs(l1 - l2) error += c self.assertAlmostEqual(l1, l2, places=1) print("Average cubic-line error: %g" % (error / n)) class TestElementCubicBezierPoint(unittest.TestCase): def test_cubic_bezier_point_start_stop(self): import numpy as np for _ in range(1000): b = get_random_cubic_bezier() self.assertEqual(b.start, b.point(0)) self.assertEqual(b.end, b.point(1)) self.assertTrue(np.all(np.array([list(b.start), list(b.end)]) == b.npoint([0, 1]))) def test_cubic_bezier_point_implementations_match(self): import numpy as np for _ in range(1000): b = get_random_cubic_bezier() pos = np.linspace(0, 1, 100) v1 = b.npoint(pos) v2 = [] for i in range(len(pos)): v2.append(b.point(pos[i])) for p, p1, p2 in zip(pos, v1, v2): self.assertEqual(b.point(p), Point(p1)) self.assertEqual(Point(p1), Point(p2)) svgelements-1.6.8/test/test_descriptive_elements.py000066400000000000000000000011051417573325500227000ustar00rootroot00000000000000import io import unittest from svgelements import * class TestDescriptiveElements(unittest.TestCase): def test_descriptive_element(self): q = io.StringIO(u'''\n Who? My Friend. ''') m = SVG.parse(q) q = list(m.elements()) self.assertEqual(len(q), 3) self.assertEqual(q[1].title, "Who?") self.assertEqual(q[2].desc, "My Friend.") svgelements-1.6.8/test/test_element.py000066400000000000000000000065771417573325500201360ustar00rootroot00000000000000import unittest from svgelements import * class TestElementElement(unittest.TestCase): """These tests ensure the performance of the SVGElement basecase.""" def test_element_id(self): values = {'id': 'my_id', 'random': True} r = Rect(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = Circle(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = Ellipse(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = Polygon(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = Polyline(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = SimpleLine(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = Path(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = SVGImage(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) r = SVGText(values) self.assertEqual(values['id'], r.values['id']) self.assertEqual(values['random'], r.values['random']) self.assertRaises(KeyError, lambda: r.values['not_there']) def test_element_merge(self): values = {'id': 'my_id', 'random': True} r = Rect(values, random=False, tat='awesome') self.assertEqual(r.values['id'], values['id']) self.assertNotEqual(r.values['random'], values['random']) self.assertEqual(r.values['tat'], 'awesome') r = Rect(fill='red') self.assertEqual(r.fill, '#f00') def test_element_propagate(self): values = {'id': 'my_id', 'random': True} r = Rect(values, random=False, tat='awesome') r = Rect(r) self.assertEqual(r.values['id'], values['id']) self.assertNotEqual(r.values['random'], values['random']) self.assertEqual(r.values['tat'], 'awesome') r = Rect(fill='red') r = Rect(r) self.assertEqual(r.fill, '#f00') r = Rect(stroke='red') r = Rect(r) self.assertEqual(r.stroke, '#f00') r = Rect(width=20) r = Rect(r) self.assertEqual(r.width, 20) p = Path('M0,0 20,0 0,20z M20,20 40,20 20,40z', fill='red') p2 = Path(p.subpath(1)) p2[0].start = None self.assertEqual(p2, 'M20,20 40,20 20,40z') self.assertEqual(p2.fill, 'red') svgelements-1.6.8/test/test_generation.py000066400000000000000000000051101417573325500206160ustar00rootroot00000000000000import unittest from svgelements import * paths = [ 'M 100,100 L 300,100 L 200,300 Z', 'M 0,0 L 50,20 M 100,100 L 300,100 L 200,300 Z', 'M 100,100 L 200,200', 'M 100,200 L 200,100 L -100,-200', 'M 100,200 C 100,100 250,100 250,200 S 400,300 400,200', 'M 100,200 C 100,100 400,100 400,200', 'M 100,500 C 25,400 475,400 400,500', 'M 100,800 C 175,700 325,700 400,800', 'M 600,200 C 675,100 975,100 900,200', 'M 600,500 C 600,350 900,650 900,500', 'M 600,800 C 625,700 725,700 750,800 S 875,900 900,800', 'M 200,300 Q 400,50 600,300 T 1000,300', 'M -3.4E+38,3.4E+38 L -3.4E-38,3.4E-38', 'M 0,0 L 50,20 M 50,20 L 200,100 Z', 'M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275', ] class TestGeneration(unittest.TestCase): """Examples from the SVG spec""" def test_svg_examples(self): for path in paths[15:]: self.assertEqual(Path(path).d(), path) def test_svg_example0(self): path = paths[0] self.assertEqual(Path(path).d(), path) def test_svg_example1(self): path = paths[1] self.assertEqual(Path(path).d(), path) def test_svg_example2(self): path = paths[2] self.assertEqual(Path(path).d(), path) def test_svg_example3(self): path = paths[3] self.assertEqual(Path(path).d(), path) def test_svg_example4(self): path = paths[4] self.assertEqual(Path(path).d(), path) def test_svg_example5(self): path = paths[5] self.assertEqual(Path(path).d(), path) def test_svg_example6(self): path = paths[6] self.assertEqual(Path(path).d(), path) def test_svg_example7(self): path = paths[7] self.assertEqual(Path(path).d(), path) def test_svg_example8(self): path = paths[8] self.assertEqual(Path(path).d(), path) def test_svg_example9(self): path = paths[9] self.assertEqual(Path(path).d(), path) def test_svg_example10(self): path = paths[10] self.assertEqual(Path(path).d(), path) def test_svg_example11(self): path = paths[11] self.assertEqual(Path(path).d(), path) def test_svg_example12(self): path = paths[12] self.assertEqual(Path(path).d(), path) def test_svg_example13(self): path = paths[13] self.assertEqual(Path(path).d(), path) def test_svg_example14(self): path = paths[14] self.assertEqual(Path(path).d(), "M 600,350 L 650,325 A 27.9508,27.9508 -30 0,1 700,300 L 750,275") # Too small arc forced increase rx,ry svgelements-1.6.8/test/test_group.py000066400000000000000000000067461417573325500176370ustar00rootroot00000000000000import io import unittest from svgelements import * class TestElementGroup(unittest.TestCase): def test_group_bbox(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) m *= 'scale(2)' for e in m.select(lambda e: isinstance(e, Rect)): self.assertEqual(e.x, 0) self.assertEqual(e.y, 40) self.assertEqual(e.width, 100) self.assertEqual(e.height, 100) self.assertEqual(m.width, 200) self.assertEqual(m.height, 200) def test_group_2rect(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500, reify=False) m *= 'scale(2)' rects = list(m.select(lambda e: isinstance(e, Rect))) r0 = rects[0] self.assertEqual(r0.implicit_x, 0) self.assertEqual(r0.implicit_y, 80) self.assertEqual(r0.implicit_width, 200) self.assertEqual(r0.implicit_height, 200) self.assertEqual(m.width, 200) self.assertEqual(m.height, 200) self.assertEqual(r0.bbox(), (0.0, 80.0, 200.0, 280.0)) m.reify() self.assertEqual(m.implicit_width, 400) self.assertEqual(m.implicit_height, 400) r1 = rects[1] self.assertEqual(r1.implicit_x, 0) self.assertEqual(r1.implicit_y, 0) self.assertAlmostEqual(r1.implicit_width, 200) self.assertAlmostEqual(r1.implicit_height, 200) print(r1.bbox()) def test_issue_107(self): """ Tests issue 107 inability to multiple group matrix objects while creating new group objects. https://github.com/meerk40t/svgelements/issues/107 """ q = io.StringIO(u''' ''') m = SVG.parse(q) m *= "translate(100,100)" # Test __imul__ n = m * 'scale(2)' # Test __mult__ self.assertEqual(n[0][0].transform, Matrix("matrix(2,0,0,2,200,200)")) self.assertEqual(m[0][0].transform, Matrix("matrix(1,0,0,1,100,100)")) def test_issue_152(self): """ Tests issue 152, closed text objects within a group with style:display=None This should have the SVG element and nothing else. https://github.com/meerk40t/svgelements/issues/152 """ q = io.StringIO(u''' Issue 152 ''') elements = list(SVG.parse(q).elements()) self.assertEqual(len(elements), 1) svgelements-1.6.8/test/test_image.py000066400000000000000000000124121417573325500175500ustar00rootroot00000000000000import unittest from svgelements import * class TestElementImage(unittest.TestCase): def test_image_datauri(self): e = Image(href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAEFklEQVR4nO3dsW7bQBRFwSjw//8yU6UJYDgil9xdnZnalp6LewhBhV/HcRy/gKTfsw8A5hEACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACPuafUDd6/X68WeO43jgEooE4GH/M/iffkcQGEUAHnJm+D+9lhBwlQDcbOTwv3ttIeAsAbjJncP/7r2EgHf5FuAGT45/hfdlXwIw2OwRzn5/9iIAA60yvlXuYH0CMMhqo1vtHtYkAAOsOrZV72IdAnDR6iNb/T7mEoALdhnXLnfyPAGAMAE4aben6m738gwBgDABOGHXp+mud3MfAYAwAXjT7k/R3e9nLAGAMAGAMAGAMAF4w6d8fv6Uv4PrBADCBADCBADCBADCBADCBADCBADCBADCBOANn/Kfdz7l7+A6AYAwAYAwAYAwAXjT7p+fd7+fsQQAwgTghF2forvezX0EAMIE4KTdnqa73cszBADCBOCCXZ6qu9zJ8wTgotXHtfp9zCUAA6w6slXvYh0CMMhqY1vtHtYkAAOtMrpV7mB9AjDY7PHNfn/2IgA3mDVC4+ddX7MP+FR/x/jEf+ExfM4SgJvdGQLD5yoBeMjIEBg+owjAw/4d7/8EweC5iwBMZtzM5FsACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACBMACPsDhkRyDUVGTB8AAAAASUVORK5CYII=") self.assertEqual(e.data[:6], b"\x89PNG\r\n") e1 = Image(href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAEAAQADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiq99f2emWcl5f3cFpax43zTyCNFyQBljwMkgfjXj/iH9ozQtPvBBoemT6vGPvzvIbZDwCNoKlj1IOQuMcZBzQB7RRXzB/w0d4w/6Buh/wDfib/47XV+Hv2kLG5uILfxBoslmhRVku7WXzVD5ALGMgFU+8eCxGAMN1oA90orP0bXNL8Q6cl/pF/Be2rYG+F87SQDtYdVbBGVOCM8itCgAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAqvf31vpmnXN/eSeXa2sTzTPtJ2ooJY4HJwAelWK8X/aM8Q3Gn+F9O0OBcR6pKzzvkcpEUITBHdmU5BGNmOQTQB5J8TPiZfeP9UCIJLbRbdybW0J5J6eZJjguR26KDgdSW4OiigAooooA6jwL461TwHrgv7A+bbyYW6tHbCXCDsfRhk4btnuCQfsPw5r9j4p8P2etaa0htLpCyeYu1lIJVlI9QwI4yOOCRzXwpXvH7N3iG4XUdW8NMu61eL7fGcgeW4KRt2ydwZO/GzpyaAPoeiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACvmD9o7/koen/8AYKj/APRstfT9eF/tIeHprnS9I8QW9tGyWjvb3UioS4V8GMsQPuBgw5PBkGPvGgD50ooooAKKKKACvQPgl/yV7Qv+3j/0nkrz+vbP2cvD01z4l1DxBLbRtaWlubeKSRCSJnIOUOMZCBg2DkCQdmoA+k6KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKz9c0az8Q6He6RfpvtbuJonwASuejLkEBgcEHHBANaFFAHxR468C6p4D1w2F+PNt5Mta3aLhLhB3HowyMr2z3BBPL1916/4c0jxTpbabrVjHd2hcPsYlSrDoVZSCp6jII4JHQmvDPEP7N14t4G8NazA9q3WPUiVePgfxIpD5O7+FccDnrQB4PRXoH/AApL4h/9C9/5O2//AMcrrPD37OWtXNxBL4g1O0s7QoryRWpMs4ORmM5ARTjcNwLAEDhhQB5X4Y8Map4u1yHSNIg824k5Zm4SJB1dz2UZH5gAEkA/Y/gvwnZ+CvC9rotm/m+Vl5pygRppGOWYgfgBnJChRk4zR4T8F6F4K05rPRbTyvN2meZ2LyTMowCzH8TgYUEnAGTXQUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAH//Z") self.assertEqual(e1.data[:3], b"\xff\xd8\xff") e2 = Image(href="data:text/plain;base64,c3ZnZWxlbWVudHMgcmVhZHMgc3ZnIGZpbGVz") self.assertEqual(e2.data, b"svgelements reads svg files") e3 = Image(href="data:text/vnd-example+xyz;foo=bar;base64,R0lGODdh") self.assertEqual(e3.data, b"GIF87a") e4 = Image(href="data:text/plain;charset=UTF-8;page=21,the%20data:1234,5678") self.assertEqual(e4.data, b"the data:1234,5678") svgelements-1.6.8/test/test_length.py000066400000000000000000000165221417573325500177550ustar00rootroot00000000000000import io import unittest from svgelements import * class TestElementLength(unittest.TestCase): """Tests the functionality of the Length Element.""" def test_length_parsing(self): self.assertAlmostEqual(Length('10cm'), (Length('100mm'))) self.assertNotEqual(Length("1mm"), 0) self.assertNotEqual(Length("1cm"), 0) self.assertNotEqual(Length("1in"), 0) self.assertNotEqual(Length("1px"), 0) self.assertNotEqual(Length("1pc"), 0) self.assertNotEqual(Length("1pt"), 0) self.assertNotEqual(Length("1%").value(relative_length=100), 0) self.assertEqual(Length("50%").value(relative_length=100), 50.0) def test_distance_matrix(self): m = Matrix("Translate(20mm,50%)", ppi=1000, width=600, height=800) self.assertEqual(Matrix(1, 0, 0, 1, 787.402, 400), m) m = Matrix("Translate(20mm,50%)") m.render(ppi=1000, width=600, height=800) self.assertEqual(Matrix(1, 0, 0, 1, 787.402, 400), m) def test_rect_distance_percent(self): rect = Rect("0%", "0%", "100%", "100%") rect.render(relative_length="1mm", ppi=DEFAULT_PPI) self.assertEqual(rect, Path("M 0,0 H 3.7795296 V 3.7795296 H 0 z")) rect = Rect("0%", "0%", "100%", "100%") rect.render(relative_length="1in", ppi=DEFAULT_PPI) self.assertEqual(rect, Path("M 0,0 H 96 V 96 H 0 z")) def test_circle_distance_percent(self): shape = Circle(0, 0, "50%") shape.render(relative_length="1in", ppi=DEFAULT_PPI) print(shape.d()) self.assertEqual( shape, Path('M48,0A48,48 0 0,1 0,48A48,48 0 0,1-48,0A48,48 0 0,1 0,-48A48,48 0 0,1 48,0Z') ) def test_length_division(self): self.assertEqual(Length("1mm") // Length('1mm'), 1.0) self.assertEqual(Length("1mm") / Length('1mm'), 1.0) self.assertEqual(Length('1in') / '1in', 1.0) self.assertEqual(Length('1cm') / '1mm', 10.0) def test_length_compare(self): self.assertTrue(Length('1in') < Length('2.6cm')) self.assertTrue(Length('1in') < '2.6cm') self.assertFalse(Length('1in') < '2.5cm') self.assertTrue(Length('10mm') >= '1cm') self.assertTrue(Length('10mm') <= '1cm') self.assertTrue(Length('11mm') >= '1cm') self.assertTrue(Length('10mm') <= '1.1cm') self.assertFalse(Length('11mm') <= '1cm') self.assertFalse(Length('10mm') >= '1.1cm') self.assertTrue(Length('20%') > '10%') self.assertRaises(ValueError, lambda: Length('20%') > '1in') self.assertRaises(ValueError, lambda: Length('20px') > '1in') self.assertRaises(ValueError, lambda: Length('20pc') > '1in') self.assertRaises(ValueError, lambda: Length('20em') > '1in') self.assertEqual(max(Length('1in'), Length('2.5cm')), '1in') def test_length_parsed(self): q = io.StringIO(u''' ''') m = SVG.parse(q, ppi=96.0) q = list(m.elements()) self.assertEqual(q[1].x, 96.0) self.assertEqual(q[1].y, 96.0) self.assertEqual(q[1].width, 960) self.assertEqual(q[1].height, 960) def test_length_parsed_percent(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width=1000, height=1000) q = list(m.elements()) self.assertEqual(q[1].x, 250) self.assertEqual(q[1].y, 250) self.assertEqual(q[1].width, 500) self.assertEqual(q[1].height, 500) def test_length_parsed_percent2(self): q = io.StringIO(u'''\n ''') m = SVG.parse(q, width=1000, height=1000) q = list(m.elements()) self.assertEqual(q[1].x, 24) self.assertEqual(q[1].y, 24) self.assertEqual(q[1].width, 48) self.assertEqual(q[1].height, 48) def test_length_parsed_percent3(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) q = list(m.elements()) self.assertEqual(q[1].x, 24) self.assertEqual(q[1].y, 24) self.assertEqual(q[1].width, 48) self.assertEqual(q[1].height, 48) def test_length_parsed_percent4(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width="garbage", height=500) q = list(m.elements()) self.assertEqual(q[1].x, 24) self.assertEqual(q[1].y, 24) self.assertEqual(q[1].width, 48) self.assertEqual(q[1].height, 48) def test_length_parsed_percent5(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width="1in", height="1in") q = list(m.elements()) self.assertEqual(q[1].x, 24) self.assertEqual(q[1].y, 24) self.assertEqual(q[1].width, 48) self.assertEqual(q[1].height, 48) self.assertEqual(q[2].x, 240) self.assertEqual(q[2].y, 240) self.assertEqual(q[2].width, 480) self.assertEqual(q[2].height, 480) def test_length_parsed_percent6(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width="10000", height="10000") q = list(m.elements()) self.assertAlmostEqual(q[2].cx, q[3].cx, delta=1) self.assertAlmostEqual(q[2].cy, q[3].cy, delta=1) self.assertAlmostEqual(q[5].rx, q[6].rx, delta=1) self.assertAlmostEqual(q[6].rx, q[7].rx, delta=1) svgelements-1.6.8/test/test_matrix.py000066400000000000000000000246771417573325500200120ustar00rootroot00000000000000import unittest from svgelements import * class TestPathMatrix(unittest.TestCase): """Tests the functionality of the Matrix element.""" def test_rotate_css_angles(self): matrix = Matrix("rotate(90, 100,100)") path = Path("M0,0Z") path *= matrix self.assertEqual("M 200,0 Z", path.d()) matrix = Matrix("rotate(90deg, 100,100)") path = Path("M0,0Z") path *= matrix self.assertEqual("M 200,0 Z", path.d()) matrix = Matrix("rotate(0.25turn, 100,100)") path = Path("M0,0Z") path *= matrix self.assertEqual("M 200,0 Z", path.d()) matrix = Matrix("rotate(100grad, 100,100)") path = Path("M0,0Z") path *= matrix self.assertEqual("M 200,0 Z", path.d()) matrix = Matrix("rotate(1.5707963267948966rad, 100,100)") path = Path("M0,0Z") path *= matrix self.assertEqual("M 200,0 Z", path.d()) def test_matrix_multiplication(self): self.assertEqual(Matrix("scale(0.2) translate(-5,-5)"), Matrix("translate(-5,-5)") * Matrix("scale(0.2)")) self.assertEqual(Matrix("translate(-5,-5) scale(0.2)"), Matrix("scale(0.2)") * Matrix("translate(-5,-5)")) def test_rotate_css_distance(self): matrix = Matrix("rotate(90deg,100cm,100cm)") matrix.render(ppi=DEFAULT_PPI) path = Path("M0,0z") path *= matrix d = Length("1cm").value(ppi=DEFAULT_PPI) p2 = Path("M 200,0 Z") * Matrix("scale(%f)" % d) p2.values[SVG_ATTR_VECTOR_EFFECT] = SVG_VALUE_NON_SCALING_STROKE self.assertEqual(p2, path) def test_skew_single_value(self): m0 = Matrix("skew(15deg,0deg)") m1 = Matrix("skewX(15deg)") self.assertEqual(m0, m1) m0 = Matrix("skew(0deg,15deg)") m1 = Matrix("skewY(15deg)") self.assertEqual(m0, m1) def test_scale_single_value(self): m0 = Matrix("scale(2,1)") m1 = Matrix("scaleX(2)") self.assertEqual(m0, m1) m0 = Matrix("scale(1,2)") m1 = Matrix("scaleY(2)") self.assertEqual(m0, m1) def test_translate_single_value(self): m0 = Matrix("translate(500cm,0)") m1 = Matrix("translateX(500cm)") self.assertEqual(m0, m1) m0 = Matrix("translate(0,500cm)") m1 = Matrix("translateY(500cm)") self.assertEqual(m0, m1) m0 = Matrix("translate(500cm)") m1 = Matrix("translateX(500cm)") self.assertEqual(m0, m1) def test_translate_css_value(self): m0 = Matrix("translate(50mm,5cm)") m1 = Matrix("translate(5cm,5cm)") self.assertEqual(m0, m1) def test_rotate_css_value(self): m0 = Matrix("rotate(90deg, 50cm,50cm)", ppi=DEFAULT_PPI) m1 = Matrix("rotate(0.25turn, 500mm,500mm)", ppi=DEFAULT_PPI) self.assertEqual(m0, m1) def test_transform_translate(self): matrix = Matrix("translate(5,4)") path = Path() path.move((0, 0), (0, 100), (100, 100), 100 + 0j, "z").closed() path *= matrix self.assertEqual("M 5,4 L 5,104 L 105,104 L 105,4 L 5,4 Z", path.d()) def test_transform_scale(self): matrix = Matrix("scale(2)") path = Path() path.move((0, 0), (0, 100), (100, 100), 100 + 0j, 'z').closed() path *= matrix self.assertEqual("M 0,0 L 0,200 L 200,200 L 200,0 L 0,0 Z", path.d()) def test_transform_rotate(self): matrix = Matrix("rotate(360)") path = Path() path.move((0, 0), (0, 100), (100, 100), 100 + 0j, "z") path *= matrix self.assertAlmostEqual(path[0][1].x, 0) self.assertAlmostEqual(path[0][1].y, 0) self.assertAlmostEqual(path[1][1].x, 0) self.assertAlmostEqual(path[1][1].y, 100) self.assertAlmostEqual(path[2][1].x, 100) self.assertAlmostEqual(path[2][1].y, 100) self.assertAlmostEqual(path[3][1].x, 100) self.assertAlmostEqual(path[3][1].y, 0) self.assertAlmostEqual(path[4][1].x, 0) self.assertAlmostEqual(path[4][1].y, 0) def test_transform_value(self): matrix = Matrix("rotate(360,50,50)") path = Path() path.move((0, 0), (0, 100), (100, 100), 100 + 0j, "z") path *= matrix self.assertAlmostEqual(path[0][1].x, 0) self.assertAlmostEqual(path[0][1].y, 0) self.assertAlmostEqual(path[1][1].x, 0) self.assertAlmostEqual(path[1][1].y, 100) self.assertAlmostEqual(path[2][1].x, 100) self.assertAlmostEqual(path[2][1].y, 100) self.assertAlmostEqual(path[3][1].x, 100) self.assertAlmostEqual(path[3][1].y, 0) self.assertAlmostEqual(path[4][1].x, 0) self.assertAlmostEqual(path[4][1].y, 0) def test_transform_skewx(self): matrix = Matrix("skewX(10,50,50)") path = Path() path.move((0, 0), (0, 100), (100, 100), 100 + 0j, "z").closed() path *= matrix self.assertEqual( "M -8.81634903542,0 L 8.81634903542,100 L 108.816349035,100 L 91.1836509646,0 L -8.81634903542,0 Z", path.d()) def test_transform_skewy(self): matrix = Matrix("skewY(10, 50,50)") path = Path() path.move((0, 0), (0, 100), (100, 100), 100 + 0j, "z").closed() path *= matrix self.assertEqual( "M 0,-8.81634903542 L 0,91.1836509646 L 100,108.816349035 L 100,8.81634903542 L 0,-8.81634903542 Z", path.d()) def test_matrix_repr_rotate(self): """ [a c e] [b d f] """ self.assertEqual(Matrix(0, 1, -1, 0, 0, 0), Matrix.rotate(radians(90))) def test_matrix_repr_scale(self): """ [a c e] [b d f] """ self.assertEqual(Matrix(2, 0, 0, 2, 0, 0), Matrix.scale(2)) def test_matrix_repr_hflip(self): """ [a c e] [b d f] """ self.assertEqual(Matrix(-1, 0, 0, 1, 0, 0), Matrix.scale(-1, 1)) def test_matrix_repr_vflip(self): """ [a c e] [b d f] """ self.assertEqual(Matrix(1, 0, 0, -1, 0, 0), Matrix.scale(1, -1)) def test_matrix_repr_post_cat(self): """ [a c e] [b d f] """ m = Matrix.scale(2) m.post_cat(Matrix.translate(-20, -20)) self.assertEqual(Matrix(2, 0, 0, 2, -20, -20), m) def test_matrix_repr_pre_cat(self): """ [a c e] [b d f] """ m = Matrix.translate(-20, -20) m.pre_cat(Matrix.scale(2)) self.assertEqual(Matrix(2, 0, 0, 2, -20, -20), m) def test_matrix_point_rotated_by_matrix(self): matrix = Matrix() matrix.post_rotate(radians(90), 100, 100) p = matrix.point_in_matrix_space((50, 50)) self.assertAlmostEqual(p[0], 150) self.assertAlmostEqual(p[1], 50) def test_matrix_point_scaled_in_space(self): matrix = Matrix() matrix.post_scale(2, 2, 50, 50) p = matrix.point_in_matrix_space((50, 50)) self.assertAlmostEqual(p[0], 50) self.assertAlmostEqual(p[1], 50) p = matrix.point_in_matrix_space((25, 25)) self.assertAlmostEqual(p[0], 0) self.assertAlmostEqual(p[1], 0) matrix.post_rotate(radians(45), 50, 50) p = matrix.point_in_matrix_space((25, 25)) self.assertAlmostEqual(p[0], 50) matrix = Matrix() matrix.post_scale(0.5, 0.5) p = matrix.point_in_matrix_space((100, 100)) self.assertAlmostEqual(p[0], 50) self.assertAlmostEqual(p[1], 50) matrix = Matrix() matrix.post_scale(2, 2, 100, 100) p = matrix.point_in_matrix_space((50, 50)) self.assertAlmostEqual(p[0], 0) self.assertAlmostEqual(p[1], 0) def test_matrix_cat_identity(self): identity = Matrix() from random import random for i in range(50): q = Matrix(random(), random(), random(), random(), random(), random()) p = copy(q) q.post_cat(identity) self.assertEqual(q, p) def test_matrix_pre_and_post_1(self): from random import random for i in range(50): tx = random() * 1000 - 500 ty = random() * 1000 - 500 rx = random() * 2 ry = random() * 2 a = random() * tau q = Matrix() q.post_translate(tx, ty) p = Matrix() p.pre_translate(tx, ty) self.assertEqual(p, q) q = Matrix() q.post_scale(rx, ry, tx, ty) p = Matrix() p.pre_scale(rx, ry, tx, ty) self.assertEqual(p, q) q = Matrix() q.post_rotate(a, tx, ty) p = Matrix() p.pre_rotate(a, tx, ty) self.assertEqual(p, q) q = Matrix() q.post_skew_x(a, tx, ty) p = Matrix() p.pre_skew_x(a, tx, ty) self.assertEqual(p, q) q = Matrix() q.post_skew_y(a, tx, ty) p = Matrix() p.pre_skew_y(a, tx, ty) self.assertEqual(p, q) def test_matrix_eval_repr(self): self.assertTrue(Matrix("rotate(20)") == eval(repr(Matrix("rotate(20)")))) self.assertFalse(Matrix("rotate(20)") != eval(repr(Matrix("rotate(20)")))) def test_matrix_reverse_scale(self): m1 = Matrix("scale(2)") m1.inverse() m2 = Matrix("scale(0.5)") self.assertEqual(m1, m2) m1.inverse() self.assertEqual(m1, "scale(2)") def test_matrix_reverse_translate(self): m1 = Matrix("translate(20,20)") m1.inverse() self.assertEqual(m1, Matrix("translate(-20,-20)")) def test_matrix_reverse_rotate(self): m1 = Matrix("rotate(30)") m1.inverse() self.assertEqual(m1, Matrix("rotate(-30)")) def test_matrix_reverse_skew(self): m1 = Matrix("skewX(1)") m1.inverse() self.assertEqual(m1, Matrix("skewX(-1)")) m1 = Matrix("skewY(1)") m1.inverse() self.assertEqual(m1, Matrix("skewY(-1)")) def test_matrix_reverse_scale_translate(self): m1 = Matrix("scale(2) translate(40,40)") m1.inverse() self.assertEqual(m1, Matrix("translate(-40,-40) scale(0.5)")) svgelements-1.6.8/test/test_parsing.py000066400000000000000000001141241417573325500201340ustar00rootroot00000000000000import io import unittest from svgelements import * class TestParser(unittest.TestCase): def test_svg_examples(self): """Examples from the SVG spec""" path1 = Path('M 100 100 L 300 100 L 200 300 z') self.assertEqual(path1, Path(Move(100 + 100j), Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j))) # CHANGED. Closed object self.assertTrue(path1.closed) # for Z command behavior when there is multiple subpaths path1 = Path('M 0 0 L 50 20 M 100 100 L 300 100 L 200 300 z') self.assertEqual(path1, Path( Move(0j), Line(0 + 0j, 50 + 20j), Move(50 + 20j, 100+100j), # CHANGED. Move knows start position. Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j))) # CHANGED. Closed object path1 = Path('M 100 100 L 200 200') path2 = Path('M100 100L200 200') self.assertEqual(path1, path2) path1 = Path('M 100 200 L 200 100 L -100 -200') path2 = Path('M 100 200 L 200 100 -100 -200') self.assertEqual(path1, path2) path1 = Path("""M100,200 C100,100 250,100 250,200 S400,300 400,200""") self.assertEqual(path1, Path(Move(100 + 200j), CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j), CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j))) path1 = Path('M100,200 C100,100 400,100 400,200') self.assertEqual(path1, Path(Move(100 + 200j), CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j))) path1 = Path('M100,500 C25,400 475,400 400,500') self.assertEqual(path1, Path(Move(100 + 500j), CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j))) path1 = Path('M100,800 C175,700 325,700 400,800') self.assertEqual(path1, Path(Move(100+800j), CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j))) path1 = Path('M600,200 C675,100 975,100 900,200') self.assertEqual(path1, Path(Move(600 + 200j), CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j))) path1 = Path('M600,500 C600,350 900,650 900,500') self.assertEqual(path1, Path(Move(600 + 500j), CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j))) path1 = Path("""M600,800 C625,700 725,700 750,800 S875,900 900,800""") self.assertEqual(path1, Path(Move(600 + 800j), CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j), CubicBezier(750 + 800j, 775 + 900j, 875 + 900j, 900 + 800j))) path1 = Path('M200,300 Q400,50 600,300 T1000,300') self.assertEqual(path1, Path(Move(200 + 300j), QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j), QuadraticBezier(600 + 300j, 800 + 550j, 1000 + 300j))) path1 = Path('M300,200 h-150 a150,150 0 1,0 150,-150 z') self.assertEqual(path1, Path(Move(300 + 200j), Line(300 + 200j, 150 + 200j), Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), Close(300 + 50j, 300 + 200j))) # CHANGED. Closed object path1 = Path('M275,175 v-150 a150,150 0 0,0 -150,150 z') self.assertEqual(path1, Path(Move(275 + 175j), Line(275 + 175j, 275 + 25j), Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), Close(125 + 175j, 275 + 175j))) # CHANGED. Closed object path1 = Path('M275,175 v-150 a150,150 0 0,0 -150,150 L 275,175 z') self.assertEqual(path1, Path(Move(275 + 175j), Line(275 + 175j, 275 + 25j), Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), Line(125 + 175j, 275 + 175j), Close(275 + 175j, 275 + 175j))) # CHANGED. Closed object path1 = Path("""M600,350 l 50,-25 a25,25 -30 0,1 50,-25 l 50,-25 a25,50 -30 0,1 50,-25 l 50,-25 a25,75 -30 0,1 50,-25 l 50,-25 a25,100 -30 0,1 50,-25 l 50,-25""") self.assertEqual(path1, Path(Move(600 + 350j), Line(600 + 350j, 650 + 325j), Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), Line(700 + 300j, 750 + 275j), Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), Line(800 + 250j, 850 + 225j), Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), Line(900 + 200j, 950 + 175j), Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), Line(1000 + 150j, 1050 + 125j))) def test_wc3_examples12(self): """ W3C_SVG_11_TestSuite Paths Test using multiple coord sets to build a polybeizer, and implicit values for initial S. """ parse_path = Path path12 = parse_path( """M 100 100 C 100 20 200 20 200 100 S 300 180 300 100""" ) self.assertEqual( path12, Path( Move(end=(100 + 100j)), CubicBezier( start=(100 + 100j), control1=(100 + 20j), control2=(200 + 20j), end=(200 + 100j), ), CubicBezier( start=(200 + 100j), control1=(200 + 180j), control2=(300 + 180j), end=(300 + 100j), ), ), ) path12 = parse_path( """M 100 250 S 200 200 200 250 300 300 300 250""" ) self.assertEqual( path12, Path( Move(end=(100 + 250j)), CubicBezier( start=(100 + 250j), control1=(100 + 250j), control2=(200 + 200j), end=(200 + 250j), ), CubicBezier( start=(200 + 250j), control1=(200 + 300j), control2=(300 + 300j), end=(300 + 250j), ), ), ) def test_wc3_examples13(self): """ W3C_SVG_11_TestSuite Paths Test multiple coordinates for V and H. """ parse_path = Path path13 = parse_path( """ M 240.00000 56.00000 H 270.00000 300.00000 320.00000 400.00000 """ ) self.assertEqual( path13, Path( Move(end=(240 + 56j)), Line(start=(240 + 56j), end=(270 + 56j)), Line(start=(270 + 56j), end=(300 + 56j)), Line(start=(300 + 56j), end=(320 + 56j)), Line(start=(320 + 56j), end=(400 + 56j)), ), ) path13 = parse_path( """ M 240.00000 156.00000 V 180.00000 200.00000 260.00000 300.00000 """ ) self.assertEqual( path13, Path( Move(end=(240 + 156j)), Line(start=(240 + 156j), end=(240 + 180j)), Line(start=(240 + 180j), end=(240 + 200j)), Line(start=(240 + 200j), end=(240 + 260j)), Line(start=(240 + 260j), end=(240 + 300j)), ), ) def test_wc3_examples14(self): """ W3C_SVG_11_TestSuite Paths Test implicit values for moveto. If the first command is 'm' it should be taken as an absolute moveto, plus implicit lineto. """ parse_path = Path path14 = parse_path( """ m 62.00000 56.00000 51.96152 90.00000 -103.92304 0.00000 51.96152 -90.00000 z m 0.00000 15.00000 38.97114 67.50000 -77.91228 0.00000 38.97114 -67.50000 z """ ) self.assertEqual( path14, Path( Move(end=(62 + 56j)), Line(start=(62 + 56j), end=(113.96152000000001 + 146j)), Line( start=(113.96152000000001 + 146j), end=(10.038480000000007 + 146j) ), Line(start=(10.038480000000007 + 146j), end=(62.00000000000001 + 56j)), Close(start=(62.00000000000001 + 56j), end=(62 + 56j)), Move(start=Point(62,56), end=(62 + 71j)), Line(start=(62 + 71j), end=(100.97113999999999 + 138.5j)), Line( start=(100.97113999999999 + 138.5j), end=(23.058859999999996 + 138.5j), ), Line( start=(23.058859999999996 + 138.5j), end=(62.029999999999994 + 71j) ), Close(start=(62.029999999999994 + 71j), end=(62 + 71j)), ), ) path14 = parse_path( """ M 177.00000 56.00000 228.96152 146.00000 125.03848 146.00000 177.00000 56.00000 Z M 177.00000 71.00000 215.97114 138.50000 138.02886 138.50000 177.00000 71.00000 Z """ ) self.assertEqual( path14, Path( Move(end=(177 + 56j)), Line(start=(177 + 56j), end=(228.96152 + 146j)), Line(start=(228.96152 + 146j), end=(125.03848 + 146j)), Line(start=(125.03848 + 146j), end=(177 + 56j)), Close(start=(177 + 56j), end=(177 + 56j)), Move(start=Point(177,56), end=(177 + 71j)), Line(start=(177 + 71j), end=(215.97114 + 138.5j)), Line(start=(215.97114 + 138.5j), end=(138.02886 + 138.5j)), Line(start=(138.02886 + 138.5j), end=(177 + 71j)), Close(start=(177 + 71j), end=(177 + 71j)), ), ) def test_wc3_examples15(self): """ W3C_SVG_11_TestSuite Paths 'M' or 'm' command with more than one pair of coordinates are absolute if the moveto was specified with 'M' and relative if the moveto was specified with 'm'. """ parse_path = Path path15 = parse_path("""M100,120 L160,220 L40,220 z""") self.assertEqual( path15, Path( Move(end=(100 + 120j)), Line(start=(100 + 120j), end=(160 + 220j)), Line(start=(160 + 220j), end=(40 + 220j)), Close(start=(40 + 220j), end=(100 + 120j)), ), ) path15 = parse_path("""M350,120 L410,220 L290,220 z""") self.assertEqual( path15, Path( Move(end=(350 + 120j)), Line(start=(350 + 120j), end=(410 + 220j)), Line(start=(410 + 220j), end=(290 + 220j)), Close(start=(290 + 220j), end=(350 + 120j)), ), ) path15 = parse_path("""M100,120 160,220 40,220 z""") self.assertEqual( path15, Path( Move(end=(100 + 120j)), Line(start=(100 + 120j), end=(160 + 220j)), Line(start=(160 + 220j), end=(40 + 220j)), Close(start=(40 + 220j), end=(100 + 120j)), ), ) path15 = parse_path("""m350,120 60,100 -120,0 z""") self.assertEqual( path15, Path( Move(end=(350 + 120j)), Line(start=(350 + 120j), end=(410 + 220j)), Line(start=(410 + 220j), end=(290 + 220j)), Close(start=(290 + 220j), end=(350 + 120j)), ), ) def test_wc3_examples17(self): """ W3C_SVG_11_TestSuite Paths Test that the 'z' and 'Z' command have the same effect. """ parse_path = Path path17 = parse_path("""M 50 50 L 50 150 L 150 150 L 150 50 z""") self.assertEqual( path17, Path( Move(end=(50 + 50j)), Line(start=(50 + 50j), end=(50 + 150j)), Line(start=(50 + 150j), end=(150 + 150j)), Line(start=(150 + 150j), end=(150 + 50j)), Close(start=(150 + 50j), end=(50 + 50j)), ), ) path17 = parse_path("""M 50 50 L 50 150 L 150 150 L 150 50 Z""") self.assertEqual( path17, Path( Move(end=(50 + 50j)), Line(start=(50 + 50j), end=(50 + 150j)), Line(start=(50 + 150j), end=(150 + 150j)), Line(start=(150 + 150j), end=(150 + 50j)), Close(start=(150 + 50j), end=(50 + 50j)), ), ) path17 = parse_path("""M 250 50 L 250 150 L 350 150 L 350 50 Z""") self.assertEqual( path17, Path( Move(end=(250 + 50j)), Line(start=(250 + 50j), end=(250 + 150j)), Line(start=(250 + 150j), end=(350 + 150j)), Line(start=(350 + 150j), end=(350 + 50j)), Close(start=(350 + 50j), end=(250 + 50j)), ), ) path17 = parse_path("""M 250 50 L 250 150 L 350 150 L 350 50 z""") self.assertEqual( path17, Path( Move(end=(250 + 50j)), Line(start=(250 + 50j), end=(250 + 150j)), Line(start=(250 + 150j), end=(350 + 150j)), Line(start=(350 + 150j), end=(350 + 50j)), Close(start=(350 + 50j), end=(250 + 50j)), ), ) def test_wc3_examples18(self): """ W3C_SVG_11_TestSuite Paths The 'path' element's 'd' attribute ignores additional whitespace, newline characters, and commas, and BNF processing consumes as much content as possible, stopping as soon as a character that doesn't satisfy the production is encountered. """ parse_path = Path path18a = parse_path("""M 20 40 H 40""") path18b = parse_path( """M 20 40 H 40""" ) self.assertEqual(path18a, path18b) path18a = parse_path("""M 20 60 H 40""") path18b = parse_path( """ M 20 60 H 40 """ ) self.assertEqual(path18a, path18b) path18a = parse_path("""M 20 80 H40""") path18b = parse_path("""M 20,80 H 40""") self.assertEqual(path18a, path18b) path18a = parse_path("""M 20 100 H 40#90""") path18b = parse_path("""M 20 100 H 40""") self.assertEqual(path18a, path18b) path18a = parse_path("""M 20 120 H 40.5 0.6""") path18b = parse_path("""M 20 120 H 40.5.6""") self.assertEqual(path18a, path18b) path18a = parse_path("""M 20 140 h 10 -20""") path18b = parse_path("""M 20 140 h 10-20""") self.assertEqual(path18a, path18b) path18a = parse_path("""M 20 160 H 40""") path18b = parse_path("""M 20 160 H 40#90""") self.assertEqual(path18a, path18b) def test_wc3_examples19(self): """ W3C_SVG_11_TestSuite Paths Test that additional parameters to pathdata commands are treated as additional calls to the most recent command. """ parse_path = Path path19a = parse_path("""M20 20 H40 H60""") path19b = parse_path("""M20 20 H40 60""") self.assertEqual(path19a, path19b) path19a = parse_path("""M20 40 h20 h20""") path19b = parse_path("""M20 40 h20 20""") self.assertEqual(path19a, path19b) path19a = parse_path("""M120 20 V40 V60""") path19b = parse_path("""M120 20 V40 60""") self.assertEqual(path19a, path19b) path19a = parse_path("""M140 20 v20 v20""") path19b = parse_path("""M140 20 v20 20""") self.assertEqual(path19a, path19b) path19a = parse_path("""M220 20 L 240 20 L260 20""") path19b = parse_path("""M220 20 L 240 20 260 20 """) self.assertEqual(path19a, path19b) path19a = parse_path("""M220 40 l 20 0 l 20 0""") path19b = parse_path("""M220 40 l 20 0 20 0""") self.assertEqual(path19a, path19b) path19a = parse_path("""M50 150 C50 50 200 50 200 150 C200 50 350 50 350 150""") path19b = parse_path("""M50 150 C50 50 200 50 200 150 200 50 350 50 350 150""") self.assertEqual(path19a, path19b) path19a = parse_path( """M50, 200 c0,-100 150,-100 150,0 c0,-100 150,-100 150,0""" ) path19b = parse_path( """M50, 200 c0,-100 150,-100 150,0 0,-100 150,-100 150,0""" ) self.assertEqual(path19a, path19b) path19a = parse_path("""M50 250 S125 200 200 250 S275, 200 350 250""") path19b = parse_path("""M50 250 S125 200 200 250 275, 200 350 250""") self.assertEqual(path19a, path19b) path19a = parse_path("""M50 275 s75 -50 150 0 s75, -50 150 0""") path19b = parse_path("""M50 275 s75 -50 150 0 75, -50 150 0""") self.assertEqual(path19a, path19b) path19a = parse_path("""M50 300 Q 125 275 200 300 Q 275 325 350 300""") path19b = parse_path("""M50 300 Q 125 275 200 300 275 325 350 300""") self.assertEqual(path19a, path19b) path19a = parse_path("""M50 325 q 75 -25 150 0 q 75 25 150 0""") path19b = parse_path("""M50 325 q 75 -25 150 0 75 25 150 0""") self.assertEqual(path19a, path19b) path19a = parse_path("""M425 25 T 425 75 T 425 125""") path19b = parse_path("""M425 25 T 425 75 425 125""") self.assertEqual(path19a, path19b) path19a = parse_path("""M450 25 t 0 50 t 0 50""") path19b = parse_path("""M450 25 t 0 50 0 50""") self.assertEqual(path19a, path19b) path19a = parse_path("""M400,200 A25 25 0 0 0 425 150 A25 25 0 0 0 400 200""") path19b = parse_path("""M400,200 A25 25 0 0 0 425 150 25 25 0 0 0 400 200""") self.assertEqual(path19a, path19b) path19a = parse_path("""M400,300 a25 25 0 0 0 25 -50 a25 25 0 0 0 -25 50""") path19b = parse_path("""M400,300 a25 25 0 0 0 25 -50 25 25 0 0 0 -25 50""") self.assertEqual(path19a, path19b) def test_wc3_examples20(self): """ W3C_SVG_11_TestSuite Paths Tests parsing of the elliptical arc path syntax. """ parse_path = Path path20a = parse_path("""M120,120 h25 a25,25 0 1,0 -25,25 z""") path20b = parse_path("""M120,120 h25 a25,25 0 10 -25,25z""") self.assertEqual(path20a, path20b) path20a = parse_path("""M200,120 h-25 a25,25 0 1,1 25,25 z""") path20b = parse_path("""M200,120 h-25 a25,25 0 1125,25 z""") self.assertEqual(path20a, path20b) path20a = parse_path("""M280,120 h25 a25,25 0 1,0 -25,25 z""") self.assertRaises(Exception, 'parse_path("""M280,120 h25 a25,25 0 6 0 -25,25 z""")') path20a = parse_path("""M360,120 h-25 a25,25 0 1,1 25,25 z""") self.assertRaises(Exception, 'parse_path("""M360,120 h-25 a25,25 0 1 -1 25,25 z""")') path20a = parse_path("""M120,200 h25 a25,25 0 1,1 -25,-25 z""") path20b = parse_path("""M120,200 h25 a25,25 0 1 1-25,-25 z""") self.assertEqual(path20a, path20b) path20a = parse_path("""M200,200 h-25 a25,25 0 1,0 25,-25 z""") self.assertRaises(Exception, 'parse_path("""M200,200 h-25 a25,2501 025,-25 z""")') path20a = parse_path("""M280,200 h25 a25,25 0 1,1 -25,-25 z""") self.assertRaises(Exception, 'parse_path("""M280,200 h25 a25 25 0 1 7 -25 -25 z""")') path20a = parse_path("""M360,200 h-25 a25,25 0 1,0 25,-25 z""") self.assertRaises(Exception, 'parse_path("""M360,200 h-25 a25,25 0 -1 0 25,-25 z""")') def test_others(self): # Other paths that need testing: # Relative moveto: path1 = Path('M 0 0 L 50 20 m 50 80 L 300 100 L 200 300 z') self.assertEqual(path1, Path( Move(0j), Line(0 + 0j, 50 + 20j), Move(50 + 20j, 100 + 100j), # CHANGED. Path saves the start point if it knows it. Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j))) # CHANGED. This is a Close object now. # Initial smooth and relative CubicBezier path1 = Path("""M100,200 s 150,-100 150,0""") self.assertEqual(path1, Path(Move(100 + 200j), CubicBezier(100 + 200j, 100 + 200j, 250 + 100j, 250 + 200j))) # Initial smooth and relative QuadraticBezier path1 = Path("""M100,200 t 150,0""") self.assertEqual(path1, Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j))) # Relative QuadraticBezier path1 = Path("""M100,200 q 0,0 150,0""") self.assertEqual(path1, Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j))) def test_negative(self): """You don't need spaces before a minus-sign""" path1 = Path('M100,200c10-5,20-10,30-20') path2 = Path('M 100 200 c 10 -5 20 -10 30 -20') self.assertEqual(path1, path2) def test_numbers(self): """Exponents and other number format cases""" # It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported. path1 = Path('M-3.4e38 3.4E+38L-3.4E-38,3.4e-38') path2 = Path(Move(-3.4e+38 + 3.4e+38j), Line(-3.4e+38 + 3.4e+38j, -3.4e-38 + 3.4e-38j)) self.assertEqual(path1, path2) def test_errors(self): self.assertRaises(ValueError, Path, 'M 100 100 L 200 200 Z 100 200') def test_non_path(self): # It's possible in SVG to create paths that has zero length, # we need to handle that. path = Path("M10.236,100.184") self.assertEqual(path.d(), 'M 10.236,100.184') def test_issue_47(self): arc_path_declared = Path(Move(0 + 25j), Arc(0 + 25j, 25 + 25j, 0.0, 0, 0, 0 - 25j)) arc_path_parsed = Path('M 0 25 A25,25 0.0 0 0 0,-25') arc_path_parsed_scaled = Path('M 0 25 A1,1 0.0 0 0 0,-25') self.assertEqual(arc_path_declared, arc_path_parsed) self.assertEqual(arc_path_parsed_scaled, arc_path_declared) def test_svg_parse(self): s = io.StringIO(u'''''') svg = SVG.parse(s) for e in svg.elements(): if isinstance(e, Path): self.assertEqual(e, "M0,0 L1,0 z") def test_svg_parse_group(self): s = io.StringIO(u''' ''') svg = SVG.parse(s) for e in svg.elements(): if isinstance(e, Path): self.assertEqual(e, "M0,0 L10,0 z") def test_svg_parse_group_2(self): s = io.StringIO(u'''''') svg = SVG.parse(s) for e in svg.elements(): if isinstance(e, Path): self.assertEqual(e, "M0,0 L1,0 z") def test_solo_move(self): move_only = Path("M0,0") self.assertEqual(move_only.point(0), 0 + 0j) self.assertEqual(move_only.point(0.5), 0 + 0j) self.assertEqual(move_only.point(1), 0 + 0j) self.assertEqual(move_only.length(), 0) move_onlyz = Path("M0,0Z") self.assertEqual(move_onlyz.point(0), 0 + 0j) self.assertEqual(move_onlyz.point(0.5), 0 + 0j) self.assertEqual(move_onlyz.point(1), 0 + 0j) self.assertEqual(move_onlyz.length(), 0) move_2_places = Path("M0,0M1,1") self.assertEqual(move_2_places.point(0), 0 + 0j) self.assertEqual(move_2_places.point(0.49), 0 + 0j) self.assertEqual(move_2_places.point(0.51), 1 + 1j) self.assertEqual(move_2_places.point(1), 1 + 1j) self.assertEqual(move_2_places.length(), 0) def test_fill_opacity_fill_none(self): s = io.StringIO(u'''''') svg = SVG.parse(s) for e in svg.elements(): if isinstance(e, Path): self.assertEqual(e, "M0,0 H10 V10 H0 z") self.assertEqual(e.fill, "none" ) class TestParseDisplay(unittest.TestCase): """ Tests for the parsing of displayed objects within an svg for conforming to the spec. Anything with a viewbox that has a zero width or zero height is not rendered. Any svg with a zero height or zero width is not rendered. Anything with a display="none" is not rendered whether this property comes from class, style, or direct attribute. Items with visibility="hidden" are rendered and returned but should be hidden by the end user. """ def test_svgfile(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertTrue(isinstance(q[-1], SimpleLine)) def test_svgfile_0_width(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_0_height(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_viewbox_0_height(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_viewbox_0_width(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_display_none_inline(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_display_none_attribute(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_display_mixed(self): """ All children of a display="none" are excluded, even if they override that display. """ q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) print(q) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_display_none_class(self): q = io.StringIO(u''' \n ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_display_None_class(self): """ display:None is css and not svg it is case insensitive """ q = io.StringIO(u''' \n ''') m = SVG.parse(q) q = list(m.elements()) self.assertFalse(isinstance(q[-1], SimpleLine)) def test_svgfile_visibility_hidden(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertTrue(isinstance(q[-1], SimpleLine)) # Hidden elements still exist. class TestParseDefUse(unittest.TestCase): """ Tests for Def and Use within an svg file. These must work with the definitions used within the SVG spec. This means that use objects must be replaced with their pure tree forms as if they are children of the use flag in question. """ def test_struct_use_01(self): """ The purpose of this test is to validate proper handling of the use element. In particular, the test checks the proper inheritance of properties through the shadow tree (rather than through the document tree). """ q = io.StringIO(u''' Text ''') m = SVG.parse(q) q = list(m.elements()) for element in q: try: ident = element.id fill = element.fill stroke = element.stroke if ident == "half_green": self.assertEqual('#008000', fill) # Half green rectangle within used group. elif ident == "usedLine": self.assertEqual('#0F0', stroke) elif ident == "usedPolyline": self.assertEqual('#0F0', stroke) else: self.assertEqual('#0F0', fill) # Remaining are filled green. except AttributeError: pass def test_struct_defs_ignored(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertEqual(len(q), 1) def test_struct_use_unlinked(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertEqual(len(q), 2) def test_parse_conditional_issue_114(self): import io from svgelements import SVG, Path svg_str = io.StringIO(""" """) for s in SVG.parse(svg_str).elements(conditional=lambda el: isinstance(el, Path)): self.assertEqual(type(s), Path) svgelements-1.6.8/test/test_path.py000066400000000000000000000242241417573325500174260ustar00rootroot00000000000000import unittest from svgelements import * class TestPath(unittest.TestCase): """Tests of the SVG Path element.""" def test_subpaths(self): path = Path("M0,0 50,50 100,100Z M0,100 50,50, 100,0") for i, p in enumerate(path.as_subpaths()): if i == 0: self.assertEqual(p.d(), "M 0,0 L 50,50 L 100,100 Z") elif i == 1: self.assertEqual(p.d(), "M 0,100 L 50,50 L 100,0") self.assertLessEqual(i, 1) def test_move_quad_smooth(self): path = Path() path.move((4, 4), (20, 20), (25, 25), 6 + 3j) path.quad((20, 33), (100, 100)) path.smooth_quad((13, 45), (16, 16), (34, 56), "z").closed() self.assertEqual(path.d(), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 T 13,45 T 16,16 T 34,56 T 4,4 Z") def test_move_cubic_smooth(self): path = Path() path.move((4, 4), (20, 20), (25, 25), 6 + 3j) path.cubic((20, 33), (25, 25), (100, 100)) path.smooth_cubic((13, 45), (16, 16), (34, 56), "z").closed() self.assertEqual(path.d(), "M 4,4 L 20,20 L 25,25 L 6,3 C 20,33 25,25 100,100 S 13,45 16,16 S 34,56 4,4 Z") def test_convex_hull(self): pts = (3, 4), (4, 6), (18, -2), (9, 0) hull = [e for e in Point.convex_hull(pts)] self.assertEqual([(3, 4), (9, 0), (18, -2), (4, 6)], hull) # bounding box and a bunch of random numbers that must be inside. pts = [(100, 100), (100, -100), (-100, -100), (-100, 100)] from random import randint for i in range(50): pts.append((randint(-99, 99), randint(-99, 99))) hull = [e for e in Point.convex_hull(pts)] for p in hull: self.assertEqual(abs(p[0]), 100) self.assertEqual(abs(p[1]), 100) def test_reverse_path_q(self): path = Path("M1,0 22,7 Q 17,17 91,2") path.reverse() self.assertEqual(path, Path("M 91,2 Q 17,17 22,7 L 1,0")) def test_reverse_path_multi_move(self): path = Path("M1,0 M2,0 M3,0") path.reverse() self.assertEqual(path, "M3,0 M2,0 M1,0") path = Path("M1,0z M2,0z M3,0z") path.reverse() self.assertEqual(path, "M3,0 Z M2,0 Z M1,0 Z") def test_reverse_path_multipath(self): path = Path("M1,0 22,7 Q 17,17 91,2M0,0zM20,20z") path.reverse() self.assertEqual(path, Path("M20,20zM0,0zM 91,2 Q 17,17 22,7 L 1,0")) def test_path_mult_sideeffect(self): path = Path("M1,1 10,10 Q 17,17 91,2 T 9,9 C 40,40 20,0, 9,9 S 60,50 0,0 A 25,25 -30 0,1 30,30 z") q = path * "scale(2)" self.assertEqual(path, "M1,1 10,10 Q 17,17 91,2 T 9,9 C 40,40 20,0, 9,9 S 60,50 0,0 A 25,25 -30 0,1 30,30 z") def test_subpath_imult_sideeffect(self): path = Path("M1,1 10,10 Q 17,17 91,2 T 9,9 C 40,40 20,0, 9,9 S 60,50 0,0 A 25,25 -30 0,1 30,30 zM50,50z") self.assertEqual( path, "M1,1 10,10 Q 17,17 91,2 T 9,9 C 40,40 20,0, 9,9 S 60,50 0,0 A 25,25 -30 0,1 30,30 zM50,50z") for p in path.as_subpaths(): p *= "scale(2)" self.assertEqual( path, "M 2,2 L 20,20 Q 34,34 182,4 T 18,18 C 80,80 40,0 18,18 S 120,100 0,0 A 50,50 -30 0,1 60,60 ZM100,100z") def test_subpath_reverse(self): #Issue 45 p = Path("M0,0 1,1") p.reverse() self.assertEqual(p, "M1,1 0,0") p = Path("M0,0 M1,1") p.reverse() self.assertEqual(p, "M1,1 M0,0") p = Path("M1,1 L5,5M2,1 L6,5M3,1 L7,5") subpaths = list(p.as_subpaths()) subpaths[1].reverse() self.assertEqual("M 1,1 L 5,5 M 6,5 L 2,1 M 3,1 L 7,5", str(p)) subpaths[1].reverse() self.assertEqual("M 1,1 L 5,5 M 2,1 L 6,5 M 3,1 L 7,5", str(p)) p = Path("M1,1 L5,5M2,1 L6,5ZM3,1 L7,5") subpaths = list(p.as_subpaths()) subpaths[1].reverse() self.assertEqual("M 6,5 L 2,1 Z", str(subpaths[1])) self.assertEqual("M 1,1 L 5,5 M 6,5 L 2,1 Z M 3,1 L 7,5", str(p)) p = Path("M1,1 L5,5M2,1 6,5 100,100 200,200 ZM3,1 L7,5") subpaths = list(p.as_subpaths()) subpaths[1].reverse() self.assertEqual("M 1,1 L 5,5 M 200,200 L 100,100 L 6,5 L 2,1 Z M 3,1 L 7,5", str(p)) def test_validation_delete(self): p = Path("M1,1 M2,2 M3,3 M4,4") del p[2] self.assertEqual(p, "M1,1 M2,2 M4,4") p = Path("M0,0 L 1,1 L 2,2 L 3,3 L 4,4z") del p[3] self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 4,4z") p = Path("M0,0 L 1,1 L 2,2 M 3,3 L 4,4z") del p[3] self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 4,4z") def test_validation_insert(self): p = Path("M1,1 M2,2 M4,4") p.insert(2, "M3,3") self.assertEqual(p, "M1,1 M2,2 M3,3 M4,4") p = Path("M0,0 L 1,1 L 2,2 L 4,4") p.insert(3, "L3,3") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4") def test_validation_append(self): p = Path("M1,1 M2,2 M3,3") p.append("M4,4") self.assertEqual(p, "M1,1 M2,2 M3,3 M4,4") p = Path("M0,0 L 1,1 L 2,2 L 3,3") p.append("L4,4") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4") p.append("Z") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4 Z") p = Path("M1,1 M2,2") p.append("M3,3 M4,4") self.assertEqual(p, "M1,1 M2,2 M3,3 M4,4") p = Path("M0,0 L 1,1 L 2,2") p.append("L 3,3 L4,4") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4") p.append("Z") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4 Z") def test_validation_extend(self): p = Path("M1,1 M2,2") p.extend(Path("M3,3 M4,4")) self.assertEqual(p, "M1,1 M2,2 M3,3 M4,4") p = Path("M0,0 L 1,1 L 2,2") p.extend(Path("L 3,3 L4,4")) self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4") p.extend(Path("Z")) self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4 Z") p = Path("M1,1 M2,2") p.extend("M3,3 M4,4") self.assertEqual(p, "M1,1 M2,2 M3,3 M4,4") p = Path("M0,0 L 1,1 L 2,2") p.extend("L 3,3 L4,4") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4") p.extend("Z") self.assertEqual(p, "M0,0 L 1,1 L 2,2 L 3,3 L 4,4 Z") def test_validation_setitem(self): p = Path("M1,1 M2,2 M3,3 M4,4") p[2] = Line(None, (3,3)) self.assertEqual(p, "M1,1 M2,2 L3,3 M4,4") p = Path("M0,0 L 1,1 L 2,2 L 3,3 L 4,4z") p[3] = Move(None, (3,3)) self.assertEqual(p, "M0,0 L 1,1 L 2,2 M3,3 L 4,4z") def test_validation_setitem_str(self): p = Path("M1,1 M2,2 M3,3 M4,4") p[2] = "L3,3" self.assertEqual(p, Path("M1,1 M2,2 L3,3 M4,4")) p = Path("M0,0 L 1,1 L 2,2 L 3,3 L 4,4z") p[3] = "M3,3" self.assertEqual(p, Path("M0,0 L 1,1 L 2,2 M3,3 L 4,4z")) def test_arc_start_t(self): m = Path("m 0,0 a 5.01,5.01 180 0,0 0,10 z" "m 0,0 a 65,65 180 0,0 65,66 z") for a in m: if isinstance(a, Arc): start_t = a.get_start_t() a_start = a.point_at_t(start_t) self.assertEqual(a.start, a_start) self.assertEqual(a.end, a.point_at_t(a.get_end_t())) def test_relative_roundabout(self): m = Path("m 0,0 a 5.01,5.01 180 0,0 0,10 z") self.assertEqual(m.d(), "m 0,0 a 5.01,5.01 180 0,0 0,10 z") m = Path("M0,0 1,1 z") self.assertEqual(m.d(), "M 0,0 L 1,1 z") self.assertEqual(m.d(relative=True), "m 0,0 l 1,1 z") self.assertEqual(m.d(relative=False), "M 0,0 L 1,1 Z") m = Path("M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 Q 180,167 13,45 T 16,16 T 34,56 T 4,4 z") self.assertEqual(m.d(), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 Q 180,167 13,45 T 16,16 T 34,56 T 4,4 z") self.assertEqual(m.d(smooth=False), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 Q 180,167 13,45 Q -154,-77 16,16 Q 186,109 34,56 Q -118,3 4,4 z") self.assertEqual(m.d(smooth=True), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 T 13,45 T 16,16 T 34,56 T 4,4 z") self.assertEqual(m.d(), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 Q 180,167 13,45 T 16,16 T 34,56 T 4,4 z") def test_path_z_termination(self): m = Path("M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 Z") self.assertEqual(m.d(), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 4,4 Z") m = Path("M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 T Z") self.assertEqual(m.d(), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 T 4,4 Z") m = Path("M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 T z") self.assertEqual(m.d(), "M 4,4 L 20,20 L 25,25 L 6,3 Q 20,33 100,100 T 4,4 z") m = Path("m 0,0 1,1 A 5.01,5.01 180 0,0 z") self.assertEqual(m.d(), "m 0,0 l 1,1 A 5.01,5.01 180 0,0 0,0 z") m = Path("m0,0z") self.assertEqual(m.d(), "m 0,0 z") m = Path("M0,0Lz") self.assertEqual(m.d(), "M 0,0 L 0,0 z") def test_path_setitem_slice(self): m = Path("M0,0 1,1 z") m[1:] = 'L2,2z' self.assertEqual(m.d(), "M 0,0 L 2,2 z") self.assertTrue(m._is_valid()) del m[1] self.assertEqual(m.d(), "M 0,0 z") self.assertTrue(m._is_valid()) m = Path("M0,0z") m[:] = 'M1,1z' self.assertEqual(m.d(), "M 1,1 z") self.assertTrue(m._is_valid()) m = Path("M0,0z") del m[:] self.assertEqual(m, '') self.assertTrue(m._is_valid()) m = Path("M0,0z") m[0] = "M1,1" self.assertEqual(m.d(), "M 1,1 z") self.assertTrue(m._is_valid()) m[1] = "z" self.assertTrue(m._is_valid()) m = Path("M0,0z") del m[1] self.assertEqual(m.d(), "M 0,0") self.assertTrue(m._is_valid()) m = Path("M0,0 1,1 z") m[3:] = "M5,5z" self.assertEqual(m.d(), "M 0,0 L 1,1 z M 5,5 z") self.assertTrue(m._is_valid()) m = Path("M0,0 1,1 z") m[-1:] = "M5,5z" self.assertEqual(m.d(), "M 0,0 L 1,1 M 5,5 z") self.assertTrue(m._is_valid()) m = Path("M0,0 1,1 z") def m_assign(): m[-1] = 'M5,5z' self.assertRaises(ValueError, m_assign) svgelements-1.6.8/test/test_path_dunder.py000066400000000000000000000074551417573325500207760ustar00rootroot00000000000000import unittest from svgelements import * class TestPath(unittest.TestCase): """Tests of dunder methods of the SVG Path element.""" def test_path_iadd_str(self): p1 = Path("M0,0") p1 += "z" self.assertEqual(p1, Path("M0,0z")) p1 = Path("M2,2z") p1 += "M1,1z" p1 += "M0,0z" subpaths = list(p1.as_subpaths()) self.assertEqual(str(subpaths[0]), Path("M2,2z")) self.assertEqual(str(subpaths[1]), Path("M1,1z")) self.assertEqual(str(subpaths[2]), Path("M0,0z")) def test_path_add_str(self): p1 = Path("M0,0") p2 = p1 + "z" p1 += "z" self.assertEqual(p1, p2) def test_path_radd_str(self): p1 = Path("M0,0z") p2 = "M1,1z" + p1 subpaths = list(p2.as_subpaths()) self.assertEqual(str(subpaths[0]), str(Path("M1,1z"))) self.assertEqual(str(subpaths[1]), str(Path("M0,0z"))) def test_path_iadd_segment(self): p1 = Path("M0,0") p1 += Line((0, 0), (7, 7)) p1 += "z" self.assertEqual(p1, Path("M0,0 L7,7 z")) def test_path_add_segment(self): p1 = Path("M0,0") p2 = p1 + Line((0, 0), (7, 7)) p1 += "z" p2 += "z" self.assertEqual(p1, Path("M0,0 z")) self.assertEqual(p2, Path("M0,0 L7,7 z")) def test_path_radd_segment(self): p1 = Path("L7,7") p1 = Move((0, 0)) + p1 p1 += "z" self.assertEqual(p1, Path("M0,0 L7,7 z")) def test_path_from_segment(self): p1 = Move(0) + Line(0, (7, 7)) + "z" self.assertEqual(p1, Path("M0,0 L7,7 z")) p1 = Move(0) + "L7,7" + "z" self.assertEqual(p1, Path("M0,0 L7,7 z")) p1 = Move(0) + "L7,7z" self.assertEqual(p1, Path("M0,0 L7,7 z")) def test_segment_mult_string(self): p1 = Move(0) * "translate(200,200)" self.assertEqual(p1, Move((200, 200))) p1 += "z" self.assertEqual(p1, Path("M200,200z")) def test_path_mult_string(self): p1 = Path(Move(0)) * "translate(200,200)" self.assertEqual(p1, "M200,200") p1 = Path(Move(0)).set('vector-effect', 'non-scaling-stroke') * "scale(0.5) translateX(200)" self.assertEqual(p1, "M100,0") self.assertNotEqual(p1, "M200,0") p1 = Path(Move(0)).set('vector-effect', 'non-scaling-stroke') * "translateX(200) scale(0.5)" self.assertEqual(p1, "M200,0") self.assertNotEqual(p1, "M100,0") def test_path_equals_string(self): self.assertEqual(Path("M55,55z"), "M55,55z") self.assertEqual(Path("M55 55z"), "M 55, 55z") self.assertTrue(Move(0) * "translate(55,55)" + "z" == "m 55, 55Z") self.assertTrue(Move(0) * "rotate(0.50turn,100,0)" + "z" == "M200,0z") self.assertFalse(Path(Move(0)) == "M0,0z") self.assertEqual(Path("M50,50 100,100 0,100 z").set('vector-effect', 'non-scaling-stroke') * "scale(0.1)", "M5,5 L10,10 0,10z") self.assertNotEqual(Path("M50,50 100,100 0,100 z") * "scale(0.11)", "M5,5 L10,10 0,10z") self.assertEqual( Path("M0,0 h10 v10 h-10 v-10z").set('vector-effect', 'non-scaling-stroke') * "scale(0.2) translate(-5,-5)", "M -1,-1, L1,-1, 1,1, -1,1, -1,-1 Z" ) def test_path_mult_matrix(self): p = Path("L20,20 40,40") * Matrix("Rotate(20)") self.assertEqual(p, "L11.953449549205,25.634255282232 23.906899098410,51.268510564463") p.reify() p += "L 100, 100" p += Close() self.assertEqual(p, Path("L11.953449549205,25.634255282232 23.906899098410,51.268510564463 100,100 z")) def test_partial_path(self): p1 = Path("M0,0") p2 = Path("L7,7") p3 = Path("Z") q = p1 + p2 + p3 m = Path("M0,0 7,7z") self.assertEqual(q, m) svgelements-1.6.8/test/test_path_segments.py000066400000000000000000000023101417573325500213230ustar00rootroot00000000000000import unittest from svgelements import * class TestElementLinear(unittest.TestCase): def test_linear_nearest(self): line = Line((0,0),(5,0)) r = line.closest_segment_point((17,0)) self.assertEqual(r, (5,0)) r = line.closest_segment_point((2, 2)) self.assertEqual(r, (2, 0)) class TestBoundingBox(unittest.TestCase): def test_linear_bbox(self): line = Line((0,0), (5,0)) r = line.bbox() self.assertEqual(r, (0, 0, 5, 0)) def test_qbezier_bbox(self): line = QuadraticBezier((0,0), (2,2), (5,0)) r = line.bbox() self.assertEqual(r, (0, 0, 5, 1)) def test_cbezier_bbox(self): line = CubicBezier((0,0), (2,2), (2,-2), (5,0)) r = line.bbox() for z in zip(r, (0.0, -0.5773502691896257, 5.0, 0.5773502691896257)): self.assertAlmostEqual(*z) def test_arc_bbox(self): line = Arc((0,0), (5,0), control=(2.5, 2.5)) r = line.bbox() for z in zip(r, (0.0, 0, 5.0, 2.5)): self.assertAlmostEqual(*z) def test_null_arc_bbox(self): self.assertEqual(Path("M0,0A0,0 0 0 0 0,0z").bbox(), (0,0,0,0)) svgelements-1.6.8/test/test_paths.py000066400000000000000000000727171417573325500176230ustar00rootroot00000000000000import unittest from math import sqrt, pi, cos, sin from svgelements import * tau = 2 * pi # Most of these test points are not calculated separately, as that would # take too long and be too error prone. Instead the curves have been verified # to be correct visually, by drawing them with the turtle module, with code # like this: # # import turtle # t = turtle.Turtle() # t.penup() # # for arc in (path1, path2): # p = arc.point(0) # t.goto(p.real - 500, -p.imag + 300) # t.dot(3, 'black') # t.pendown() # for x in range(1, 101): # p = arc.point(x * 0.01) # t.goto(p.real - 500, -p.imag + 300) # t.penup() # t.dot(3, 'black') # # raw_input() # # After the paths have been verified to be correct this way, the testing of # points along the paths has been added as regression tests, to make sure # nobody changes the way curves are drawn by mistake. Therefore, do not take # these points religiously. They might be subtly wrong, unless otherwise # noted. class LineTest(unittest.TestCase): def test_lines(self): # These points are calculated, and not just regression tests. line1 = Line(0j, 400 + 0j) self.assertAlmostEqual(line1.point(0), (0j)) self.assertAlmostEqual(line1.point(0.3), (120 + 0j)) self.assertAlmostEqual(line1.point(0.5), (200 + 0j)) self.assertAlmostEqual(line1.point(0.9), (360 + 0j)) self.assertAlmostEqual(line1.point(1), (400 + 0j)) self.assertAlmostEqual(line1.length(), 400) line2 = Line(400 + 0j, 400 + 300j) self.assertAlmostEqual(line2.point(0), (400 + 0j)) self.assertAlmostEqual(line2.point(0.3), (400 + 90j)) self.assertAlmostEqual(line2.point(0.5), (400 + 150j)) self.assertAlmostEqual(line2.point(0.9), (400 + 270j)) self.assertAlmostEqual(line2.point(1), (400 + 300j)) self.assertAlmostEqual(line2.length(), 300) line3 = Line(400 + 300j, 0j) self.assertAlmostEqual(line3.point(0), (400 + 300j)) self.assertAlmostEqual(line3.point(0.3), (280 + 210j)) self.assertAlmostEqual(line3.point(0.5), (200 + 150j)) self.assertAlmostEqual(line3.point(0.9), (40 + 30j)) self.assertAlmostEqual(line3.point(1), (0j)) self.assertAlmostEqual(line3.length(), 500) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual line = Line(0j, 400 + 0j) self.assertTrue(line == Line(0, 400)) self.assertTrue(line != Line(100, 400)) self.assertFalse(line == str(line)) self.assertTrue(line != str(line)) self.assertFalse(CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) == line) class CubicBezierTest(unittest.TestCase): def test_approx_circle(self): """This is a approximate circle drawn in Inkscape""" arc1 = CubicBezier( complex(0, 0), complex(0, 109.66797), complex(-88.90345, 198.57142), complex(-198.57142, 198.57142) ) self.assertAlmostEqual(arc1.point(0), (0j)) self.assertAlmostEqual(arc1.point(0.1), (-2.59896457 + 32.20931647j)) self.assertAlmostEqual(arc1.point(0.2), (-10.12330256 + 62.76392816j)) self.assertAlmostEqual(arc1.point(0.3), (-22.16418039 + 91.25500149j)) self.assertAlmostEqual(arc1.point(0.4), (-38.31276448 + 117.27370288j)) self.assertAlmostEqual(arc1.point(0.5), (-58.16022125 + 140.41119875j)) self.assertAlmostEqual(arc1.point(0.6), (-81.29771712 + 160.25865552j)) self.assertAlmostEqual(arc1.point(0.7), (-107.31641851 + 176.40723961j)) self.assertAlmostEqual(arc1.point(0.8), (-135.80749184 + 188.44811744j)) self.assertAlmostEqual(arc1.point(0.9), (-166.36210353 + 195.97245543j)) self.assertAlmostEqual(arc1.point(1), (-198.57142 + 198.57142j)) arc2 = CubicBezier( complex(-198.57142, 198.57142), complex(-109.66797 - 198.57142, 0 + 198.57142), complex(-198.57143 - 198.57142, -88.90345 + 198.57142), complex(-198.57143 - 198.57142, 0), ) self.assertAlmostEqual(arc2.point(0), (-198.57142 + 198.57142j)) self.assertAlmostEqual(arc2.point(0.1), (-230.78073675 + 195.97245543j)) self.assertAlmostEqual(arc2.point(0.2), (-261.3353492 + 188.44811744j)) self.assertAlmostEqual(arc2.point(0.3), (-289.82642365 + 176.40723961j)) self.assertAlmostEqual(arc2.point(0.4), (-315.8451264 + 160.25865552j)) self.assertAlmostEqual(arc2.point(0.5), (-338.98262375 + 140.41119875j)) self.assertAlmostEqual(arc2.point(0.6), (-358.830082 + 117.27370288j)) self.assertAlmostEqual(arc2.point(0.7), (-374.97866745 + 91.25500149j)) self.assertAlmostEqual(arc2.point(0.8), (-387.0195464 + 62.76392816j)) self.assertAlmostEqual(arc2.point(0.9), (-394.54388515 + 32.20931647j)) self.assertAlmostEqual(arc2.point(1), (-397.14285 + 0j)) arc3 = CubicBezier( complex(-198.57143 - 198.57142, 0), complex(0 - 198.57143 - 198.57142, -109.66797), complex(88.90346 - 198.57143 - 198.57142, -198.57143), complex(-198.57142, -198.57143) ) self.assertAlmostEqual(arc3.point(0), (-397.14285 + 0j)) self.assertAlmostEqual(arc3.point(0.1), (-394.54388515 - 32.20931675j)) self.assertAlmostEqual(arc3.point(0.2), (-387.0195464 - 62.7639292j)) self.assertAlmostEqual(arc3.point(0.3), (-374.97866745 - 91.25500365j)) self.assertAlmostEqual(arc3.point(0.4), (-358.830082 - 117.2737064j)) self.assertAlmostEqual(arc3.point(0.5), (-338.98262375 - 140.41120375j)) self.assertAlmostEqual(arc3.point(0.6), (-315.8451264 - 160.258662j)) self.assertAlmostEqual(arc3.point(0.7), (-289.82642365 - 176.40724745j)) self.assertAlmostEqual(arc3.point(0.8), (-261.3353492 - 188.4481264j)) self.assertAlmostEqual(arc3.point(0.9), (-230.78073675 - 195.97246515j)) self.assertAlmostEqual(arc3.point(1), (-198.57142 - 198.57143j)) arc4 = CubicBezier( complex(-198.57142, -198.57143), complex(109.66797 - 198.57142, 0 - 198.57143), complex(0, 88.90346 - 198.57143), complex(0, 0), ) self.assertAlmostEqual(arc4.point(0), (-198.57142 - 198.57143j)) self.assertAlmostEqual(arc4.point(0.1), (-166.36210353 - 195.97246515j)) self.assertAlmostEqual(arc4.point(0.2), (-135.80749184 - 188.4481264j)) self.assertAlmostEqual(arc4.point(0.3), (-107.31641851 - 176.40724745j)) self.assertAlmostEqual(arc4.point(0.4), (-81.29771712 - 160.258662j)) self.assertAlmostEqual(arc4.point(0.5), (-58.16022125 - 140.41120375j)) self.assertAlmostEqual(arc4.point(0.6), (-38.31276448 - 117.2737064j)) self.assertAlmostEqual(arc4.point(0.7), (-22.16418039 - 91.25500365j)) self.assertAlmostEqual(arc4.point(0.8), (-10.12330256 - 62.7639292j)) self.assertAlmostEqual(arc4.point(0.9), (-2.59896457 - 32.20931675j)) self.assertAlmostEqual(arc4.point(1), (0j)) def test_svg_examples(self): # M100,200 C100,100 250,100 250,200 path1 = CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j) self.assertAlmostEqual(path1.point(0), (100 + 200j)) self.assertAlmostEqual(path1.point(0.3), (132.4 + 137j)) self.assertAlmostEqual(path1.point(0.5), (175 + 125j)) self.assertAlmostEqual(path1.point(0.9), (245.8 + 173j)) self.assertAlmostEqual(path1.point(1), (250 + 200j)) # S400,300 400,200 path2 = CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j) self.assertAlmostEqual(path2.point(0), (250 + 200j)) self.assertAlmostEqual(path2.point(0.3), (282.4 + 263j)) self.assertAlmostEqual(path2.point(0.5), (325 + 275j)) self.assertAlmostEqual(path2.point(0.9), (395.8 + 227j)) self.assertAlmostEqual(path2.point(1), (400 + 200j)) # M100,200 C100,100 400,100 400,200 path3 = CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j) self.assertAlmostEqual(path3.point(0), (100 + 200j)) self.assertAlmostEqual(path3.point(0.3), (164.8 + 137j)) self.assertAlmostEqual(path3.point(0.5), (250 + 125j)) self.assertAlmostEqual(path3.point(0.9), (391.6 + 173j)) self.assertAlmostEqual(path3.point(1), (400 + 200j)) # M100,500 C25,400 475,400 400,500 path4 = CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j) self.assertAlmostEqual(path4.point(0), (100 + 500j)) self.assertAlmostEqual(path4.point(0.3), (145.9 + 437j)) self.assertAlmostEqual(path4.point(0.5), (250 + 425j)) self.assertAlmostEqual(path4.point(0.9), (407.8 + 473j)) self.assertAlmostEqual(path4.point(1), (400 + 500j)) # M100,800 C175,700 325,700 400,800 path5 = CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j) self.assertAlmostEqual(path5.point(0), (100 + 800j)) self.assertAlmostEqual(path5.point(0.3), (183.7 + 737j)) self.assertAlmostEqual(path5.point(0.5), (250 + 725j)) self.assertAlmostEqual(path5.point(0.9), (375.4 + 773j)) self.assertAlmostEqual(path5.point(1), (400 + 800j)) # M600,200 C675,100 975,100 900,200 path6 = CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j) self.assertAlmostEqual(path6.point(0), (600 + 200j)) self.assertAlmostEqual(path6.point(0.3), (712.05 + 137j)) self.assertAlmostEqual(path6.point(0.5), (806.25 + 125j)) self.assertAlmostEqual(path6.point(0.9), (911.85 + 173j)) self.assertAlmostEqual(path6.point(1), (900 + 200j)) # M600,500 C600,350 900,650 900,500 path7 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) self.assertAlmostEqual(path7.point(0), (600 + 500j)) self.assertAlmostEqual(path7.point(0.3), (664.8 + 462.2j)) self.assertAlmostEqual(path7.point(0.5), (750 + 500j)) self.assertAlmostEqual(path7.point(0.9), (891.6 + 532.4j)) self.assertAlmostEqual(path7.point(1), (900 + 500j)) # M600,800 C625,700 725,700 750,800 path8 = CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j) self.assertAlmostEqual(path8.point(0), (600 + 800j)) self.assertAlmostEqual(path8.point(0.3), (638.7 + 737j)) self.assertAlmostEqual(path8.point(0.5), (675 + 725j)) self.assertAlmostEqual(path8.point(0.9), (740.4 + 773j)) self.assertAlmostEqual(path8.point(1), (750 + 800j)) # S875,900 900,800 inversion = (750 + 800j) + (750 + 800j) - (725 + 700j) path9 = CubicBezier(750 + 800j, inversion, 875 + 900j, 900 + 800j) self.assertAlmostEqual(path9.point(0), (750 + 800j)) self.assertAlmostEqual(path9.point(0.3), (788.7 + 863j)) self.assertAlmostEqual(path9.point(0.5), (825 + 875j)) self.assertAlmostEqual(path9.point(0.9), (890.4 + 827j)) self.assertAlmostEqual(path9.point(1), (900 + 800j)) def test_length(self): # A straight line: arc = CubicBezier( complex(0, 0), complex(0, 0), complex(0, 100), complex(0, 100) ) self.assertAlmostEqual(arc.length(), 100) # A diagonal line: arc = CubicBezier( complex(0, 0), complex(0, 0), complex(100, 100), complex(100, 100) ) self.assertAlmostEqual(arc.length(), sqrt(2 * 100 * 100)) # A quarter circle arc with radius 100: kappa = 4 * (sqrt(2) - 1) / 3 # http://www.whizkidtech.redprince.net/bezier/circle/ arc = CubicBezier( complex(0, 0), complex(0, kappa * 100), complex(100 - kappa * 100, 100), complex(100, 100) ) # We can't compare with pi*50 here, because this is just an # approximation of a circle arc. pi*50 is 157.079632679 # So this is just yet another "warn if this changes" test. # This value is not verified to be correct. self.assertAlmostEqual(arc.length(), 157.1016698) # A recursive solution has also been suggested, but for CubicBezier # curves it could get a false solution on curves where the midpoint is on a # straight line between the start and end. For example, the following # curve would get solved as a straight line and get the length 300. # Make sure this is not the case. arc = CubicBezier( complex(600, 500), complex(600, 350), complex(900, 650), complex(900, 500) ) self.assertTrue(arc.length() > 300.0) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = CubicBezier(complex(600, 500), complex(600, 350), complex(900, 650), complex(900, 500)) self.assertTrue(segment == CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j)) self.assertTrue(segment != CubicBezier(600 + 501j, 600 + 350j, 900 + 650j, 900 + 500j)) self.assertTrue(segment != Line(0, 400)) def test_colinear(self): p = Path("M0,0C5,0 15,0 15,0") self.assertAlmostEqual(p.length(), 15) class QuadraticBezierTest(unittest.TestCase): def test_svg_examples(self): """These is the path in the SVG specs""" # M200,300 Q400,50 600,300 T1000,300 path1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) self.assertAlmostEqual(path1.point(0), (200 + 300j)) self.assertAlmostEqual(path1.point(0.3), (320 + 195j)) self.assertAlmostEqual(path1.point(0.5), (400 + 175j)) self.assertAlmostEqual(path1.point(0.9), (560 + 255j)) self.assertAlmostEqual(path1.point(1), (600 + 300j)) # T1000, 300 inversion = (600 + 300j) + (600 + 300j) - (400 + 50j) path2 = QuadraticBezier(600 + 300j, inversion, 1000 + 300j) self.assertAlmostEqual(path2.point(0), (600 + 300j)) self.assertAlmostEqual(path2.point(0.3), (720 + 405j)) self.assertAlmostEqual(path2.point(0.5), (800 + 425j)) self.assertAlmostEqual(path2.point(0.9), (960 + 345j)) self.assertAlmostEqual(path2.point(1), (1000 + 300j)) def test_length(self): # expected results calculated with # svg.path.segment_length(q, 0, 1, q.start, q.end, 1e-14, 20, 0) q1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) q2 = QuadraticBezier(200 + 300j, 400 + 50j, 500 + 200j) closedq = QuadraticBezier(6 + 2j, 5 - 1j, 6 + 2j) linq1 = QuadraticBezier(1, 2, 3) linq2 = QuadraticBezier(1 + 3j, 2 + 5j, -9 - 17j) nodalq = QuadraticBezier(1, 1, 1) tests = [(q1, 487.77109389525975), (q2, 379.90458193489155), (closedq, 3.1622776601683795), (linq1, 2), (linq2, 22.73335777124786), (nodalq, 0)] for q, exp_res in tests: self.assertAlmostEqual(q.length(), exp_res) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) self.assertTrue(segment == QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)) self.assertTrue(segment != QuadraticBezier(200 + 301j, 400 + 50j, 600 + 300j)) self.assertFalse(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) self.assertTrue(Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) != segment) def test_issue_61(self): p = Path('M 206.5,525 Q 162.5,583 162.5,583') self.assertAlmostEqual(p.length(), 72.80109889280519) p = Path('M 425.781 446.289 Q 410.40000000000003 373.047 410.4 373.047') self.assertAlmostEqual(p.length(), 74.83959997888816) p = Path('M 639.648 568.115 Q 606.6890000000001 507.568 606.689 507.568') self.assertAlmostEqual(p.length(), 68.93645544992873) p = Path('M 288.818 616.699 Q 301.025 547.3629999999999 301.025 547.363') self.assertAlmostEqual(p.length(), 70.40235610403947) p = Path('M 339.927 706.25 Q 243.92700000000002 806.25 243.927 806.25') self.assertAlmostEqual(p.length(), 138.6217876093077) p = Path('M 539.795 702.637 Q 548.0959999999999 803.4669999999999 548.096 803.467') self.assertAlmostEqual(p.length(), 101.17111989594662) p = Path('M 537.815 555.042 Q 570.1680000000001 499.1600000000001 570.168 499.16') self.assertAlmostEqual(p.length(), 64.57177814649368) p = Path('M 615.297 470.503 Q 538.797 694.5029999999999 538.797 694.503') self.assertAlmostEqual(p.length(), 236.70287281737836) class ArcTest(unittest.TestCase): def test_points(self): arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) self.assertAlmostEqual(arc1.center, 100 + 0j) self.assertAlmostEqual(arc1.theta, 180.0) self.assertAlmostEqual(arc1.delta, -90.0) self.assertAlmostEqual(arc1.point(0.0), (0j)) self.assertAlmostEqual(arc1.point(0.1), (1.23116594049 + 7.82172325201j)) self.assertAlmostEqual(arc1.point(0.2), (4.89434837048 + 15.4508497187j)) self.assertAlmostEqual(arc1.point(0.3), (10.8993475812 + 22.699524987j)) self.assertAlmostEqual(arc1.point(0.4), (19.0983005625 + 29.3892626146j)) self.assertAlmostEqual(arc1.point(0.5), (29.2893218813 + 35.3553390593j)) self.assertAlmostEqual(arc1.point(0.6), (41.2214747708 + 40.4508497187j)) self.assertAlmostEqual(arc1.point(0.7), (54.6009500260 + 44.5503262094j)) self.assertAlmostEqual(arc1.point(0.8), (69.0983005625 + 47.5528258148j)) self.assertAlmostEqual(arc1.point(0.9), (84.3565534960 + 49.3844170298j)) self.assertAlmostEqual(arc1.point(1.0), (100 + 50j)) arc2 = Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j) self.assertAlmostEqual(arc2.center, 50j) self.assertAlmostEqual(arc2.theta, 270.0) self.assertAlmostEqual(arc2.delta, -270.0) self.assertAlmostEqual(arc2.point(0.0), (0j)) self.assertAlmostEqual(arc2.point(0.1), (-45.399049974 + 5.44967379058j)) self.assertAlmostEqual(arc2.point(0.2), (-80.9016994375 + 20.6107373854j)) self.assertAlmostEqual(arc2.point(0.3), (-98.7688340595 + 42.178276748j)) self.assertAlmostEqual(arc2.point(0.4), (-95.1056516295 + 65.4508497187j)) self.assertAlmostEqual(arc2.point(0.5), (-70.7106781187 + 85.3553390593j)) self.assertAlmostEqual(arc2.point(0.6), (-30.9016994375 + 97.5528258148j)) self.assertAlmostEqual(arc2.point(0.7), (15.643446504 + 99.3844170298j)) self.assertAlmostEqual(arc2.point(0.8), (58.7785252292 + 90.4508497187j)) self.assertAlmostEqual(arc2.point(0.9), (89.1006524188 + 72.699524987j)) self.assertAlmostEqual(arc2.point(1.0), (100 + 50j)) arc3 = Arc(0j, 100 + 50j, 0, 0, 1, 100 + 50j) self.assertAlmostEqual(arc3.center, 50j) self.assertAlmostEqual(arc3.theta, 270.0) self.assertAlmostEqual(arc3.delta, 90.0) self.assertAlmostEqual(arc3.point(0.0), (0j)) self.assertAlmostEqual(arc3.point(0.1), (15.643446504 + 0.615582970243j)) self.assertAlmostEqual(arc3.point(0.2), (30.9016994375 + 2.44717418524j)) self.assertAlmostEqual(arc3.point(0.3), (45.399049974 + 5.44967379058j)) self.assertAlmostEqual(arc3.point(0.4), (58.7785252292 + 9.54915028125j)) self.assertAlmostEqual(arc3.point(0.5), (70.7106781187 + 14.6446609407j)) self.assertAlmostEqual(arc3.point(0.6), (80.9016994375 + 20.6107373854j)) self.assertAlmostEqual(arc3.point(0.7), (89.1006524188 + 27.300475013j)) self.assertAlmostEqual(arc3.point(0.8), (95.1056516295 + 34.5491502813j)) self.assertAlmostEqual(arc3.point(0.9), (98.7688340595 + 42.178276748j)) self.assertAlmostEqual(arc3.point(1.0), (100 + 50j)) arc4 = Arc(0j, 100 + 50j, 0, 1, 1, 100 + 50j) self.assertAlmostEqual(arc4.center, 100 + 0j) self.assertAlmostEqual(arc4.theta, 180.0) self.assertAlmostEqual(arc4.delta, 270.0) self.assertAlmostEqual(arc4.point(0.0), (0j)) self.assertAlmostEqual(arc4.point(0.1), (10.8993475812 - 22.699524987j)) self.assertAlmostEqual(arc4.point(0.2), (41.2214747708 - 40.4508497187j)) self.assertAlmostEqual(arc4.point(0.3), (84.3565534960 - 49.3844170298j)) self.assertAlmostEqual(arc4.point(0.4), (130.901699437 - 47.5528258148j)) self.assertAlmostEqual(arc4.point(0.5), (170.710678119 - 35.3553390593j)) self.assertAlmostEqual(arc4.point(0.6), (195.105651630 - 15.4508497187j)) self.assertAlmostEqual(arc4.point(0.7), (198.768834060 + 7.82172325201j)) self.assertAlmostEqual(arc4.point(0.8), (180.901699437 + 29.3892626146j)) self.assertAlmostEqual(arc4.point(0.9), (145.399049974 + 44.5503262094j)) self.assertAlmostEqual(arc4.point(1.0), (100 + 50j)) def test_length(self): # I'll test the length calculations by making a circle, in two parts. arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) arc1_length = arc1.length(1e-5, 4) arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) self.assertAlmostEqual(arc1.length(), pi * 100) self.assertAlmostEqual(arc2.length(), pi * 100) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) self.assertTrue(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) self.assertTrue(segment != Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j)) def test_issue25(self): # This raised a math domain error Arc((725.307482225571 - 915.5548199281527j), (202.79421639137703 + 148.77294617167183j), 225.6910319606926, 1, 1, (-624.6375539637027 + 896.5483089399895j)) class TestPath(unittest.TestCase): def test_circle(self): arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) path = Path(arc1, arc2) self.assertAlmostEqual(path.point(0.0), (0j)) self.assertAlmostEqual(path.point(0.25), (100 + 100j)) self.assertAlmostEqual(path.point(0.5), (200 + 0j)) self.assertAlmostEqual(path.point(0.75), (100 - 100j)) self.assertAlmostEqual(path.point(1.0), (0j)) self.assertAlmostEqual(path.length(), pi * 200) def test_svg_specs_big_pie(self): """The paths that are in the SVG specs""" # Big pie: M300,200 h-150 a150,150 0 1,0 150,-150 z path = Path(Line(300 + 200j, 150 + 200j), Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), Line(300 + 50j, 300 + 200j)) # The points and length for this path are calculated and not regression tests. self.assertAlmostEqual(path.point(0.0), (300 + 200j)) self.assertAlmostEqual(path.point(0.14897825542), (150 + 200j)) self.assertAlmostEqual(path.point(0.5), (406.066017177 + 306.066017177j)) self.assertAlmostEqual(path.point(1 - 0.14897825542), (300 + 50j)) self.assertAlmostEqual(path.point(1.0), (300 + 200j)) # The errors seem to accumulate. Still 6 decimal places is more than good enough. self.assertAlmostEqual(path.length(), pi * 225 + 300, places=6) def test_svg_specs_little_pie(self): # Little pie: M275,175 v-150 a150,150 0 0,0 -150,150 z path = Path(Line(275 + 175j, 275 + 25j), Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), Line(125 + 175j, 275 + 175j)) # The points and length for this path are calculated and not regression tests. self.assertAlmostEqual(path.point(0.0), (275 + 175j)) self.assertAlmostEqual(path.point(0.2800495767557787), (275 + 25j)) self.assertAlmostEqual(path.point(0.5), (168.93398282201787 + 68.93398282201787j)) self.assertAlmostEqual(path.point(1 - 0.2800495767557787), (125 + 175j)) self.assertAlmostEqual(path.point(1.0), (275 + 175j)) # The errors seem to accumulate. Still 6 decimal places is more than good enough. self.assertAlmostEqual(path.length(), pi * 75 + 300, places=6) # Some of these were regression tested with a broken radius function. # def test_svg_specs_bumpy_path(self): # Bumpy path: M600,350 l 50,-25 # a25,25 -30 0,1 50,-25 l 50,-25 # a25,50 -30 0,1 50,-25 l 50,-25 # a25,75 -30 0,1 50,-25 l 50,-25 # a25,100 -30 0,1 50,-25 l 50,-25 # path = Path(Line(600 + 350j, 650 + 325j), # Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), # Line(700 + 300j, 750 + 275j), # Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), # Line(800 + 250j, 850 + 225j), # Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), # Line(900 + 200j, 950 + 175j), # Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), # Line(1000 + 150j, 1050 + 125j), # ) # These are *not* calculated, but just regression tests. Be skeptical. # self.assertAlmostEqual(path.point(0.0), (600 + 350j)) # self.assertAlmostEqual(path.point(0.3), (755.31526434 + 217.51578768j)) # self.assertAlmostEqual(path.point(0.5), (832.23324151 + 156.33454892j)) # self.assertAlmostEqual(path.point(0.9), (974.00559321 + 115.26473532j)) # self.assertAlmostEqual(path.point(1.0), (1050 + 125j)) # The errors seem to accumulate. Still 6 decimal places is more than good enough. # self.assertAlmostEqual(path.length(), 860.6756221710) def test_point_in_arc_path(self): from math import cos, sin, pi tau = 2 * pi for angle in range(-180, 180, 60): arc = Arc(0 + 25j, 25 + 25j, angle, 0, 0, 0 - 25j) path = Path(arc) v = 5 for i in range(v+1): x = sin(i * tau / (2.0 * float(v))) * 25 y = cos(i * tau / (2.0 * float(v))) * 25 p = i / float(v) point = path.point(p) self.assertAlmostEqual(point.real, x, places=6) self.assertAlmostEqual(point.imag, y, places=6) self.assertEqual(arc.point(p), point) def test_point_in_arc(self): from math import cos, sin, pi tau = 2 * pi arc = Arc(0 + 25j, 25 + 25j, 0.0, 0, 0, 0 - 25j) for i in range(100): x = sin(i * tau / 200) * 25 y = cos(i * tau / 200) * 25 p = i / 100.0 point = arc.point(p) self.assertAlmostEqual(point.real, x, places=7) # 7th place wrong. self.assertAlmostEqual(point.imag, y, places=7) def test_point_in_arc_rotated(self): arc = Arc(0 + 25j, 25 + 25j, 90.0, 0, 0, 0 - 25j) for i in range(100): x = sin(i * tau / 200) * 25 y = cos(i * tau / 200) * 25 p = i / 100.0 point = arc.point(p) self.assertAlmostEqual(point.real, x, places=7) # 7th place wrong. self.assertAlmostEqual(point.imag, y, places=7) def test_repr(self): path = Path( Line(start=600 + 350j, end=650 + 325j), Arc(start=650 + 325j, radius=25 + 25j, rotation=-30, arc_flag=0, sweep_flag=1, end=700 + 300j), CubicBezier(start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j)) self.assertEqual(eval(repr(path)), path) def test_reverse(self): # Currently you can't reverse paths. # self.assertRaises(NotImplementedError, Path().reverse) pass def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual path1 = Path( Line(start=600 + 350j, end=650 + 325j), Arc(start=650 + 325j, radius=25 + 25j, rotation=-30, arc_flag=0, sweep_flag=1, end=700 + 300j), CubicBezier(start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j)) path2 = Path( Line(start=600 + 350j, end=650 + 325j), Arc(start=650 + 325j, radius=25 + 25j, rotation=-30, arc_flag=0, sweep_flag=1, end=700 + 300j), CubicBezier(start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j)) self.assertTrue(path1 == path2) # Modify path2: path2[0].start = 601 + 350j self.assertTrue(path1 != path2) # Modify back: path2[0].start = 600 + 350j self.assertFalse(path1 != path2) # Get rid of the last segment: del path2[-1] self.assertFalse(path1 == path2) # It's not equal to a list of its segments self.assertTrue(path1 != path1[:]) self.assertFalse(path1 == path1[:]) def test_non_arc(self): # And arc with the same start and end is a noop. segment = Arc(0 + 70j, 35 + 35j, 0, 1, 0, 0 + 70j) self.assertEqual(segment.length(), 0) self.assertEqual(segment.point(0.5), segment.start) svgelements-1.6.8/test/test_point.py000066400000000000000000000131001417573325500176120ustar00rootroot00000000000000import unittest from random import random from svgelements import * class TestElementPoint(unittest.TestCase): def test_point_init_string(self): p = Point("(0,24)") self.assertEqual(p, (0, 24)) self.assertEqual(p, 0 + 24j) self.assertEqual(p, [0, 24]) self.assertEqual(p, "(0,24)") def test_polar_angle(self): for i in range(1000): p = Point(random() * 50, random() * 50) a = random() * tau - tau / 2 r = random() * 50 m = Point.polar(p, a, r) self.assertAlmostEqual(Point.angle(p, m), a) def test_not_equal_unparsed(self): self.assertNotEqual(Point(0, 0), "string that doesn't parse to point") def test_dunder_iadd(self): p = Point(0) p += (1, 0) self.assertEqual(p, (1, 0)) p += Point(1, 1) self.assertEqual(p, (2, 1)) p += 1 + 2j self.assertEqual(p, (3, 3)) class c: def __init__(self): self.x = 1 self.y = 1 p += c() self.assertEqual(p, (4, 4)) p += Point("-4,-4") self.assertEqual(p, (0, 0)) p += 1 self.assertEqual(p, (1, 0)) self.assertRaises(TypeError, 'p += "hello"') def test_dunder_isub(self): p = Point(0) p -= (1, 0) self.assertEqual(p, (-1, 0)) p -= Point(1, 1) self.assertEqual(p, (-2, -1)) p -= 1 + 2j self.assertEqual(p, (-3, -3)) class c: def __init__(self): self.x = 1 self.y = 1 p -= c() self.assertEqual(p, (-4, -4)) p -= Point("-4,-4") self.assertEqual(p, (0, 0)) p -= 1 self.assertEqual(p, (-1, 0)) r = p - 1 self.assertEqual(r, (-2, 0)) self.assertRaises(TypeError, 'p -= "hello"') def test_dunder_add(self): p = Point(0) p = p + (1, 0) self.assertEqual(p, (1, 0)) p = p + Point(1, 1) self.assertEqual(p, (2, 1)) p = p + 1 + 2j self.assertEqual(p, (3, 3)) class c: def __init__(self): self.x = 1 self.y = 1 p = p + c() self.assertEqual(p, (4, 4)) p = p + Point("-4,-4") self.assertEqual(p, (0, 0)) p = p + 1 self.assertEqual(p, (1, 0)) self.assertRaises(TypeError, 'p = p + "hello"') def test_dunder_sub(self): p = Point(0) p = p - (1, 0) self.assertEqual(p, (-1, 0)) p = p - Point(1, 1) self.assertEqual(p, (-2, -1)) p = p - (1 + 2j) self.assertEqual(p, (-3, -3)) class c: def __init__(self): self.x = 1 self.y = 1 p = p - c() self.assertEqual(p, (-4, -4)) p = p - Point("-4,-4") self.assertEqual(p, (0, 0)) p = p - 1 self.assertEqual(p, (-1, 0)) self.assertRaises(TypeError, 'p = p - "hello"') def test_dunder_rsub(self): p = Point(0) p = (1, 0) - p self.assertEqual(p, (1, 0)) p = Point(1, 1) - p self.assertEqual(p, (0, 1)) p = (1 + 2j) - p self.assertEqual(p, (1, 1)) class c: def __init__(self): self.x = 1 self.y = 1 p = c() - p self.assertEqual(p, (0, 0)) p = Point("-4,-4") - p self.assertEqual(p, (-4, -4)) p = 1 - p self.assertEqual(p, (5, 4)) self.assertRaises(TypeError, 'p = "hello" - p') def test_dunder_mult(self): """ For backwards compatibility multiplication of points works like multiplication of complex variables. :return: """ p = Point(2, 2) p *= (1, 0) self.assertEqual(p, (2, 2)) p *= Point(1, 1) self.assertEqual(p, (0, 4)) p *= 1 + 2j self.assertEqual(p, (-8, 4)) class c: def __init__(self): self.x = 1 self.y = 1 p *= c() self.assertEqual(p, (-12, -4)) p *= Point("-4,-4") self.assertEqual(p, (32, 64)) p *= 1 self.assertEqual(p, (32, 64)) r = p * 1 self.assertEqual(r, (32, 64)) r *= "scale(0.1)" self.assertEqual(r, (3.2, 6.4)) def test_dunder_transform(self): p = Point(4, 4) m = Matrix("scale(4)") p.matrix_transform(m) self.assertEqual(p, (16, 16)) def test_move_towards(self): p = Point(4, 4) p.move_towards((6, 6), 0.5) self.assertEqual(p, (5, 5)) def test_distance_to(self): p = Point(4, 4) m = p.distance_to((6, 6)) self.assertEqual(m, 2 * sqrt(2)) m = p.distance_to(4) self.assertEqual(m, 4) def test_angle_to(self): p = Point(0) a = p.angle_to((3, 3)) self.assertEqual(a, Angle.parse("45deg")) a = p.angle_to((0, 3)) self.assertEqual(a, Angle.parse("0.25turn")) a = p.angle_to((-3, 0)) self.assertEqual(a, Angle.parse("200grad")) def test_polar(self): p = Point(0) q = p.polar_to(Angle.parse("45deg"), 10) self.assertEqual(q, (sqrt(2)/2 * 10, sqrt(2)/2 * 10)) def test_reflected_across(self): p = Point(0) r = p.reflected_across((10,10)) self.assertEqual(r, (20,20))svgelements-1.6.8/test/test_quadratic_bezier.py000066400000000000000000000022611417573325500220040ustar00rootroot00000000000000import unittest from random import * from svgelements import * def get_random_quadratic_bezier(): return QuadraticBezier((random() * 50, random() * 50), (random() * 50, random() * 50), (random() * 50, random() * 50)) class TestElementQuadraticBezierPoint(unittest.TestCase): def test_quadratic_bezier_point_start_stop(self): import numpy as np for _ in range(1000): b = get_random_quadratic_bezier() self.assertEqual(b.start, b.point(0)) self.assertEqual(b.end, b.point(1)) self.assertTrue(np.all(np.array([list(b.start), list(b.end)]) == b.npoint([0, 1]))) def test_quadratic_bezier_point_implementations_match(self): import numpy as np for _ in range(1000): b = get_random_quadratic_bezier() pos = np.linspace(0, 1, 100) v1 = b.npoint(pos) v2 = [] for i in range(len(pos)): v2.append(b.point(pos[i])) for p, p1, p2 in zip(pos, v1, v2): self.assertEqual(b.point(p), Point(p1)) self.assertEqual(Point(p1), Point(p2)) svgelements-1.6.8/test/test_repr.py000066400000000000000000000174611417573325500174470ustar00rootroot00000000000000import io import unittest from svgelements import * class TestElementsRepr(unittest.TestCase): """Tests the functionality of the repr for elements.""" def test_repr_length(self): obj = Length("10cm") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_color(self): obj = Color("red") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_point(self): obj = Point("20.3,3.1615926535") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_angle(self): obj = Angle.parse("1.1turn") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertAlmostEqual(obj, obj2) def test_repr_matrix(self): obj = Matrix("rotate(20)") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_viewbox(self): obj = Viewbox("0 0 100 60") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_move(self): obj = Move(0.1, 50) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_close(self): obj = Close(0.1, 50) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_line(self): obj = Line(start=(0.2, 0.99), end=(0.1, 22.9996)) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Line(end=(0.1, 22.9996)) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_qbez(self): obj = QuadraticBezier(start=(0.2, 0.99), control=(-3,-3), end=(0.1, 22.9996)) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_cbez(self): obj = CubicBezier(start=(0.2, 0.99), control1=(-3, -3), control2=(-4, -4), end=(0.1, 22.9996)) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_arc(self): obj = Arc(start=(0,0), end=(0,100), control=(50,50)) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_path(self): obj = Path("M0,0Z") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Path("M0,0L100,100Z") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Path("M0,0L100,100Z", transform="scale(4)") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_rect(self): obj = Rect(x=100, y=100, width=500, height=500) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Rect(x=100, y=100, width=500, height=500, transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_ellipse(self): obj = Ellipse(cx=100, cy=100, rx=500, ry=500) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Ellipse(cx=100, cy=100, rx=500, ry=500, transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_circle(self): obj = Circle(cx=100, cy=100, r=500) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Circle(cx=100, cy=100, r=500, transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_simpleline(self): obj = SimpleLine(start=(0,0), end=(100,100)) repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = SimpleLine(start=(0, 0), end=(100, 100), transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_polyline(self): obj = Polyline("0,0 7,7 10,10 0 20") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Polyline("0,0 7,7 10,10 0 20", transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_polygon(self): obj = Polygon("0,0 7,7 10,10 0 20") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Polygon("0,0 7,7 10,10 0 20", transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_group(self): obj = Group() repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = Group(transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_clippath(self): obj = ClipPath() repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_pattern(self): obj = Pattern() repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_text(self): obj = SVGText(x=0, y=0, text="Hello") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) obj = SVGText(x=0, y=0, text="Hello", transform="scale(2)", stroke="red", fill="blue") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_image(self): obj = SVGImage(href="test.png", transform="scale(2)") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_desc(self): obj = Desc("Describes Object") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) def test_repr_title(self): obj = Title(title="SVG Description") repr_obj = repr(obj) obj2 = eval(repr_obj) self.assertTrue(obj == obj2) self.assertFalse(obj != obj2) svgelements-1.6.8/test/test_shape.py000066400000000000000000000555771417573325500176110ustar00rootroot00000000000000import unittest import io from svgelements import * class TestElementShape(unittest.TestCase): def test_rect_dict(self): values = { 'tag': 'rect', 'rx': "4", 'ry': "2", 'x': "50", 'y': "51", 'width': "20", 'height': "10" } e = Rect(values) e2 = Rect(50, 51, 20, 10, 4, 2) self.assertEqual(e, e2) e2 *= "translate(2)" e3 = Rect() self.assertNotEqual(e, e3) def test_line_dict(self): values = { 'tag': 'rect', 'x1': "0", 'y1': "0", 'x2': "100", 'y2': "100" } e = SimpleLine(values) e2 = SimpleLine(0, '0px', '100px', '100px') e3 = SimpleLine(0, 0, 100, 100) self.assertEqual(e, e2) self.assertEqual(e, e3) e4 = SimpleLine() self.assertNotEqual(e, e4) def test_ellipse_dict(self): values = { 'tag': 'ellipse', 'rx': "4.0", 'ry': "8.0", 'cx': "22.4", 'cy': "33.33" } e = Ellipse(values) e2 = Ellipse(22.4, 33.33, 4, 8) self.assertEqual(e, e2) e3 = Ellipse() self.assertNotEqual(e, e3) def test_circle_dict(self): values = { 'tag': 'circle', 'r': "4.0", 'cx': "22.4", 'cy': "33.33" } e = Circle(values) e2 = Circle(22.4, 33.33, 4) self.assertEqual(e, e2) e3 = Circle() self.assertNotEqual(e, e3) circle_d = e.d() self.assertEqual(Path(circle_d), 'M26.4,33.33A4,4 0 0,1 22.4,37.33 A4,4 0 0,1 18.4,33.33 A4,4 0 0,1 22.4,29.33 A4,4 0 0,1 26.4,33.33Z') def test_polyline_dict(self): values = { 'tag': 'polyline', 'points': '0,100 50,25 50,75 100,0', } e = Polyline(values) e2 = Polyline(0, 100, 50, 25, 50, 75, 100, 0) self.assertEqual(e, e2) e3 = Polyline() self.assertNotEqual(e, e3) polyline_d = e.d() self.assertEqual(Path(polyline_d), "M 0,100 L 50,25 L 50,75 L 100,0") def test_polygon_dict(self): values = { 'tag': 'polyline', 'points': '0,100 50,25 50,75 100,0', } e = Polygon(values) e2 = Polygon(0, 100, 50, 25, 50, 75, 100, 0) self.assertEqual(e, e2) e3 = Polygon() self.assertNotEqual(e, e3) polygon_d = e.d() self.assertEqual(Path(polygon_d), 'M 0,100 L 50,25 L 50,75 L 100,0 Z') def test_circle_ellipse_equal(self): self.assertTrue(Ellipse(center=(0, 0), rx=10, ry=10) == Circle(center="0,0", r=10.0)) def test_transform_circle_to_ellipse(self): c = Circle(center="0,0", r=10.0) p = c * Matrix.skew_x(Angle.degrees(50)) p.reify() p = c * "translate(10,1)" p.reify() p = c * "scale(10,1)" p.reify() p = c * "rotate(10deg)" p.reify() p = c * "skewy(10)" p.reify() self.assertFalse(isinstance(Circle(), Ellipse)) self.assertFalse(isinstance(Ellipse(), Circle)) def test_circle_decomp(self): circle = Circle() c = Path(circle.d()) self.assertEqual(c, "M 1,0 A 1,1 0 0,1 0,1 A 1,1 0 0,1 -1,0 A 1,1 0 0,1 0,-1 A 1,1 0 0,1 1,0 Z") circle *= "scale(2,1)" c = Path(circle.d()) self.assertEqual(c, "M 2,0 A 2,1 0 0,1 0,1 A 2,1 0 0,1 -2,0 A 2,1 0 0,1 0,-1 A 2,1 0 0,1 2,0 Z") circle *= "scale(0.5,1)" c = Path(circle.d()) self.assertEqual(c, "M 1,0 A 1,1 0 0,1 0,1 A 1,1 0 0,1 -1,0 A 1,1 0 0,1 0,-1 A 1,1 0 0,1 1,0 Z") def test_circle_implicit(self): shape = Circle() shape *= "translate(40,40) rotate(15deg) scale(2,1.5)" self.assertAlmostEqual(shape.implicit_rx, 2.0) self.assertAlmostEqual(shape.implicit_ry, 1.5) self.assertAlmostEqual(shape.rotation, Angle.degrees(15)) self.assertEqual(shape.implicit_center, (40, 40)) def test_rect_implicit(self): shape = Rect() shape *= "translate(40,40) rotate(15deg) scale(2,1.5)" self.assertAlmostEqual(shape.implicit_x, 40) self.assertAlmostEqual(shape.implicit_y, 40) self.assertAlmostEqual(shape.implicit_width, 2) self.assertAlmostEqual(shape.implicit_height, 1.5) self.assertAlmostEqual(shape.implicit_rx, 0) self.assertAlmostEqual(shape.implicit_ry, 0) self.assertAlmostEqual(shape.rotation, Angle.degrees(15)) def test_line_implicit(self): shape = SimpleLine(0, 0, 1, 1) shape *= "translate(40,40) rotate(15deg) scale(2,1.5)" self.assertAlmostEqual(shape.implicit_x1, 40) self.assertAlmostEqual(shape.implicit_y1, 40) p = Point(1, 1) * "rotate(15deg) scale(2,1.5)" self.assertAlmostEqual(shape.implicit_x2, 40 + p[0]) self.assertAlmostEqual(shape.implicit_y2, 40 + p[1]) self.assertAlmostEqual(shape.rotation, Angle.degrees(15)) def test_circle_equals_transformed_circle(self): shape1 = Circle(r=2) shape2 = Circle().set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) shape2.reify() self.assertEqual(shape1, shape2) def test_rect_equals_transformed_rect(self): shape1 = Rect(x=0, y=0, width=2, height=2) shape2 = Rect(0, 0, 1, 1).set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) shape2.reify() self.assertEqual(shape1, shape2) def test_rrect_equals_transformed_rrect(self): shape1 = Rect(0, 0, 2, 2, 1, 1) shape2 = Rect(0, 0, 1, 1, 0.5, 0.5).set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) shape2.reify() self.assertEqual(shape1, shape2) def test_line_equals_transformed_line(self): shape1 = SimpleLine(0, 0, 2, 2) shape2 = SimpleLine(0, 0, 1, 1).set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) shape2.reify() self.assertEqual(shape1, shape2) def test_polyline_equals_transformed_polyline(self): shape1 = Polyline(0, 0, 2, 2) shape2 = Polyline(0, 0, 1, 1).set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) shape2.reify() self.assertEqual(shape1, shape2) def test_polygon_equals_transformed_polygon(self): shape1 = Polyline(0, 0, 2, 2) shape2 = Polyline(0, 0, 1, 1).set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) shape2.reify() self.assertEqual(shape1, shape2) def test_polyline_not_equal_transformed_polygon(self): shape1 = Polyline(0, 0, 2, 2) shape2 = Polygon(0, 0, 1, 1) * "scale(2)" self.assertNotEqual(shape1, shape2) def test_polyline_closed_equals_transformed_polygon(self): shape1 = Path(Polyline(0, 0, 2, 2)) + "z" shape2 = Polygon(0, 0, 1, 1).set('vector-effect', 'non-scaling-stroke') * "scale(2)" self.assertEqual(shape1, shape2) def test_path_plus_shape(self): path = Path("M 0,0 z") path += Rect(0, 0, 1, 1) self.assertEqual(path, "M0,0zM0,0h1v1h-1z") def test_circle_not_equal_red_circle(self): shape1 = Circle() shape2 = Circle(stroke="red") self.assertNotEqual(shape1, shape2) shape1 = Circle() shape2 = Circle(fill="red") self.assertNotEqual(shape1, shape2) def test_rect_initialize(self): shapes = ( Rect(), Rect(0), Rect(0, 0), Rect(0, 0, 1), Rect(0, 0, 1, 1), Rect(0, y=0), Rect(0, y=0, width=1), Rect(0, y=0, width=1, height=1), Rect(width=1, height=1, x=0, y=0), Rect(0, 0, 1, 1, 0, 0), Rect(0, 0, 1, 1, rx=0, ry=0) ) for s in shapes: self.assertEqual(shapes[0], s) def test_circle_initialize(self): shapes = ( Circle(), Circle(0, 0), Circle(center=(0, 0), r=1), Circle("0px", "0px", 1), Ellipse("0", "0", 1, 1), Ellipse("0", "0", rx=1, ry=1), Ellipse(0, 0, 1, ry=1), Circle(Circle()), Circle({"cx": 0, "cy": 0, "r": 1}), Ellipse({"cx": 0, "cy": 0, "rx": 1}), Ellipse({"cx": 0, "cy": 0, "ry": 1}), Ellipse({"cx": 0, "cy": 0, "rx": 1, "ry": 1.0}), Circle(Ellipse()), Ellipse(Circle()) ) for s in shapes: self.assertEqual(shapes[0], s) def test_polyline_initialize(self): shapes = ( Polyline(0, 0, 1, 1), Polyline((0, 0), (1, 1)), Polyline(points=((0, 0), (1, 1))), Polyline("0,0", "1,1"), Polyline("0,0", (1, 1)), Polyline("0,0", Point(1, 1)), Polyline({"points": "0,0,1,1"}), Polyline(Polyline(0, 0, 1, 1)), Path("M0,0L1,1"), SimpleLine(0, 0, 1, 1), ) for s in shapes: self.assertEqual(shapes[0], s) def test_polygon_initialize(self): shapes = ( Polygon(0, 0, 1, 1), Polygon((0, 0), (1, 1)), Polygon(points=((0, 0), (1, 1))), Polygon("0,0", "1,1"), Polygon("0,0", (1, 1)), Polygon("0,0", Point(1, 1)), Polygon({"points": "0,0,1,1"}), Polygon(Polyline(0, 0, 1, 1)), Polygon("0,0,1,1"), Path("M0,0L1,1z"), ) for s in shapes: self.assertEqual(shapes[0], s) def test_shapes_repr(self): s = Rect(fill='red') self.assertEqual(repr(s), "Rect(width=1, height=1, fill='#ff0000')") s = Ellipse(fill='red') self.assertEqual(repr(s), "Ellipse(cx=0, cy=0, r=1, fill='#ff0000')") s = Circle(fill='red') self.assertEqual(repr(s), "Circle(cx=0, cy=0, r=1, fill='#ff0000')") s = SimpleLine(fill='red') self.assertEqual(repr(s), "SimpleLine(x1=0.0, y1=0.0, x2=0.0, y2=0.0, fill='#ff0000')") s = Polygon(fill='red') self.assertEqual(repr(s), "Polygon(points='', fill='#ff0000')") s = Polyline(fill='red') self.assertEqual(repr(s), "Polyline(points='', fill='#ff0000')") s = Path(fill='red') self.assertEqual(repr(s), "Path(fill='#ff0000')") def test_shape_bbox(self): s = Rect() * 'scale(20)' self.assertEqual(s.bbox(False), (0, 0, 1, 1)) self.assertEqual(s.bbox(True), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(False), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(True), (0, 0, 1, 1)) s = Circle() * 'scale(20)' self.assertEqual(s.bbox(False), (-1, -1, 1, 1)) self.assertEqual(s.bbox(True), (-20, -20, 20, 20)) self.assertNotEqual(s.bbox(False), (-20, -20, 20, 20)) self.assertNotEqual(s.bbox(True), (-1, -1, 1, 1)) s = Ellipse() * 'scale(20)' self.assertEqual(s.bbox(False), (-1, -1, 1, 1)) self.assertEqual(s.bbox(True), (-20, -20, 20, 20)) self.assertNotEqual(s.bbox(False), (-20, -20, 20, 20)) self.assertNotEqual(s.bbox(True), (-1, -1, 1, 1)) s = Polygon() * 'scale(20)' self.assertEqual(s.bbox(False), None) self.assertEqual(s.bbox(True), None) self.assertNotEqual(s.bbox(False), (0, 0, 0, 0)) self.assertNotEqual(s.bbox(True), (0, 0, 0, 0)) s = Polyline() * 'scale(20)' self.assertEqual(s.bbox(False), None) self.assertEqual(s.bbox(True), None) self.assertNotEqual(s.bbox(False), (0, 0, 0, 0)) self.assertNotEqual(s.bbox(True), (0, 0, 0, 0)) s = Polygon("0,0 0,1 1,1 1,0 0,0") * 'scale(20)' self.assertEqual(s.bbox(False), (0, 0, 1, 1)) self.assertEqual(s.bbox(True), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(False), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(True), (0, 0, 1, 1)) s = Polyline("0,0 0,1 1,1 1,0 0,0") * 'scale(20)' self.assertEqual(s.bbox(False), (0, 0, 1, 1)) self.assertEqual(s.bbox(True), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(False), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(True), (0, 0, 1, 1)) s = SimpleLine(0, 0, 1, 1) * 'scale(20)' self.assertEqual(s.bbox(False), (0, 0, 1, 1)) self.assertEqual(s.bbox(True), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(False), (0, 0, 20, 20)) self.assertNotEqual(s.bbox(True), (0, 0, 1, 1)) def test_rect_rot_equal_rect_path_rotate(self): r = Rect(10, 10, 8, 4) a = r.d() b = Path(a).d() self.assertEqual(a, b) a = (Path(r.d()) * "rotate(0.5turns)").d() b = (r * "rotate(0.5turns)").d() self.assertEqual(a, b) def test_rect_reify(self): """Reifying a rotated rect.""" reification_checks(self, Rect()) reification_checks(self, Rect(2, 2, 4, 4)) shape = Rect() * "rotate(-90) translate(20,0)" t = Rect(0, -20, 1, 1) t *= "rotate(-90, 0, -20)" self.assertEqual(t, shape) def test_circle_reify(self): """Reifying a rotated circle.""" reification_checks(self, Circle()) reification_checks(self, Circle(2, 2, 4, 4)) def test_ellipse_reify(self): """Reifying a rotated ellipse.""" reification_checks(self, Ellipse(rx=1, ry=2)) reification_checks(self, Ellipse(2, 2, 5, 8)) def test_polyline_reify(self): """Reifying a rotated polyline.""" reification_checks(self, Polyline("0,0 1,1 2,2")) reification_checks(self, Polyline("0,0 1,1 2,0")) def test_polygon_reify(self): """Reifying a rotated polygon.""" reification_checks(self, Polygon("0,0 1,1 2,2")) reification_checks(self, Polygon("0,0 1,1 2,0")) def test_line_reify(self): """Reifying a rotated line.""" reification_checks(self, SimpleLine(0, 0, 1, 1)) reification_checks(self, SimpleLine(2, 2, 1, 0)) def test_path_reify(self): """Reifying a path.""" reification_checks(self, Path("M0,0L1,1L1,0z")) reification_checks(self, Path("M100,100L70,70L45,0z")) def test_shapes_degenerate(self): """Testing Degenerate Shapes""" self.assertEqual(Rect(0, 0, 0, 100).d(), '') self.assertEqual(Rect(0, 0, 100, 0).d(), '') self.assertEqual(Circle(0, 0, 0).d(), '') self.assertEqual(Ellipse(0,0,0,100).d(), '') self.assertEqual(Ellipse(0, 0, 100, 0).d(), '') self.assertEqual(Polygon(points='').d(), '') def test_issue_95(self): """Testing Issue 95 stroke-width""" q = io.StringIO(u''' ''') m = SVG.parse(q) ellipse = m[0] for i in range(5): ellipse = ellipse.reify() self.assertEqual(ellipse.stroke_width, 1.0) rect = m[1] for i in range(5): rect = rect.reify() self.assertEqual(rect.stroke_width, 1.0) def test_issue_99(self): """Test Issue of inverted circle reified location""" q = io.StringIO(u''' ''') m = SVG.parse(q, reify=False) q = copy(m[0]) r = copy(m[0]) self.assertEqual(q, r) q.reify() r = Path(r) q = Path(q) self.assertEqual(q, r) r.reify() q.reify() self.assertEqual(q, r) def test_issue_99b(self): """Test Issue of double inverted circle reified location""" q = io.StringIO(u''' ''') m = SVG.parse(q, reify=False) q = copy(m[0]) r = copy(m[0]) self.assertEqual(q, r) q.reify() r = Path(r) q = Path(q) self.assertEqual(q, r) r.reify() q.reify() self.assertEqual(q, r) def test_issue_99c(self): """Test Issue of inverted rect reified location""" q = io.StringIO(u''' ''') m = SVG.parse(q, reify=False) q = copy(m[0]) r = copy(m[0]) self.assertEqual(q, r) q.reify() r = Path(r) q = Path(q) self.assertEqual(q, r) r.reify() q.reify() self.assertEqual(q, r) def test_issue_99d(self): """Test Issue of double inverted rect reified location""" q = io.StringIO(u''' ''') m = SVG.parse(q, reify=False) q = copy(m[0]) r = copy(m[0]) self.assertEqual(q, r) q.reify() r = Path(r) q = Path(q) self.assertEqual(q, r) r.reify() q.reify() self.assertEqual(q, r) def test_issue_104(self): """Testing Issue 104 degenerate parsing""" q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(len(m), 0) def test_rect_strict(self): values = { 'tag': 'rect', 'rx': "-4", 'x': "50", 'y': "51", 'width': "20", 'height': "10" } e = Rect(values) e2 = Rect(50, 51, 20, 10) self.assertEqual(e, e2) e3 = Rect(values) e3._strict = False # unstrict rx-negative rectangles, have scooped corners. self.assertNotEqual(e3, e2) values['ry'] = 4 e4 = Rect(values) self.assertEqual(e, e4) def test_shape_npoints(self): import numpy as np shapes = [ Rect(10, 20, 300, 340), Circle(10, 10, 5), Ellipse(50, 50, 30, 20), Polygon(points=((10, 10), (20, 30), (50, 20))), Polyline(points=((10, 10), (20, 30), (50, 20), (100, 120))), ] for shape in shapes: pos = np.linspace(0, 1, 1000) pts1 = shape.npoint(pos) # Test rendered worthless. v2 = [] for i in range(len(pos)): v2.append(shape.point(pos[i])) for p, p1, p2 in zip(pos, pts1, v2): self.assertEqual(shape.point(p), Point(p1)) self.assertEqual(Point(p1), Point(p2)) def reification_checks(test, shape): correct_reify(test, shape * "rotate(-90) translate(20,0)") correct_reify(test, shape * "rotate(12turn)") correct_reify(test, shape * "translate(20,0)") correct_reify(test, shape * "scale(2) translate(20,0)") correct_reify(test, shape * "rotate(90) scale(-1) translate(20,0)") correct_reify(test, shape * "rotate(90) translate(20,0)") correct_reify(test, shape * "skewX(10)") correct_reify(test, shape * "skewY(10)") def correct_reify(test, shape): path = abs(Path(shape)) reified = abs(copy(shape)) test.assertEqual(path, shape) test.assertEqual(reified, shape) test.assertEqual(reified, path) svgelements-1.6.8/test/test_text.py000066400000000000000000000012271417573325500174540ustar00rootroot00000000000000import io import unittest from svgelements import * class TestElementText(unittest.TestCase): def test_issue_157(self): q = io.StringIO(u''' Test ''') m = SVG.parse(q) q = list(m.elements()) self.assertIsNotNone(q[1].id) # Group self.assertIsNotNone(q[2].id) # Text self.assertIsNotNone(q[3].id) # TSpan svgelements-1.6.8/test/test_viewbox.py000066400000000000000000000271441417573325500201610ustar00rootroot00000000000000import io import unittest from svgelements import * class TestElementViewbox(unittest.TestCase): def test_viewbox_creation(self): """Test various ways of creating a viewbox are equal.""" v1 = Viewbox('0 0 100 100', 'xMid') v2 = Viewbox(viewBox="0 0 100 100", preserve_aspect_ratio="xMid") v3 = Viewbox(x=0, y=0, width=100, height=100, preserveAspectRatio="xMid") v4 = Viewbox(v1) v5 = Viewbox({"x":0, "y":0, "width":100, "height":100, "preserveAspectRatio":"xMid"}) self.assertEqual(v1, v2) self.assertEqual(v1, v3) self.assertEqual(v1, v4) self.assertEqual(v1, v5) self.assertEqual(v2, v3) self.assertEqual(v2, v4) self.assertEqual(v2, v5) self.assertEqual(v3, v4) self.assertEqual(v3, v5) self.assertEqual(v4, v5) def test_viewbox_incomplete_none(self): """ Test viewboxes based on incomplete information. """ q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 1000) self.assertEqual(m.height, 1000) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 500) self.assertEqual(m.height, 500) def test_viewbox_incomplete_height(self): """ Test viewboxes based on incomplete information, only height. """ q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 1000) self.assertEqual(m.height, 200) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 500) self.assertEqual(m.height, 200) def test_viewbox_incomplete_width(self): """ Test viewboxes based on incomplete information, only width. """ q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 200) self.assertEqual(m.height, 1000) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 200) self.assertEqual(m.height, 500) def test_viewbox_incomplete_dims(self): """ Test viewboxes based on incomplete information, only dims. """ q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 200) self.assertEqual(m.height, 200) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(m.viewbox_transform, '') self.assertEqual(m.width, 200) self.assertEqual(m.height, 200) def test_viewbox_incomplete_viewbox(self): """ Test viewboxes based on incomplete information, only viewbox. """ q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(1)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(Matrix(m.viewbox_transform), 'scale(5)') self.assertEqual(m.width, 500) self.assertEqual(m.height, 500) def test_viewbox_incomplete_height_viewbox(self): """ Test viewboxes based on incomplete information, only height and viewbox. """ q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), '') self.assertEqual(m.width, 100) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(Matrix(m.viewbox_transform), 'scale(1) translateX(200)') self.assertEqual(m.width, 500) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q, width=500, height=500) self.assertEqual(Matrix(m.viewbox_transform), 'scale(2)') self.assertEqual(m.width, 200) self.assertEqual(m.height, 200) def test_viewbox_aspect_ratio_xMinMax(self): q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'translateX(100)') self.assertEqual(m.width, 300) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'translateX(0)') self.assertEqual(m.width, 300) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'translateX(200)') self.assertEqual(m.width, 300) self.assertEqual(m.height, 100) def test_viewbox_aspect_ratio_xMinMaxSlice(self): q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(3)') self.assertEqual(m.width, 300) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(3)') self.assertEqual(m.width, 300) self.assertEqual(m.height, 100) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(3)') self.assertEqual(m.width, 300) self.assertEqual(m.height, 100) def test_viewbox_aspect_ratio_yMinMax(self): q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'translateY(100)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 300) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'translateY(0)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 300) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'translateY(200)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 300) def test_viewbox_aspect_ratio_yMinMaxSlice(self): q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(3)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 300) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(3)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 300) q = io.StringIO(u''' ''') m = SVG.parse(q) self.assertEqual(Matrix(m.viewbox_transform), 'scale(3)') self.assertEqual(m.width, 100) self.assertEqual(m.height, 300) def test_viewbox_simple(self): r = Rect(0, 0, 100, 100) v = Viewbox({'viewBox': '0 0 100 100'}) self.assertEqual(v.transform(r), '') def test_viewbox_scale(self): r = Rect(0, 0, 200, 200) v = Viewbox('0 0 100 100') self.assertEqual(v.transform(r), 'scale(2, 2)') def test_viewbox_translate(self): r = Rect(0, 0, 100, 100) v = Viewbox(Viewbox('-50 -50 100 100')) self.assertEqual(v.transform(r), 'translate(50, 50)') def test_viewbox_parse_empty(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertEqual(len(q), 1) self.assertEqual(None, m.viewbox) def test_viewbox_parse_100(self): q = io.StringIO(u''' ''') m = SVG.parse(q, width=100, height=100) q = list(m.elements()) self.assertEqual(len(q), 1) self.assertEqual(Matrix(m.viewbox_transform), Matrix.identity()) def test_viewbox_parse_translate(self): q = io.StringIO(u''' ''') m = SVG.parse(q) q = list(m.elements()) self.assertEqual(len(q), 1) self.assertEqual(Matrix(m.viewbox_transform), Matrix.translate(1, 1))