pax_global_header00006660000000000000000000000064151445674400014524gustar00rootroot0000000000000052 comment=c11e91f442462d1efc1a45d76c76ae9e74aa0de4 skia-pathops-0.9.2/000077500000000000000000000000001514456744000141375ustar00rootroot00000000000000skia-pathops-0.9.2/.coveragerc000066400000000000000000000012671514456744000162660ustar00rootroot00000000000000[run] # measure 'branch' coverage in addition to 'statement' coverage # See: http://coverage.readthedocs.org/en/coverage-4.0.3/branch.html#branch branch = True plugins = Cython.Coverage # list of directories or packages to measure source = src/python/pathops [report] # Regexes for lines to exclude from consideration exclude_lines = # keywords to use in inline comments to skip coverage pragma: no cover # don't complain if tests don't hit defensive assertion code raise AssertionError raise NotImplementedError # don't complain if non-runnable code isn't run if 0: if __name__ == .__main__.: # ignore source code that can’t be found ignore_errors = True skia-pathops-0.9.2/.github/000077500000000000000000000000001514456744000154775ustar00rootroot00000000000000skia-pathops-0.9.2/.github/workflows/000077500000000000000000000000001514456744000175345ustar00rootroot00000000000000skia-pathops-0.9.2/.github/workflows/ci.yml000066400000000000000000000127721514456744000206630ustar00rootroot00000000000000name: Build + Deploy on: push: branches: [main] tags: ["v*.*.*"] pull_request: branches: [main] env: # skip 3.8, 3.9 (EOL); skip free-threaded 3.14 (untested yet) CIBW_SKIP: cp38-* cp39-* cp314t-* # enable PyPy builds on all platforms CIBW_ENABLE: pypy CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014 CIBW_MANYLINUX_I686_IMAGE: manylinux2014 CIBW_MANYLINUX_PYPY_X86_64_IMAGE: manylinux2014 CIBW_TEST_EXTRAS: testing CIBW_TEST_COMMAND: pytest {project}/tests BUILD_SKIA_FROM_SOURCE: 0 SKIA_LIBRARY_DIR: "build/download" CIBW_ENVIRONMENT: BUILD_SKIA_FROM_SOURCE=0 SKIA_LIBRARY_DIR=build/download jobs: build_wheels: runs-on: ${{ matrix.os }} env: CIBW_ARCHS: ${{ matrix.arch }} CIBW_BUILD: ${{ matrix.cibw_build_filter || '' }} defaults: run: shell: bash strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] arch: [auto64] include: # CPython universal2 wheels (x86_64 + arm64) for macOS - os: macos-latest arch: universal2 cibw_build_filter: "cp*" # PyPy x86_64 wheels for macOS (PyPy doesn't support universal2) - os: macos-15-intel arch: x86_64 cibw_build_filter: "pp*" # PyPy arm64 wheels for macOS (PyPy doesn't support universal2) - os: macos-latest arch: arm64 cibw_build_filter: "pp*" - os: windows-latest arch: x86 steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python 3.x uses: actions/setup-python@v5 with: python-version: "3.x" - name: Download pre-compiled libskia env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} run: | if [ "$BUILD_SKIA_FROM_SOURCE" == "0" ]; then pip install setuptools githubrelease if ! [[ $CIBW_ARCHS =~ ^auto ]]; then # translate cibuildwheel arch names to download_libskia.py names case "$CIBW_ARCHS" in x86_64) cpu_arch="--cpu-arch=x64" ;; *) cpu_arch="--cpu-arch=$CIBW_ARCHS" ;; esac fi python ci/download_libskia.py -d "${SKIA_LIBRARY_DIR}" $cpu_arch fi - name: Install dependencies run: pip install cibuildwheel - name: Build and Test Wheels run: python -m cibuildwheel --output-dir wheelhouse - uses: actions/upload-artifact@v4 with: name: skia_pathops-${{ matrix.os }}-${{ matrix.arch }} path: wheelhouse/*.whl build_aarch64_wheels: # native ARM64 runner, much faster than qemu runs-on: ubuntu-24.04-arm env: CIBW_ARCHS: aarch64 steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Download pre-compiled libskia env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} run: | if [ "$BUILD_SKIA_FROM_SOURCE" == "0" ]; then pip install setuptools githubrelease python ci/download_libskia.py -d "${SKIA_LIBRARY_DIR}" --cpu-arch "arm64" fi - name: Install dependencies run: pip install cibuildwheel - name: Build and Test Wheels run: python -m cibuildwheel --output-dir wheelhouse - uses: actions/upload-artifact@v4 with: name: skia_pathops-linux-aarch64 path: wheelhouse/*.whl deploy: # only run if the commit is tagged... if: startsWith(github.ref, 'refs/tags/v') # ... and all build jobs completed successfully needs: [build_wheels, build_aarch64_wheels] runs-on: ubuntu-latest permissions: # Required for trusted publishing to PyPI id-token: write # Required for creating GitHub releases contents: write steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Download artifacts from build jobs uses: actions/download-artifact@v4 with: path: dist/ merge-multiple: true - name: Extract release notes from annotated tag message id: release_notes env: # e.g. v0.1.0a1, v1.2.0b2 or v2.3.0rc3, but not v1.0.0 PRERELEASE_TAG_PATTERN: "v[[:digit:]]+\\.[[:digit:]]+\\.[[:digit:]]+([ab]|rc)[[:digit:]]+" run: | # GH checkout action doesn't preserve tag annotations, we must fetch them # https://github.com/actions/checkout/issues/290 git fetch --tags --force # strip leading 'refs/tags/' to get the tag name TAG_NAME="${GITHUB_REF##*/}" # Dump tag message to temporary .md file (excluding the PGP signature at the bottom) TAG_MESSAGE=$(git tag -l --format='%(contents)' $TAG_NAME | sed -n '/-----BEGIN PGP SIGNATURE-----/q;p') echo "$TAG_MESSAGE" > "${{ runner.temp }}/release_notes.md" # if the tag has a pre-release suffix mark the Github Release accordingly if egrep -q "$PRERELEASE_TAG_PATTERN" <<< "$TAG_NAME"; then echo "Tag contains a pre-release suffix" echo "IS_PRERELEASE=true" >> "$GITHUB_ENV" else echo "Tag does not contain pre-release suffix" echo "IS_PRERELEASE=false" >> "$GITHUB_ENV" fi - name: Create GitHub release id: create_release uses: softprops/action-gh-release@v2 with: body_path: "${{ runner.temp }}/release_notes.md" draft: false prerelease: ${{ env.IS_PRERELEASE }} - name: Build source distribution run: pipx run build --sdist - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 skia-pathops-0.9.2/.gitignore000066400000000000000000000007711514456744000161340ustar00rootroot00000000000000# Byte-compiled / optimized files __pycache__/ *.py[co] *.o *.so # Distribution / Packaging *.egg *.egg-info *.eggs MANIFEST build dist # Unit test / coverage files .tox/* .cache/ .pytest_cache/ .coverage .coverage.* coverage.xml htmlcov/ # cython annotated html files src/python/pathops/_pathops.html # emacs backup files *~ # OSX Finder .DS_Store # Generated cpp file(s) from Cython source src/python/pathops/_pathops.cpp # version file generated by setuptools_scm src/python/pathops/_version.py skia-pathops-0.9.2/.gitmodules000066400000000000000000000002011514456744000163050ustar00rootroot00000000000000[submodule "src/cpp/skia-builder"] path = src/cpp/skia-builder url = https://github.com/fonttools/skia-builder shallow = true skia-pathops-0.9.2/LICENSE000066400000000000000000000027031514456744000151460ustar00rootroot00000000000000Copyright (c) 2011 Google Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. skia-pathops-0.9.2/MANIFEST.in000066400000000000000000000010141514456744000156710ustar00rootroot00000000000000# we use setuptools_scm as git file finder, however we still need to use a # MANIFEST.in to either exclude files under VCS from the generated sdist, # or to include files from git submodules which are not included by default # by setuptools_scm # https://github.com/pypa/setuptools_scm/issues/206 exclude .gitignore .gitmodules exclude appveyor.yml .travis.yml exclude config.sh include src/cpp/skia/README include src/cpp/skia/LICENSE recursive-include src/cpp/skia/src *.cpp *.h recursive-include src/cpp/skia/include *.h skia-pathops-0.9.2/README.md000066400000000000000000000024301514456744000154150ustar00rootroot00000000000000[![Githun CI Status](https://github.com/fonttools/skia-pathops/workflows/Build%20+%20Deploy/badge.svg)](https://github.com/fonttools/skia-pathops/actions?query=workflow%3A%22Build+%2B+Deploy%22) [![Appveyor CI Status](https://ci.appveyor.com/api/projects/status/jv7g1e0m0vyopbej?svg=true)](https://ci.appveyor.com/project/fonttools/skia-pathops/branch/master) [![PyPI](https://img.shields.io/pypi/v/skia-pathops.svg)](https://pypi.org/project/skia-pathops/) Python bindings for the [Google Skia](https://skia.org) library's [Path Ops](https://skia.org/docs/dev/present/pathops/) module, performing boolean operations on paths (intersection, union, difference, xor). Install ======= To install or update to the latest released package, run: pip3 install --upgrade skia-pathops Build ===== A recent version of [Cython](https://github.com/cython/cython) is required to build the package (see the `pyproject.toml` file for the minimum required version). For developers we recommend installing in editable mode, and compiling the extension module in the same source directory: git clone --recursive https://github.com/fonttools/skia-pathops.git cd skia-pathops pip install -e . If this fails, try upgrading pip to v18 or later, and try again: pip3 install --upgrade pip skia-pathops-0.9.2/benchmarks.py000066400000000000000000000052221514456744000166270ustar00rootroot00000000000000from pathops import union as pathops_union from booleanOperations import union as boolops_union from defcon import Font as DefconFont from ufoLib2 import Font as UfoLib2Font import math import timeit REPEAT = 10 NUMBER = 1 def remove_overlaps(font, union_func, pen_getter, **kwargs): for glyph in font: contours = list(glyph) if not contours: continue glyph.clearContours() pen = getattr(glyph, pen_getter)() union_func(contours, pen, **kwargs) def mean_and_stdev(runs, loops): timings = [t / loops for t in runs] n = len(runs) mean = math.fsum(timings) / n stdev = (math.fsum([(x - mean) ** 2 for x in timings]) / n) ** 0.5 return mean, stdev def run( ufo, FontClass, union_func, pen_getter, repeat=REPEAT, number=NUMBER, **kwargs, ): all_runs = timeit.repeat( stmt="remove_overlaps(font, union_func, pen_getter, **kwargs)", setup="font = FontClass(ufo); list(font)", repeat=repeat, number=number, globals={ "ufo": ufo, "FontClass": FontClass, "union_func": union_func, "pen_getter": pen_getter, "remove_overlaps": remove_overlaps, "kwargs": kwargs, }, ) mean, stdev = mean_and_stdev(all_runs, number) class_module = FontClass.__module__.split(".")[0] func_module = union_func.__module__.split(".")[0] print( f"{class_module}::{func_module}: {mean:.3f} s +- {stdev:.3f} s per loop " f"(mean +- std. dev. of {repeat} run(s), {number} loop(s) each)" ) def main(): import sys try: ufo = sys.argv[1] except IndexError: sys.exit("usage: %s FONT.ufo [N]" % sys.argv[0]) if len(sys.argv) > 2: repeat = int(sys.argv[2]) else: repeat = REPEAT for FontClass in [DefconFont, UfoLib2Font]: for union_func, pen_getter, kwargs in [ (boolops_union, "getPointPen", {}), (pathops_union, "getPen", {}), # (pathops_union, "getPen", {"keep_starting_points": True}), ]: run( ufo, FontClass, union_func, pen_getter, repeat=repeat, **kwargs ) # import os # import shutil # font = UfoLib2Font(ufo) # font = DefconFont(ufo) # union_func = pathops_union # pen_getter = "getPen" # union_func = boolops_union # pen_getter = "getPointPen" # remove_overlaps(font, union_func, pen_getter) # output = ufo.rsplit(".", 1)[0] + "_ro.ufo" # if os.path.isdir(output): # shutil.rmtree(output) # font.save(output) if __name__ == "__main__": main() skia-pathops-0.9.2/ci/000077500000000000000000000000001514456744000145325ustar00rootroot00000000000000skia-pathops-0.9.2/ci/download_libskia.py000066400000000000000000000063421514456744000204160ustar00rootroot00000000000000import argparse import glob import logging import platform import os import shutil import struct import tempfile from distutils.util import get_platform __requires__ = ["github_release"] import github_release GITHUB_REPO = "fonttools/skia-builder" ASSET_TEMPLATE = "libskia-{plat}-{arch}.zip" DOWNLOAD_DIR = os.path.join("build", "download") PLATFORM_TAGS = {"Linux": "linux", "Darwin": "mac", "Windows": "win"} CURRENT_PLATFORM = PLATFORM_TAGS.get(platform.system()) SUPPORTED_CPU_ARCHS = { "linux": {"x64", "arm64"}, "mac": {"x64", "arm64", "universal2"}, "win": {"x64", "x86"}, } machine = get_platform().split("-")[-1] CURRENT_CPU_ARCH = { "win32": "x86", "amd64": "x64", "x86_64": "x64", "aarch64": "arm64", }.get(machine, machine) logger = logging.getLogger() def get_latest_release(repo): releases = github_release.get_releases(repo) if not releases: raise ValueError("no releases found for {!r}".format(repo)) return releases[0] def download_unpack_assets(repo, tag, asset_name, dest_dir): dest_dir = os.path.abspath(dest_dir) os.makedirs(dest_dir, exist_ok=True) with tempfile.TemporaryDirectory() as tmpdir: curdir = os.getcwd() os.chdir(tmpdir) try: downloaded = github_release.gh_asset_download(repo, tag, asset_name) except: raise else: if not downloaded: raise ValueError( "no assets found for {0!r} with name {1!r}".format(tag, asset_name) ) for archive in glob.glob(asset_name): shutil.unpack_archive(archive, dest_dir) finally: os.chdir(curdir) if __name__ == "__main__": logging.basicConfig(level="INFO") parser = argparse.ArgumentParser() parser.add_argument( "-p", "--platform", default=CURRENT_PLATFORM, choices=["win", "mac", "linux"], help="The desired platform (default: %(default)s)", ) parser.add_argument( "-a", "--cpu-arch", default=CURRENT_CPU_ARCH, help="The desired CPU architecture (default: %(default)s)", choices=["x86", "x64", "arm64", "universal2"], ) parser.add_argument( "-d", "--download-dir", default=DOWNLOAD_DIR, help="directory where to download libskia (default: %(default)s)", ) parser.add_argument( "-t", "--tag-name", default=None, help="release tag name (default: latest)" ) args = parser.parse_args() if args.platform is None: parser.error(f"Unsupported platform: {platform.system()}") if args.cpu_arch not in SUPPORTED_CPU_ARCHS[args.platform]: parser.error(f"Unsupported architecture for {args.platform}: {args.cpu_arch}") tag_name = args.tag_name if tag_name is None: latest_release = get_latest_release(GITHUB_REPO) tag_name = latest_release["tag_name"] asset_name = ASSET_TEMPLATE.format(plat=args.platform, arch=args.cpu_arch) logger.info( "Downloading '%s' from '%s' at tag '%s' to %s", asset_name, GITHUB_REPO, tag_name, args.download_dir, ) download_unpack_assets(GITHUB_REPO, tag_name, asset_name, args.download_dir) skia-pathops-0.9.2/pyproject.toml000066400000000000000000000010071514456744000170510ustar00rootroot00000000000000[build-system] requires = [ "setuptools", "wheel", "setuptools_scm", "packaging", "cython >= 3.2.0", ] [tool.cibuildwheel] # Run abi3audit after the default repair commands to scan for abi3 violations # https://github.com/pypa/abi3audit # Only on Unix platforms (Linux/macOS) since Windows has no default repair command [[tool.cibuildwheel.overrides]] select = "cp*-{*linux_*,*macosx_*}" inherit.repair-wheel-command = "append" repair-wheel-command = "pipx run abi3audit --strict --report {wheel}" skia-pathops-0.9.2/setup.cfg000066400000000000000000000000711514456744000157560ustar00rootroot00000000000000[sdist] formats = zip [metadata] license_file = LICENSE skia-pathops-0.9.2/setup.py000066400000000000000000000366511514456744000156640ustar00rootroot00000000000000#!/usr/bin/env python from __future__ import print_function from setuptools import setup, find_packages, Extension from setuptools.command.build_ext import build_ext from distutils.errors import DistutilsSetupError from distutils import log from distutils.dep_util import newer_group from distutils.dir_util import mkpath from distutils.file_util import copy_file from distutils.util import get_platform from importlib.metadata import version as get_package_version from packaging.version import parse as parse_version import struct import subprocess import sys import os import platform from io import open import re import argparse import shlex def bool_from_environ(key: str, default: bool = False): """Get a boolean value from an environment variable.""" value = os.environ.get(key) if not value: return default return value.lower() not in ("0", "false", "no", "off") # export BUILD_SKIA_FROM_SOURCE=0 to not build libskia when building extension BUILD_SKIA_FROM_SOURCE = bool(int(os.environ.get("BUILD_SKIA_FROM_SOURCE", "1"))) # Use this to specify the directory where your pre-built skia is located SKIA_LIBRARY_DIR = os.environ.get("SKIA_LIBRARY_DIR") # Python Limited API for stable ABI support is enabled by default. # PyPy does not support Limited API, so we disable it automatically. # Can also be disabled manually with USE_PY_LIMITED_API=0. # https://docs.python.org/3/c-api/stable.html#limited-c-api is_pypy = sys.implementation.name == "pypy" use_py_limited_api = ( False if is_pypy else bool_from_environ("USE_PY_LIMITED_API", default=True) ) # NOTE: this must be kept in sync with python_requires='>=3.10' below limited_api_min_version = "0x030A0000" # Python 3.10 # check if minimum required Cython is available cython_version_re = re.compile(r'\s*"cython\s*>=\s*([0-9][0-9\w\.]*)\s*"') with open("pyproject.toml", "r", encoding="utf-8") as fp: for line in fp: m = cython_version_re.match(line) if m: cython_min_version = m.group(1) break else: sys.exit("error: could not parse cython version from pyproject.toml") try: cython_version = parse_version(get_package_version("cython")) cython_min = parse_version(cython_min_version) with_cython = cython_version >= cython_min except Exception: with_cython = False inside_sdist = os.path.exists("PKG-INFO") argv = sys.argv[1:] # bail out early if we are compiling the cython extension module if {"build", "build_ext", "bdist_wheel", "install", "develop", "test"}.intersection( argv ) and not with_cython: sys.exit("error: the required Cython >= %s was not found" % cython_min_version) needs_wheel = {"bdist_wheel"}.intersection(argv) wheel = ["wheel"] if needs_wheel else [] setuptools_git_ls_files = ["setuptools_git_ls_files"] if os.path.isdir(".git") else [] class custom_build_ext(build_ext): """Custom 'build_ext' command which allows to pass compiler-specific 'extra_compile_args', 'extra_link_args', 'define_macros' and 'undef_macros' options. The value of the Extension class keywords can be provided as a dict, with the the compiler type as the keys (e.g. "unix", "mingw32", "msvc"), and the values containing the compiler-specific list of options. A special empty string '' key may be used for default options that apply to all the other compiler types except for those explicitly listed. """ _library_builders = {} @classmethod def register_library_builder(cls, library_name, builder): """Associates a builder function with signature `func(str) -> str` to the given library_name. The builder is a callable that takes one parameter, a build directory (e.g. './build'), and returns the full directory path where the newly built library is located (e.g. a sub- directory of the base build dir). Builder functions will be called in `get_libraries` method. E.g. see `build_skia` function defined below. """ cls._library_builders[library_name] = builder def finalize_options(self): if with_cython: # compile *.pyx source files to *.cpp using cythonize from Cython.Build import cythonize # optionally enable line tracing for test coverage support linetrace = os.environ.get("CYTHON_TRACE") == "1" force = linetrace or self.force self.distribution.ext_modules[:] = cythonize( self.distribution.ext_modules, force=force, annotate=os.environ.get("CYTHON_ANNOTATE", False), quiet=not self.verbose, compiler_directives={ "linetrace": linetrace, "language_level": 3, "embedsignature": True, }, ) build_ext.finalize_options(self) def build_extension(self, ext): sources = ext.sources if sources is None or not isinstance(sources, (list, tuple)): raise DistutilsSetupError( "in 'ext_modules' option (extension '%s'), " "'sources' must be present and must be " "a list of source filenames" % ext.name ) sources = list(sources) ext_path = self.get_ext_fullpath(ext.name) depends = sources + ext.depends if not (self.force or newer_group(depends, ext_path, "newer")): log.debug("skipping '%s' extension (up-to-date)", ext.name) return else: log.info("building '%s' extension", ext.name) # Detect target language, if not provided language = ext.language or self.compiler.detect_language(sources) # do compiler specific customizations compiler_type = self.compiler.compiler_type # strip compile flags that are not valid for C++ to avoid warnings if compiler_type == "unix" and language == "c++": if "-Wstrict-prototypes" in self.compiler.compiler_so: self.compiler.compiler_so.remove("-Wstrict-prototypes") if isinstance(ext.extra_compile_args, dict): if compiler_type in ext.extra_compile_args: extra_compile_args = ext.extra_compile_args[compiler_type] else: extra_compile_args = ext.extra_compile_args.get("", []) else: extra_compile_args = ext.extra_compile_args or [] if isinstance(ext.extra_link_args, dict): if compiler_type in ext.extra_link_args: extra_link_args = ext.extra_link_args[compiler_type] else: extra_link_args = ext.extra_link_args.get("", []) else: extra_link_args = ext.extra_link_args or [] if isinstance(ext.define_macros, dict): if compiler_type in ext.define_macros: macros = ext.define_macros[compiler_type] else: macros = ext.define_macros.get("", []) else: macros = ext.define_macros or [] if isinstance(ext.undef_macros, dict): for tp, undef in ext.undef_macros.items(): if tp == compiler_type: macros.append((undef,)) else: for undef in ext.undef_macros: macros.append((undef,)) if os.environ.get("CYTHON_TRACE") == "1": log.debug("adding -DCYTHON_TRACE to preprocessor macros") macros.append(("CYTHON_TRACE", 1)) # compile the source code to object files. objects = self.compiler.compile( sources, output_dir=self.build_temp, macros=macros, include_dirs=ext.include_dirs, debug=self.debug, extra_postargs=extra_compile_args, depends=ext.depends, ) # Now link the object files together into a "shared object" if ext.extra_objects: objects.extend(ext.extra_objects) self.compiler.link_shared_object( objects, ext_path, libraries=self.get_libraries(ext), library_dirs=ext.library_dirs, runtime_library_dirs=ext.runtime_library_dirs, extra_postargs=extra_link_args, export_symbols=self.get_export_symbols(ext), debug=self.debug, build_temp=self.build_temp, target_lang=language, ) def get_libraries(self, ext): """Build all libraries for which a builder function is registered, and append the resulting directory path to the extension module's 'library_dirs' list so that the linker can find. """ for library in ext.libraries: if library in self._library_builders: library_dir = self._library_builders[library](self.build_temp) ext.library_dirs.append(library_dir) return build_ext.get_libraries(self, ext) def run(self): build_ext.run(self) if sys.platform == "win32": self._copy_windows_dlls() def _copy_windows_dlls(self): # copy DLLs next to the extension module for ext in self.extensions: for lib_name in ext.libraries: for lib_dir in ext.library_dirs: dll_filename = lib_name + ".dll" dll_fullpath = os.path.join(lib_dir, dll_filename) if os.path.exists(dll_fullpath): break else: log.debug( "cannot find '{}' in: {}".format( dll_filename, ", ".join(ext.library_dirs) ) ) continue ext_path = self.get_ext_fullpath(ext.name) dest_dir = os.path.dirname(ext_path) if not self.dry_run: mkpath(dest_dir, verbose=self.verbose) copy_file( dll_fullpath, os.path.join(dest_dir, dll_filename), verbose=self.verbose, ) def build_skia(build_base): log.info("building 'skia' library") build_dir = os.path.join(build_base, skia_dir) build_skia_py = os.path.join(skia_builder_dir, "build_skia.py") build_cmd = [sys.executable, build_skia_py, build_dir] if inside_sdist: build_cmd.append("--no-sync-deps") env = os.environ target_cpu = None if sys.platform == "win32": from distutils._msvccompiler import _get_vc_env # for Windows, we want to build a shared skia.dll. If we build a static lib # then gn/ninja pass the /MT flag (static runtime library) instead of /MD, # and produce linker errors when building the python extension module build_cmd.append("--shared-lib") # update Visual C++ toolchain environment depending on python architecture target_cpu = "x64" if struct.calcsize("P") * 8 == 64 else "x86" env = os.environ.copy() env.update(_get_vc_env(target_cpu)) elif {"macosx", "universal2"}.issubset(get_platform().split("-")): # if Python was built as a 'universal2' binary, we also try to build # a single library combining both x86_64 and arm64 architectures target_cpu = "universal2" if target_cpu: build_cmd.extend(["--target-cpu", target_cpu]) subprocess.run(build_cmd, check=True, env=env) return build_dir def get_skia_using_pkgconfig(): """Runs `pkg-config --libs --cflags skia` and parses returned flags using argparse. """ _parser = argparse.ArgumentParser() _parser.add_argument("-I", dest="include_dirs", action="append", default=[]) _parser.add_argument("-L", dest="library_dirs", action="append", default=[]) _parser.add_argument("-l", dest="libraries", action="append", default=[]) if BUILD_SKIA_FROM_SOURCE: return _parser.parse_known_args([])[0] pkgconfig = os.environ.get("PKG_CONFIG", "pkg-config") log.info("Finding skia using pkg-config") try: op = subprocess.run( [pkgconfig, "--cflags", "--libs", "skia"], text=True, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stout = op.stdout except FileNotFoundError: stout = "" args, _ = _parser.parse_known_args(shlex.split(stout)) return args pkg_config_op = get_skia_using_pkgconfig() if BUILD_SKIA_FROM_SOURCE: custom_build_ext.register_library_builder("skia", build_skia) pkg_dir = os.path.join("src", "python") skia_builder_dir = os.path.join("src", "cpp", "skia-builder") skia_dir = os.path.join(skia_builder_dir, "skia") skia_src_dir = os.path.join(skia_dir, "src") # allow access to internals include_dirs = [skia_dir, skia_src_dir, *pkg_config_op.include_dirs] extra_compile_args = { "": [ "-std=c++17", ] + ( [ # extra flags needed on macOS for C++11 "-stdlib=libc++", "-mmacosx-version-min=11.0", ] if platform.system() == "Darwin" else [] ), "msvc": [ "/EHsc", "/Zi", "/std:c++17", ], } library_dirs = [SKIA_LIBRARY_DIR] if SKIA_LIBRARY_DIR is not None else [] library_dirs += pkg_config_op.library_dirs define_macros = [("SK_SUPPORT_UNSPANNED_APIS", "1")] if use_py_limited_api: define_macros.append(("Py_LIMITED_API", limited_api_min_version)) extensions = [ Extension( "pathops._pathops", sources=[ os.path.join(pkg_dir, "pathops", "_pathops.pyx"), ], include_dirs=include_dirs, extra_compile_args=extra_compile_args, define_macros=define_macros, libraries=["skia", *pkg_config_op.libraries], library_dirs=library_dirs, language="c++", py_limited_api=use_py_limited_api, ), ] with open("README.md", "r") as f: long_description = f.read() version_file = os.path.join(pkg_dir, "pathops", "_version.py") setup_params = dict( name="skia-pathops", use_scm_version={"write_to": version_file}, description="Python access to operations on paths using the Skia library", url="https://github.com/fonttools/skia-pathops", long_description=long_description, long_description_content_type="text/markdown", author="Khaled Hosny, Cosimo Lupo", author_email="fonttools@googlegroups.com", license="BSD-3-Clause", package_dir={"": pkg_dir}, packages=find_packages(pkg_dir), ext_modules=extensions, cmdclass={ "build_ext": custom_build_ext, }, options={"bdist_wheel": {"py_limited_api": "cp310"}} if use_py_limited_api else {}, setup_requires=["setuptools_scm", "packaging"] + setuptools_git_ls_files + wheel, install_requires=[], extras_require={ "testing": [ "pytest", "coverage", "pytest-xdist", "pytest-randomly", # https://github.com/lgpage/pytest-cython/pull/5#issuecomment-742782671 # "pytest-cython", ], }, python_requires=">=3.10", zip_safe=False, classifiers=[ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Multimedia :: Graphics", "Topic :: Multimedia :: Graphics :: Graphics Conversion", ], ) if __name__ == "__main__": setup(**setup_params) skia-pathops-0.9.2/src/000077500000000000000000000000001514456744000147265ustar00rootroot00000000000000skia-pathops-0.9.2/src/cpp/000077500000000000000000000000001514456744000155105ustar00rootroot00000000000000skia-pathops-0.9.2/src/cpp/skia-builder/000077500000000000000000000000001514456744000200635ustar00rootroot00000000000000skia-pathops-0.9.2/src/python/000077500000000000000000000000001514456744000162475ustar00rootroot00000000000000skia-pathops-0.9.2/src/python/pathops/000077500000000000000000000000001514456744000177255ustar00rootroot00000000000000skia-pathops-0.9.2/src/python/pathops/__init__.py000066400000000000000000000033111514456744000220340ustar00rootroot00000000000000from ._pathops import ( PathPen, Path, PathVerb, PathOp, FillType, LineCap, LineJoin, ArcSize, Direction, op, simplify, OpBuilder, PathOpsError, UnsupportedVerbError, OpenPathError, NumberOfPointsError, bits2float, float2bits, decompose_quadratic_segment, ) # Cython generates cpdef enums as IntFlag. Starting in Python 3.11, IntFlag # only includes "canonical" members when iterating: that is, only powers of # two (1, 2, 4, 8...). Non-powers of two members ("aliases") are excluded # from _member_names_ which controls iteration, even though they're still # in __members__. This breaks would code that iterates over the enum # expecting all members to be listed (just like our operations.py does). # Cython added a workaround that sets _member_names_ to bypass this filtering, # but it only applies when compiling with Python 3.11+ (controlled by a # compile-time PY_VERSION_HEX check). If we build the abi3 wheels with Python # 3.10, that workaround doesn't get applied, causing enum iteration to fail # when the wheel is installed and run on Python 3.11+. # We fix this by manually setting _member_names_ here, just like Cython does, # ensuring wheels built with 3.10 will also work on 3.11+. See: # https://github.com/cython/cython/pull/4877 # https://github.com/cython/cython/issues/5109 for _enum_class in [PathOp, FillType, LineCap, LineJoin, ArcSize, Direction, PathVerb]: _enum_class._member_names_ = list(_enum_class.__members__.keys()) del _enum_class from .operations import ( union, difference, intersection, xor, ) try: from ._version import version as __version__ except ImportError: __version__ = "0.0.0+unknown" skia-pathops-0.9.2/src/python/pathops/_pathops.pxd000066400000000000000000000151271514456744000222650ustar00rootroot00000000000000from ._skia.core cimport ( SkArcSize, SkLineCap, SkLineJoin, SkPath, SkPathBuilder, SkPathFillType, SkPathIter, SkPathVerb, SkPoint, SkScalar, SkSpan, SkPathDirection, SkMatrix, ) from ._skia.pathops cimport ( SkOpBuilder, SkPathOp, kDifference_SkPathOp, kIntersect_SkPathOp, kUnion_SkPathOp, kXOR_SkPathOp, kReverseDifference_SkPathOp, ) from libc.stdint cimport uint8_t, int32_t, uint32_t from libcpp.optional cimport optional cpdef enum PathOp: DIFFERENCE = kDifference_SkPathOp INTERSECTION = kIntersect_SkPathOp UNION = kUnion_SkPathOp XOR = kXOR_SkPathOp REVERSE_DIFFERENCE = kReverseDifference_SkPathOp cpdef enum FillType: WINDING = SkPathFillType.kWinding EVEN_ODD = SkPathFillType.kEvenOdd INVERSE_WINDING = SkPathFillType.kInverseWinding INVERSE_EVEN_ODD = SkPathFillType.kInverseEvenOdd cpdef enum LineCap: BUTT_CAP = SkLineCap.kButt_Cap, ROUND_CAP = SkLineCap.kRound_Cap, SQUARE_CAP = SkLineCap.kSquare_Cap cpdef enum LineJoin: MITER_JOIN = SkLineJoin.kMiter_Join, ROUND_JOIN = SkLineJoin.kRound_Join, BEVEL_JOIN = SkLineJoin.kBevel_Join cpdef enum ArcSize: SMALL = SkArcSize.kSmall_ArcSize LARGE = SkArcSize.kLarge_ArcSize cpdef enum Direction: CW = SkPathDirection.kCW CCW = SkPathDirection.kCCW cdef union FloatIntUnion: float Float int32_t SignBitInt cdef int32_t _float2bits(float x) cdef float _bits2float(int32_t float_as_bits) cdef float SCALAR_NEARLY_ZERO_SQD cdef bint can_normalize(SkScalar dx, SkScalar dy) cdef bint points_almost_equal(const SkPoint& p1, const SkPoint& p2) cdef bint is_middle_point( const SkPoint& p1, const SkPoint& p2, const SkPoint& p3 ) cdef bint collinear( const SkPoint& p1, const SkPoint& p2, const SkPoint& p3 ) cdef class Path: cdef SkPathBuilder path @staticmethod cdef Path create(const SkPathBuilder& path) cpdef PathPen getPen(self, object glyphSet=*, bint allow_open_paths=*) cpdef void moveTo(self, SkScalar x, SkScalar y) cpdef void lineTo(self, SkScalar x, SkScalar y) cpdef void quadTo( self, SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2 ) cpdef void conicTo( self, SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2, SkScalar w ) cpdef void cubicTo( self, SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2, SkScalar x3, SkScalar y3, ) cpdef void arcTo( self, SkScalar rx, SkScalar ry, SkScalar xAxisRotate, ArcSize largeArc, Direction sweep, SkScalar x, SkScalar y, ) cpdef void close(self) cpdef void reset(self) cpdef void rewind(self) cpdef draw(self, pen) cpdef addPath(self, Path path) cpdef reverse(self) cpdef simplify( self, bint fix_winding=*, bint keep_starting_points=*, bint clockwise=*, ) cpdef convertConicsToQuads(self, float tolerance=*) cpdef stroke( self, SkScalar width, LineCap cap, LineJoin join, SkScalar miter_limit, object dash_array=*, SkScalar dash_offset=*, ) cdef list getVerbs(self) cdef list getPoints(self) cdef int countContours(self) except -1 cdef int getFirstPoints(self, SkPoint **pp, int *count) except -1 cpdef Path transform( self, SkScalar scaleX=*, SkScalar skewY=*, SkScalar skewX=*, SkScalar scaleY=*, SkScalar translateX=*, SkScalar translateY=*, SkScalar perspectiveX=*, SkScalar perspectiveY=*, SkScalar perspectiveBias=*, ) cpdef enum PathVerb: MOVE = SkPathVerb.kMove LINE = SkPathVerb.kLine QUAD = SkPathVerb.kQuad CONIC = SkPathVerb.kConic # unsupported CUBIC = SkPathVerb.kCubic CLOSE = SkPathVerb.kClose cdef uint8_t *POINTS_IN_VERB cdef dict VERB_METHODS cdef dict PEN_METHODS cdef class RawPathIterator: cdef Path path cdef optional[SkPathIter] iterator cdef class SegmentPenIterator: cdef Path path cdef const SkPoint *pts cdef const SkPathVerb *verbs cdef const SkPathVerb *verb_stop cdef SkPoint move_pt cdef bint closed cdef bint nextIsClose(self) cdef tuple _join_quadratic_segments(self) cdef class PathPen: cdef Path path cdef object glyphSet cdef bint allow_open_paths cpdef moveTo(self, pt) cpdef lineTo(self, pt) # def curveTo(self, *points) # def qCurveTo(self, *points) cdef _qCurveToOne(self, pt1, pt2) cpdef closePath(self) cpdef endPath(self) cpdef addComponent(self, glyphName, transformation) cdef double get_path_area(const SkPathBuilder& path) except? -1234567 cdef class _SkScalarArray: cdef SkScalar *data cdef int count @staticmethod cdef _SkScalarArray create(object values) cdef SkSpan[SkScalar] as_span(self) cdef int pts_in_verb(SkPathVerb v) except -1 cdef bint reverse_contour(SkPathBuilder& path) except False cdef int path_is_inside(const SkPathBuilder& self, const SkPathBuilder& other) except -1 cpdef int restore_starting_points(Path path, list points) except -1 cpdef bint winding_from_even_odd(Path path, bint clockwise=*) except False cdef list _decompose_quadratic_segment(tuple points) cdef int find_oncurve_point( SkScalar x, SkScalar y, const SkPoint *pts, int pt_count, const SkPathVerb *verbs, int verb_count, int *pt_index, int *verb_index, ) except -1 cdef int contour_is_closed(SkSpan[const SkPathVerb] verbs) except -1 cdef int set_contour_start_point(SkPathBuilder& path, SkScalar x, SkScalar y) except -1 cdef int compute_conic_to_quad_pow2( SkPoint p0, SkPoint p1, SkPoint p2, SkScalar weight, SkScalar tol ) except -1 cpdef Path op( Path one, Path two, SkPathOp operator, bint fix_winding=*, bint keep_starting_points=*, bint clockwise=*, ) cpdef Path simplify( Path path, bint fix_winding=*, bint keep_starting_points=*, bint clockwise=*, ) cdef class OpBuilder: cdef SkOpBuilder builder cdef bint fix_winding cdef bint keep_starting_points cdef list first_points cdef bint clockwise cpdef add(self, Path path, SkPathOp operator) cpdef Path resolve(self) skia-pathops-0.9.2/src/python/pathops/_pathops.pyx000066400000000000000000001425631514456744000223170ustar00rootroot00000000000000from ._skia.core cimport ( SkArcSize, SkPath, SkPathBuilder, SkPathFillType, SkPathIter, SkPathVerb, SkPoint, SkRect, SkScalar, SkSpan, SkLineCap, SkLineJoin, SkPathDirection, SK_ScalarNearlyZero, ConvertConicToQuads, SkPaint, SkPaintStyle, sk_sp, SkPathEffect, SkDashPathEffect, FillPathWithPaint, ) from libcpp.optional cimport optional from ._skia.pathops cimport ( Op, Simplify, AsWinding, SkOpBuilder, SkPathOp, kDifference_SkPathOp, kIntersect_SkPathOp, kUnion_SkPathOp, kXOR_SkPathOp, kReverseDifference_SkPathOp, ) from libc.stdint cimport uint8_t, int32_t, uint32_t from libc.math cimport fabs, sqrt, isfinite from libc.stddef cimport size_t from libc.string cimport memset cimport cython # Explicit declarations for Limited API / Stable ABI compatibility cdef extern from "Python.h": void* PyMem_Malloc(size_t) void* PyMem_Realloc(void*, size_t) void PyMem_Free(void*) import itertools import sys if sys.version_info[:2] < (3, 10): from itertools import tee def pairwise(iterable): """Return successive overlapping pairs taken from the input iterable. Backported from Python 3.10. https://docs.python.org/3/library/itertools.html#itertools.pairwise """ a, b = tee(iterable) next(b, None) return zip(a, b) else: from itertools import pairwise # Standard Python exception classes (not cdef classes) for Limited API compatibility class PathOpsError(Exception): pass class UnsupportedVerbError(PathOpsError): pass class OpenPathError(PathOpsError): pass class NumberOfPointsError(PathOpsError): pass # Helpers to convert to/from a float and its bit pattern cdef inline int32_t _float2bits(float x): cdef FloatIntUnion data data.Float = x return data.SignBitInt def float2bits(float x): """ >>> hex(float2bits(17.5)) '0x418c0000' >>> hex(float2bits(-10.0)) '0xc1200000' """ # we use unsigned to match the C printf %x behaviour # used by Skia's SkPath::dumpHex cdef uint32_t bits = _float2bits(x) return bits cdef inline float _bits2float(int32_t float_as_bits): cdef FloatIntUnion data data.SignBitInt = float_as_bits return data.Float def bits2float(long long float_as_bits): """ >>> bits2float(0x418c0000) 17.5 >>> bits2float(-0x3ee00000) -10.0 >>> bits2float(0xc1200000) -10.0 """ return _bits2float(float_as_bits) cdef float SCALAR_NEARLY_ZERO_SQD = SK_ScalarNearlyZero * SK_ScalarNearlyZero cdef inline bint can_normalize(SkScalar dx, SkScalar dy): return (dx*dx + dy*dy) > SCALAR_NEARLY_ZERO_SQD cdef inline bint points_almost_equal(const SkPoint& p1, const SkPoint& p2): return not can_normalize(p1.x() - p2.x(), p1.y() - p2.y()) cdef inline bint is_middle_point( const SkPoint& p1, const SkPoint& p2, const SkPoint& p3 ): cdef SkScalar midx = (p1.x() + p3.x()) / 2.0 cdef SkScalar midy = (p1.y() + p3.y()) / 2.0 return not can_normalize(p2.x() - midx, p2.y() - midy) cdef inline bint collinear( const SkPoint& p1, const SkPoint& p2, const SkPoint& p3 ): # the area of a triangle is zero iff the three vertices are collinear return fabs( p1.x() * (p2.y() - p3.y()) + p2.x() * (p3.y() - p1.y()) + p3.x() * (p1.y() - p2.y()) ) <= 2 * SK_ScalarNearlyZero def _format_hex_coords(floats): floats = list(floats) if not floats: return "" return "".join( "\n bits2float(%s), # %g" % (hex(float2bits(f)), f) for f in floats ) + "\n" def triplewise(iterable): """Return overlapping triplets from an iterable E.g. triplewise('ABCDEFG') --> ABC BCD CDE DEF EFG From: https://docs.python.org/3/library/itertools.html#itertools-recipes """ for (a, _), (b, c) in pairwise(pairwise(iterable)): yield a, b, c cdef class Path: def __init__(self, other=None, fillType=None): cdef Path static_path if other is not None: if isinstance(other, Path): static_path = other self.path = static_path.path else: other.draw(self.getPen()) if fillType is not None: self.fillType = fillType @staticmethod cdef Path create(const SkPathBuilder& path): cdef Path self = Path.__new__(Path) self.path = path return self cpdef PathPen getPen(self, object glyphSet=None, bint allow_open_paths=True): return PathPen(self, glyphSet=glyphSet, allow_open_paths=allow_open_paths) def __iter__(self): return RawPathIterator(self) def add(self, PathVerb verb, *pts): if verb is PathVerb.MOVE: self.path.moveTo(pts[0][0], pts[0][1]) elif verb is PathVerb.LINE: self.path.lineTo(pts[0][0], pts[0][1]) elif verb is PathVerb.QUAD: self.path.quadTo(pts[0][0], pts[0][1], pts[1][0], pts[1][1]) elif verb is PathVerb.CONIC: self.path.conicTo(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2]) elif verb is PathVerb.CUBIC: self.path.cubicTo(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]) elif verb is PathVerb.CLOSE: self.path.close() else: raise UnsupportedVerbError(verb) cpdef void moveTo(self, SkScalar x, SkScalar y): self.path.moveTo(x, y) cpdef void lineTo(self, SkScalar x, SkScalar y): self.path.lineTo(x, y) cpdef void quadTo( self, SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2 ): self.path.quadTo(x1, y1, x2, y2) cpdef void conicTo( self, SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2, SkScalar w ): self.path.conicTo(x1, y2, x2, y2, w) cpdef void cubicTo( self, SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2, SkScalar x3, SkScalar y3, ): self.path.cubicTo(x1, y1, x2, y2, x3, y3) cpdef void arcTo( self, SkScalar rx, SkScalar ry, SkScalar xAxisRotate, ArcSize largeArc, Direction sweep, SkScalar x, SkScalar y, ): self.path.arcTo( SkPoint.Make(rx, ry), xAxisRotate, largeArc, sweep, SkPoint.Make(x, y) ) cpdef void close(self): self.path.close() cpdef void reset(self): self.path.reset() cpdef void rewind(self): """Deprecated alias for reset(). Use reset() instead.""" self.path.reset() cpdef draw(self, pen): cdef str method cdef tuple pts for method, pts in self.segments: getattr(pen, method)(*pts) def dump(self, cpp=False, as_hex=False): # print a text repesentation to stdout if cpp: # C++ if as_hex: self.path.dump(SkPathBuilder.DumpFormat.kHex) else: self.path.dump(SkPathBuilder.DumpFormat.kDecimal) else: print(self._to_string(as_hex=as_hex)) # Python def _to_string(self, as_hex=False): # return a text repesentation as Python code if self.path.isEmpty(): return "" if as_hex: coords_to_string = _format_hex_coords else: coords_to_string = lambda fs: (", ".join("%g" % f for f in fs)) s = ["path.fillType = %s" % self.fillType] for verb, pts in self: # if the last pt isn't a pt, such as for conic weight, peel it off suffix = '' if pts and not isinstance(pts[-1], tuple): suffix = "[%s]" % coords_to_string([pts[-1]]) pts = pts[:-1] method = VERB_METHODS[verb] coords = itertools.chain(*pts) line = "path.%s(%s)%s" % (method, coords_to_string(coords), suffix) s.append(line) return "\n".join(s) def __str__(self): return self._to_string() def __repr__(self): return "" % ( hex(id(self)), self.countContours() ) def __len__(self): return self.countContours() def __eq__(self, other): if not isinstance(other, Path): return NotImplemented cdef Path static_other = other return self.path == static_other.path def __ne__(self, other): return not self == other __hash__ = None # Path is a mutable object, let's make it unhashable cpdef addPath(self, Path path): self.path.addPath(path.path.snapshot()) @property def fillType(self): return FillType(self.path.fillType()) @fillType.setter def fillType(self, value): cdef uint32_t fill = int(FillType(value)) self.path.setFillType(fill) @property def isConvex(self): return self.path.snapshot().isConvex() def contains(self, tuple pt): return self.path.contains(SkPoint.Make(pt[0], pt[1])) @property def bounds(self): cdef optional[SkRect] bounds = self.path.computeTightBounds() if not bounds.has_value(): return None cdef SkRect r = bounds.value() return (r.left(), r.top(), r.right(), r.bottom()) @property def controlPointBounds(self): cdef optional[SkRect] bounds = self.path.computeFiniteBounds() if not bounds.has_value(): return None cdef SkRect r = bounds.value() return (r.left(), r.top(), r.right(), r.bottom()) @property def area(self): return fabs(get_path_area(self.path)) @property def clockwise(self): return get_path_area(self.path) < 0 @clockwise.setter def clockwise(self, value): if self.clockwise != value: self.reverse() cpdef reverse(self): cdef Path contour cdef SkPathBuilder skpath skpath.setFillType(self.path.fillType()) for contour in self.contours: reverse_contour(contour.path) skpath.addPath(contour.path.snapshot()) self.path = skpath cpdef simplify( self, bint fix_winding=True, bint keep_starting_points=True, bint clockwise=False, ): cdef list first_points if keep_starting_points: first_points = self.firstPoints cdef optional[SkPath] simplified = Simplify(self.path.snapshot()) if not simplified.has_value(): raise PathOpsError("simplify operation did not succeed") self.path = simplified.value() if fix_winding: winding_from_even_odd(self, clockwise) if keep_starting_points: restore_starting_points(self, first_points) def _has(self, verb): return any(my_verb == verb for my_verb, _ in self) cpdef convertConicsToQuads(self, float tolerance=0.25): # TODO is 0.25 too delicate? - blindly copies from Skias own use if not self._has(SkPathVerb.kConic): return cdef max_pow2 = 5 cdef count = 1 + 2 * (1< PyMem_Malloc(count * sizeof(SkPoint)) if not quad_pts: raise MemoryError() cdef SkPoint *quad = quad_pts cdef SkPathBuilder temp cdef SkPathFillType fillType = self.path.fillType() temp.setFillType(fillType) cdef SkPoint p0 cdef SkPoint p1 cdef SkPoint p2 cdef SkScalar weight cdef int pow2 try: prev = (0., 0.) for verb, pts in self: if verb != SkPathVerb.kConic: if verb != SkPathVerb.kClose: prev_verb = verb prev = pts[-1] # TODO cython got angry when I tried to make this a fn if verb == SkPathVerb.kMove: temp.moveTo(pts[0][0], pts[0][1]) elif verb == SkPathVerb.kLine: temp.lineTo(pts[0][0], pts[0][1]) elif verb == SkPathVerb.kQuad: temp.quadTo(pts[0][0], pts[0][1], pts[1][0], pts[1][1]) elif verb == SkPathVerb.kCubic: temp.cubicTo(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]) elif verb == SkPathVerb.kClose: temp.close() else: raise UnsupportedVerbError(verb) continue # Figure out a good value for pow2 p0 = SkPoint.Make(prev[0], prev[1]) p1 = SkPoint.Make(pts[0][0], pts[0][1]) p2 = SkPoint.Make(pts[1][0], pts[1][1]) weight = pts[2] pow2 = compute_conic_to_quad_pow2(p0, p1, p2, weight, tolerance) assert pow2 <= max_pow2 num_quads = ConvertConicToQuads(p0, p1, p2, weight, quad_pts, pow2) # quad_pts[0] is effectively a moveTo that may be a nop if prev != (quad_pts[0].x(), quad_pts[0].y()): temp.moveTo(quad_pts[0].x(), quad_pts[0].y()) for i in range(num_quads): p1 = quad_pts[2 * i + 1] p2 = quad_pts[2 * i + 2] temp.quadTo(p1.x(), p1.y(), p2.x(), p2.y()) prev = pts[-2] # -1 is weight finally: PyMem_Free(quad_pts) self.path = temp cpdef stroke( self, SkScalar width, LineCap cap, LineJoin join, SkScalar miter_limit, object dash_array=None, SkScalar dash_offset=0.0, ): cdef _SkScalarArray intervals cdef sk_sp[SkPathEffect] dash cdef SkPaint paint = SkPaint() paint.setStyle(SkPaintStyle.kStroke_Style) paint.setStrokeWidth(width) paint.setStrokeCap(cap) paint.setStrokeJoin(join) paint.setStrokeMiter(miter_limit) if dash_array: intervals = _SkScalarArray.create(dash_array) if intervals.count % 2 != 0: raise ValueError("Expected an even number of dash_array entries") paint.setPathEffect( SkDashPathEffect.Make(intervals.as_span(), dash_offset) ) FillPathWithPaint(self.path.detach(), paint, &self.path) cdef list getVerbs(self): return [PathVerb(verb) for verb in self.path.verbs()] @property def verbs(self): return self.getVerbs() cdef list getPoints(self): return [(pt.x(), pt.y()) for pt in self.path.points()] @property def points(self): return self.getPoints() cdef int countContours(self) except -1: if self.path.isEmpty(): return 0 cdef int n = 0 for verb in self.path.verbs(): if verb == SkPathVerb.kMove: n += 1 return n @property def firstPoints(self): cdef SkPoint *p = NULL cdef int count = 0 cdef list result = [] if self.getFirstPoints(&p, &count): for i in range(count): result.append((p[i].x(), p[i].y())) if p is not NULL: PyMem_Free(p) return result cdef int getFirstPoints(self, SkPoint **pp, int *count) except -1: cdef int c = self.path.verbs().size() if c == 0: return 0 # empty cdef SkPoint *points = PyMem_Malloc(c * sizeof(SkPoint)) if not points: raise MemoryError() cdef optional[SkPathIter] iterator = self.path.iter() cdef SkPathVerb verb cdef optional[SkPathIter.Rec] rec cdef int i = 0 while True: rec = iterator.value().next() if not rec.has_value(): break verb = rec.value().fVerb if verb == SkPathVerb.kMove: points[i] = rec.value().fPoints[0] i += 1 points = PyMem_Realloc(points, i * sizeof(SkPoint)) count[0] = i pp[0] = points return 1 @property def contours(self): cdef SkPathBuilder temp cdef SkPathFillType fillType = self.path.fillType() temp.setFillType(fillType) cdef SkPathVerb verb cdef SkSpan[const SkPoint] p cdef optional[SkPathIter] iterator = self.path.iter() cdef optional[SkPathIter.Rec] rec while True: rec = iterator.value().next() if not rec.has_value(): break verb = rec.value().fVerb p = rec.value().fPoints if verb == SkPathVerb.kMove: if not temp.isEmpty(): yield Path.create(temp) temp.reset() temp.setFillType(fillType) temp.moveTo(p[0]) elif verb == SkPathVerb.kLine: temp.lineTo(p[1]) elif verb == SkPathVerb.kQuad: temp.quadTo(p[1], p[2]) elif verb == SkPathVerb.kConic: temp.conicTo(p[1], p[2], rec.value().conicWeight()) elif verb == SkPathVerb.kCubic: temp.cubicTo(p[1], p[2], p[3]) elif verb == SkPathVerb.kClose: temp.close() yield Path.create(temp) temp.reset() temp.setFillType(fillType) else: raise AssertionError(verb) if not temp.isEmpty(): yield Path.create(temp) @property def segments(self): # We need to check for TrueType special quadratic closed spline made of # off-curve points only so that we can make the move point implied. # It's easier to do this in here than inside the SegmentPenIterator, as that # yields each segment one by one, whereas we want to sometimes *not* yield a # moveTo in very specific circumstances (i.e. the whole contour is a single # closed quadratic spline where all the on-curve points are midway between # consecutive off-curve points) based on previous and next segments. cdef SkPoint p1, p2, p3 result = [] it = itertools.chain([None], SegmentPenIterator(self), [None]) for previous, current, next_ in triplewise(it): if ( previous is not None and previous[0] == "moveTo" and current[0] == "qCurveTo" and next_ is not None and next_[0] == "closePath" and previous[1][0] == current[1][-1] ): qpoints = current[1] last_off, move_pt, first_off = qpoints[-2], qpoints[-1], qpoints[0] p1 = SkPoint.Make(last_off[0], last_off[1]) p2 = SkPoint.Make(move_pt[0], move_pt[1]) p3 = SkPoint.Make(first_off[0], first_off[1]) if is_middle_point(p1, p2, p3): # drop the moveTo and make the last on-curve None del result[-1] result.append((current[0], current[1][:-1] + (None,))) continue result.append(current) yield from result cpdef Path transform( self, SkScalar scaleX=1, SkScalar skewY=0, SkScalar skewX=0, SkScalar scaleY=1, SkScalar translateX=0, SkScalar translateY=0, SkScalar perspectiveX=0, SkScalar perspectiveY=0, SkScalar perspectiveBias=1, ): """Apply 3x3 transformation matrix and return new transformed Path. SkMatrix stores the values in row-major order: [ scaleX skewX transX skewY scaleY transY perspX perspY perspBias ] However here the first 6 parameters are in column-major order, like the affine matrix vectors from SVG transform attribute: [ a c e b d f => [a b c d e f] 0 0 1 ] This is so one can easily unpack a 6-tuple as positional arguments to this method. >>> p1 = Path() >>> p1.moveTo(1, 2) >>> p1.lineTo(3, 4) >>> affine = (2, 0, 0, 2, 0, 0) >>> p2 = p1.transform(*affine) >>> list(p2.segments) == [ ... ('moveTo', ((2.0, 4.0),)), ... ('lineTo', ((6.0, 8.0),)), ... ('endPath', ()), ... ] True """ cdef SkMatrix matrix = SkMatrix.MakeAll( scaleX, skewX, translateX, skewY, scaleY, translateY, perspectiveX, perspectiveY, perspectiveBias, ) cdef Path result = Path.create(self.path) result.path.transform(matrix) return result DEF NUM_VERBS = 7 cdef uint8_t *POINTS_IN_VERB = [ 1, # MOVE 1, # LINE 2, # QUAD 2, # CONIC 3, # CUBIC 0, # CLOSE 0 # DONE ] cdef dict VERB_METHODS = { SkPathVerb.kMove: "moveTo", SkPathVerb.kLine: "lineTo", SkPathVerb.kQuad: "quadTo", SkPathVerb.kConic: "conicTo", SkPathVerb.kCubic: "cubicTo", SkPathVerb.kClose: "close", } cdef dict PEN_METHODS = { SkPathVerb.kMove: "moveTo", SkPathVerb.kLine: "lineTo", SkPathVerb.kQuad: "qCurveTo", SkPathVerb.kCubic: "curveTo", SkPathVerb.kClose: "closePath", } cdef tuple NO_POINTS = () cdef class RawPathIterator: def __cinit__(self, Path path): self.path = path self.iterator = self.path.path.iter() def __iter__(self): return self def __next__(self): cdef tuple pts cdef SkPathVerb verb cdef SkSpan[const SkPoint] p rec = self.iterator.value().next() if not rec.has_value(): raise StopIteration() verb = rec.value().fVerb p = rec.value().fPoints if verb == SkPathVerb.kMove: pts = ((p[0].x(), p[0].y()),) elif verb == SkPathVerb.kLine: pts = ((p[1].x(), p[1].y()),) elif verb == SkPathVerb.kQuad: pts = ((p[1].x(), p[1].y()), (p[2].x(), p[2].y())) elif verb == SkPathVerb.kConic: pts = ((p[1].x(), p[1].y()), (p[2].x(), p[2].y()), rec.value().conicWeight()) elif verb == SkPathVerb.kCubic: pts = ((p[1].x(), p[1].y()), (p[2].x(), p[2].y()), (p[3].x(), p[3].y())) elif verb == SkPathVerb.kClose: pts = NO_POINTS else: raise UnsupportedVerbError(verb) return (PathVerb(verb), pts) cdef tuple END_PATH = ("endPath", NO_POINTS) cdef tuple CLOSE_PATH = ("closePath", NO_POINTS) cdef class SegmentPenIterator: def __cinit__(self, Path path): self.path = Path.create(path.path) self.pts = self.path.path.points().begin() self.verbs = self.path.path.verbs().begin() - 1 # TODO: UB self.verb_stop = self.path.path.verbs().end() self.move_pt = SkPoint.Make(.0, .0) self.closed = True def __iter__(self): return self def __next__(self): cdef tuple points cdef SkPathVerb verb self.verbs += 1 if self.verbs >= self.verb_stop: if not self.closed: self.closed = True return END_PATH else: raise StopIteration() else: verb = self.verbs[0] if verb == SkPathVerb.kMove: # skia contours are implicitly open, unless they end with "close" if not self.closed: self.closed = True self.verbs -= 1 return END_PATH self.move_pt = self.pts[0] self.closed = False points = ((self.pts[0].x(), self.pts[0].y()),) self.pts += 1 elif verb == SkPathVerb.kClose: self.closed = True return CLOSE_PATH elif verb == SkPathVerb.kLine: if ( self.nextIsClose() and points_almost_equal(self.pts[0], self.move_pt) ): # skip closing lineTo if contour's last point ~= first points = ((self.move_pt.x(), self.move_pt.y()),) else: points = ((self.pts[0].x(), self.pts[0].y()),) self.pts += 1 elif verb == SkPathVerb.kQuad: points = self._join_quadratic_segments() elif verb == SkPathVerb.kCubic: if ( self.nextIsClose() and points_almost_equal(self.pts[2], self.move_pt) ): # skip closing lineTo if contour's last point ~= first points = ( (self.pts[0].x(), self.pts[0].y()), (self.pts[1].x(), self.pts[1].y()), (self.move_pt.x(), self.move_pt.y()), ) else: points = ( (self.pts[0].x(), self.pts[0].y()), (self.pts[1].x(), self.pts[1].y()), (self.pts[2].x(), self.pts[2].y()), ) self.pts += 3 else: raise UnsupportedVerbError(PathVerb(verb).name) cdef str method = PEN_METHODS[verb] return (method, points) cdef inline bint nextIsClose(self): if self.verbs + 1 < self.verb_stop: return (self.verbs + 1)[0] == SkPathVerb.kClose else: return 0 cdef tuple _join_quadratic_segments(self): # must only be called when the current verb is kQuad_Verb # assert self.verbs < self.verb_stop and self.verbs[0] == kQuad_Verb cdef const SkPathVerb *verbs = self.verbs cdef const SkPathVerb *next_verb_ptr cdef const SkPoint *pts = self.pts cdef list points = [] while True: # always add the current quad's off-curve point points.append((pts[0].x(), pts[0].y())) # check if the following segments (if any) are also quadratic next_verb_ptr = verbs + 1 if next_verb_ptr != self.verb_stop: if next_verb_ptr[0] == SkPathVerb.kQuad: if is_middle_point(pts[0], pts[1], pts[2]): # skip TrueType "implied" on-curve point, and keep # evaluating the next quadratic segment verbs = next_verb_ptr pts += 2 continue elif ( next_verb_ptr[0] == SkPathVerb.kClose and points_almost_equal(pts[1], self.move_pt) ): # last segment on a closed contour: make sure there is no # extra closing lineTo when the last point is almost equal # to the moveTo point points.append((self.move_pt.x(), self.move_pt.y())) pts += 2 break # no more segments, or the next segment isn't quadratic, or it is # but the on-curve point doesn't interpolate half-way in between # the respective off-curve points; add on-curve and exit the loop points.append((pts[1].x(), pts[1].y())) pts += 2 break self.verbs = verbs self.pts = pts return tuple(points) cdef class PathPen: def __cinit__(self, Path path, object glyphSet=None, bint allow_open_paths=True): self.path = path self.glyphSet = glyphSet self.allow_open_paths = allow_open_paths cpdef moveTo(self, pt): self.path.moveTo(pt[0], pt[1]) cpdef lineTo(self, pt): self.path.lineTo(pt[0], pt[1]) def curveTo(self, *points): num_offcurves = len(points) - 1 if num_offcurves == 2: pt1, pt2, pt3 = points self.path.cubicTo( pt1[0], pt1[1], pt2[0], pt2[1], pt3[0], pt3[1]) elif num_offcurves == 1: pt1, pt2 = points self.path.quadTo(pt1[0], pt1[1], pt2[0], pt2[1]) elif num_offcurves == 0: pt = points[0] self.path.lineTo(pt[0], pt[1]) else: # support BasePen "super-beziers"? Nah. raise NumberOfPointsError( "curveTo requires between 1 and 3 points; got %d" % len(points) ) def qCurveTo(self, *points): num_offcurves = len(points) - 1 if num_offcurves > 0: oncurveless_contour = points[-1] is None if oncurveless_contour: # Special case for TrueType closed contours without on-curve points. # FontTools pens supports this by allowing the last point of qCurveTo # to be None, which is translated as an implied on-curve point between # the last and the first off-curve points: # https://github.com/fonttools/fonttools/blob/02a0636/Lib/fontTools/pens/basePen.py#L332-L344 # https://github.com/fonttools/skia-pathops/issues/45 x, y = points[-2] nx, ny = points[0] implied_pt = (0.5 * (x + nx), 0.5 * (y + ny)) self.moveTo(implied_pt) points = points[:-1] + (implied_pt,) for pt1, pt2 in _decompose_quadratic_segment(points): self._qCurveToOne(pt1, pt2) if oncurveless_contour: # oncurve-less contour is closed by definition self.closePath() elif num_offcurves == 0: self.lineTo(points[0]) else: raise NumberOfPointsError("qCurveTo requires at least 1 point; got 0") cdef _qCurveToOne(self, pt1, pt2): self.path.quadTo(pt1[0], pt1[1], pt2[0], pt2[1]) cpdef closePath(self): self.path.close() cpdef endPath(self): if not self.allow_open_paths: raise OpenPathError() cpdef addComponent(self, glyphName, transformation): if self.glyphSet is None: raise TypeError("Missing required glyphSet; can't decompose components") base_glyph = self.glyphSet[glyphName] cdef Path base_path = Path() base_glyph.draw(base_path.getPen(glyphSet=self.glyphSet)) cdef Path component_path = base_path.transform(*transformation) self.path.addPath(component_path) cdef double get_path_area(const SkPathBuilder& path) except? -1234567: # Adapted from fontTools/pens/areaPen.py cdef double value = .0 cdef SkPathVerb verb cdef SkSpan[const SkPoint] p cdef SkPoint p0, start_point cdef SkScalar x0, y0, x1, y1, x2, y2, x3, y3 cdef optional[SkPathIter] iterator = path.iter() cdef bint need_close = False cdef optional[SkPathIter.Rec] rec p0 = start_point = SkPoint.Make(.0, .0) while True: rec = iterator.value().next() if not rec.has_value(): break verb = rec.value().fVerb p = rec.value().fPoints if verb == SkPathVerb.kMove: if need_close: x0, y0 = p0.x(), p0.y() x1, y1 = start_point.x(), start_point.y() value -= (x1 - x0) * (y1 + y0) * .5 p0 = start_point = p[0] need_close = True elif verb == SkPathVerb.kLine: x0, y0 = p0.x(), p0.y() x1, y1 = p[1].x(), p[1].y() value -= (x1 - x0) * (y1 + y0) * .5 p0 = p[1] elif verb == SkPathVerb.kQuad: # https://github.com/Pomax/bezierinfo/issues/44 x0, y0 = p0.x(), p0.y() x1, y1 = p[1].x() - x0, p[1].y() - y0 x2, y2 = p[2].x() - x0, p[2].y() - y0 value -= (x2 * y1 - x1 * y2) / 3 value -= (p[2].x() - x0) * (p[2].y() + y0) * .5 p0 = p[2] elif verb == SkPathVerb.kConic: raise UnsupportedVerbError("CONIC") elif verb == SkPathVerb.kCubic: # https://github.com/Pomax/bezierinfo/issues/44 x0, y0 = p0.x(), p0.y() x1, y1 = p[1].x() - x0, p[1].y() - y0 x2, y2 = p[2].x() - x0, p[2].y() - y0 x3, y3 = p[3].x() - x0, p[3].y() - y0 value -= ( x1 * ( - y2 - y3) + x2 * (y1 - 2*y3) + x3 * (y1 + 2*y2 ) ) * 0.15 value -= (p[3].x() - x0) * (p[3].y() + y0) * .5 p0 = p[3] elif verb == SkPathVerb.kClose: x0, y0 = p0.x(), p0.y() x1, y1 = start_point.x(), start_point.y() value -= (x1 - x0) * (y1 + y0) * .5 p0 = start_point = SkPoint.Make(.0, .0) need_close = False else: raise AssertionError(verb) if need_close: x0, y0 = p0.x(), p0.y() x1, y1 = start_point.x(), start_point.y() value -= (x1 - x0) * (y1 + y0) * .5 return value cdef class _SkScalarArray: @staticmethod cdef _SkScalarArray create(object values): # 'values' must be a sequence (e.g. list or tuple) of floats cdef _SkScalarArray self = _SkScalarArray.__new__(_SkScalarArray) self.count = len(values) self.data = PyMem_Malloc(self.count * sizeof(SkScalar)) if not self.data: raise MemoryError() for i, v in enumerate(values): self.data[i] = v return self def __dealloc__(self): PyMem_Free(self.data) # no-op if data is NULL cdef SkSpan[SkScalar] as_span(self): return SkSpan[SkScalar](self.data, self.count) cdef inline int pts_in_verb(SkPathVerb v) except -1: if v >= NUM_VERBS: raise IndexError(v) return POINTS_IN_VERB[v] cdef bint reverse_contour(SkPathBuilder& path) except False: cdef SkPathBuilder temp cdef SkPoint lastPt cdef optional[SkPoint] maybeLastPt = path.getLastPt() if not maybeLastPt.has_value(): return True # ignore empty path lastPt = maybeLastPt.value() cdef SkSpan[const SkPathVerb] va = path.verbs() cdef const SkPathVerb *verbsStart = va.begin() # pointer to the first verb cdef const SkPathVerb *verbs = va.end() - 1 # pointer to the last verb cdef SkSpan[const SkPoint] pa = path.points() cdef const SkPoint *pts = pa.end() - 1 # pointer to the last point # the last point becomes the first temp.moveTo(lastPt) cdef SkPathVerb v cdef bint closed = False # loop over both arrays in reverse, break before the first verb while verbs > verbsStart: v = verbs[0] verbs -= 1 pts -= pts_in_verb(v) if v == SkPathVerb.kMove: # if the path has multiple contours, stop after reversing the last break elif v == SkPathVerb.kLine: temp.lineTo(pts[0]) elif v == SkPathVerb.kQuad: temp.quadTo(pts[1], pts[0]) elif v == SkPathVerb.kConic: raise UnsupportedVerbError("CONIC") elif v == SkPathVerb.kCubic: temp.cubicTo(pts[2], pts[1], pts[0]) elif v == SkPathVerb.kClose: closed = True else: raise AssertionError(v) if closed: temp.close() temp.setFillType(path.fillType()) # assignment to references is allowed in C++ but Cython doesn't support it # https://github.com/cython/cython/issues/1863 # path = temp (&path)[0] = temp return True # NOTE This is meant to be used only on simplified paths (i.e. without # overlapping contours), like the ones returned from Skia's path operations. # It only tests the bounding boxes and the on-curve points. cdef int path_is_inside(const SkPathBuilder& self, const SkPathBuilder& other) except -1: cdef optional[SkRect] r1, r2 cdef SkPathVerb verb cdef SkSpan[const SkPoint] p cdef SkPoint oncurve r1 = self.computeTightBounds() r2 = other.computeTightBounds() if not r1.has_value() or not r2.has_value() or not SkRect.Intersects(r1.value(), r2.value()): return 0 cdef optional[SkPathIter] iterator = other.iter() cdef optional[SkPathIter.Rec] rec while True: rec = iterator.value().next() if not rec.has_value(): break verb = rec.value().fVerb p = rec.value().fPoints if verb == SkPathVerb.kMove: oncurve = p[0] elif verb == SkPathVerb.kLine: oncurve = p[1] elif verb == SkPathVerb.kQuad: oncurve = p[2] elif verb == SkPathVerb.kConic: raise UnsupportedVerbError("CONIC") elif verb == SkPathVerb.kCubic: oncurve = p[3] elif verb == SkPathVerb.kClose: continue else: raise AssertionError(verb) if not self.contains(oncurve): return 0 return 1 @cython.wraparound(False) @cython.boundscheck(False) cpdef int restore_starting_points(Path path, list points) except -1: if not points: return 0 cdef list contours = list(path.contours) cdef Py_ssize_t n = len(contours) cdef Py_ssize_t m = len(points) cdef int i, j cdef Path this cdef bint modified = False for i in range(n): this = contours[i] for j in range(m): pt = points[j] if set_contour_start_point(this.path, pt[0], pt[1]): modified = True # we don't retry the same point again on a different contour del points[j] m -= 1 break if not modified: return 0 path.path.reset() for i in range(n): this = contours[i] path.path.addPath(this.path.detach()) return 1 DEF DEBUG_WINDING = False @cython.wraparound(False) @cython.boundscheck(False) cpdef bint winding_from_even_odd(Path path, bint clockwise=False) except False: """ Take a simplified path (without overlaps) and set the contours directions according to the non-zero winding fill type. The outermost contours are set to counter-clockwise direction, unless 'clockwise' is True. """ # TODO re-enable this once the new feature is stabilized in upstream skia # https://github.com/fonttools/skia-pathops/issues/10 # if AsWinding(path.path, &path.path): # if path.clockwise ^ clockwise: # path.reverse() # return True # # # in the unlikely event the built-in method fails, try our naive approach cdef int i, j cdef bint inverse = not clockwise cdef bint is_clockwise, is_even cdef Path contour, other # sort contours by area, from largest to smallest cdef dict contours_by_area = {} cdef object area for contour in path.contours: area = -fabs(get_path_area(contour.path)) if area not in contours_by_area: contours_by_area[area] = [] contours_by_area[area].append(contour) cdef list group cdef list contours = [] for _, group in sorted(contours_by_area.items()): contours.extend(group) cdef Py_ssize_t n = len(contours) # XXX permature optimization? needs profile cdef size_t* nested nested = PyMem_Malloc(n * sizeof(size_t)) if not nested: raise MemoryError() memset(nested, 0, n * sizeof(size_t)) try: # increment the nesting level when a contour is inside another for i in range(n): contour = contours[i] for j in range(i + 1, n): other = contours[j] if path_is_inside(contour.path, other.path): nested[j] += 1 IF DEBUG_WINDING: print("nested: ", end="") for i in range(n): print(nested[i], end=" ") print("") # reverse a contour when its winding and even-odd number disagree; # for TrueType, set the outermost direction to clockwise for i in range(n): contour = contours[i] is_clockwise = get_path_area(contour.path) < .0 is_even = not (nested[i] & 1) IF DEBUG_WINDING: print( "%d: inverse=%s is_clockwise=%s is_even=%s" % (i, inverse, is_clockwise, is_even) ) if inverse ^ is_clockwise ^ is_even: IF DEBUG_WINDING: print("reverse_contour %d" % i) reverse_contour(contour.path) finally: PyMem_Free(nested) path.path.reset() for i in range(n): contour = contours[i] path.path.addPath(contour.path.detach()) path.path.setFillType(SkPathFillType.kWinding) return True def decompose_quadratic_segment(points): return _decompose_quadratic_segment(points) cdef list _decompose_quadratic_segment(tuple points): cdef: int i, n = len(points) - 1 list quad_segments = [] SkScalar x, y, nx, ny tuple implied_pt assert n > 0 for i in range(n - 1): x, y = points[i] nx, ny = points[i+1] implied_pt = (0.5 * (x + nx), 0.5 * (y + ny)) quad_segments.append((points[i], implied_pt)) quad_segments.append((points[-2], points[-1])) return quad_segments cdef int find_oncurve_point( SkScalar x, SkScalar y, const SkPoint *pts, int pt_count, const SkPathVerb *verbs, int verb_count, int *pt_index, int *verb_index, ) except -1: cdef SkPoint oncurve cdef SkPathVerb v cdef int i, j, n cdef int seen = 0 for i in range(verb_count): v = verbs[i] n = pts_in_verb(v) if n == 0: continue assert seen + n <= pt_count j = seen + n - 1 oncurve = pts[j] if oncurve.equals(x, y): pt_index[0] = j verb_index[0] = i return 1 seen += n return 0 cdef int contour_is_closed(SkSpan[const SkPathVerb] verbs) except -1: cdef SkPathVerb v cdef bint closed = False if verbs.empty(): return closed for verb in verbs.subspan(1): if verb == SkPathVerb.kMove: raise ValueError("expected single contour") elif verb == SkPathVerb.kClose: closed = True return closed cdef int set_contour_start_point(SkPathBuilder& path, SkScalar x, SkScalar y) except -1: cdef SkSpan[const SkPathVerb] va = path.verbs() cdef const SkPathVerb *verbs = va.data() cdef size_t verb_count = va.size() cdef SkSpan[const SkPoint] pa = path.points() cdef const SkPoint *pts = pa.data() cdef size_t pt_count = pa.size() cdef bint closed = contour_is_closed(va) cdef int pt_index = -1 cdef int verb_index = -1 cdef bint found = find_oncurve_point( x, y, pts, pt_count, verbs, verb_count, &pt_index, &verb_index, ) if not found or pt_index == 0 or ( not closed and pt_index != (pt_count - 1) ): return 0 if not closed and pt_index == (pt_count - 1): reverse_contour(path) return 1 cdef SkPathBuilder temp temp.setFillType(path.fillType()) cdef SkPathVerb first_verb cdef SkPoint first_pt cdef int vi, pi first_verb = verbs[verb_index] vi = (verb_index + 1) % verb_count first_pt = pts[pt_index] pi = (pt_index + 1) % pt_count temp.moveTo(first_pt) cdef int i, n cdef SkPathVerb v cdef const SkPoint *last = &first_pt for i in range(1, verb_count): v = verbs[vi] n = pts_in_verb(v) assert pi + n <= pt_count if v == SkPathVerb.kMove: # the moveTo from the original contour is converted to a lineTo, # unless it's equal to the previous point, or collinear between # the last oncuve point and the next line segment # https://github.com/fonttools/skia-pathops/issues/12 if ( points_almost_equal(last[0], pts[pi]) or ( verbs[(vi + 1) % verb_count] == SkPathVerb.kLine and collinear(last[0], pts[pi], pts[(pi + 1) % pt_count]) ) ): pass else: temp.lineTo(pts[pi]) last = pts + pi elif v == SkPathVerb.kLine: # skip adding lineTo if it's the last segment from the original # contour and overlaps with the old moveTo point if ( verbs[(vi + 1) % verb_count] == SkPathVerb.kClose and points_almost_equal(pts[pi], pts[(pi + 1) % pt_count]) ): pass else: temp.lineTo(pts[pi]) last = pts + pi elif v == SkPathVerb.kQuad: temp.quadTo(pts[pi], pts[pi + 1]) last = pts + pi + 1 elif v == SkPathVerb.kConic: raise UnsupportedVerbError("CONIC") elif v == SkPathVerb.kCubic: temp.cubicTo(pts[pi], pts[pi + 1], pts[pi + 2]) last = pts + pi + 2 elif v == SkPathVerb.kClose: pass else: raise AssertionError(v) vi = (vi + 1) % verb_count pi = (pi + n) % pt_count if first_verb == SkPathVerb.kQuad: temp.quadTo(pts[pi], pts[pi + 1]) elif first_verb == SkPathVerb.kCubic: temp.cubicTo(pts[pi], pts[pi + 1], pts[pi + 2]) temp.close() (&path)[0] = temp return 1 DEF MAX_CONIC_TO_QUAD_POW2 = 5 cdef int compute_conic_to_quad_pow2( SkPoint p0, SkPoint p1, SkPoint p2, SkScalar weight, SkScalar tol ) except -1: # Return the power-of-2 number of quads needed to approximate this conic # with a sequence of quads (will be >= 0). This is used to determine the optimal # (within tolerance) 'pow2' parameter when calling SkPath::ConvertConicToQuads. # Copied from SkConic::computeQuadPOW2 method in src/core/SkGeometry.cpp: # https://github.com/google/skia/blob/52a4379f03f7cd4e1c67eb69a756abc5838a658f/src/core/SkGeometry.cpp#L1198-L1231 if tol < 0 or not all( isfinite(v) for v in (tol, weight, p0.x(), p0.y(), p1.x(), p1.y(), p2.x(), p2.y()) ): return 0 cdef SkScalar a = weight - 1 cdef SkScalar k = a / (4 * (2 + a)) cdef SkScalar x = k * (p0.x() - 2 * p1.x() + p2.x()) cdef SkScalar y = k * (p0.y() - 2 * p1.y() + p2.y()) cdef SkScalar error = sqrt(x * x + y * y) cdef int pow2 for pow2 in range(MAX_CONIC_TO_QUAD_POW2): if error <= tol: break error *= 0.25 return pow2 cpdef Path op( Path one, Path two, SkPathOp operator, bint fix_winding=True, bint keep_starting_points=True, bint clockwise=False, ): cdef list first_points if keep_starting_points: first_points = one.firstPoints + two.firstPoints cdef optional[SkPath] skresult = Op(one.path.snapshot(), two.path.snapshot(), operator) if not skresult.has_value(): raise PathOpsError("operation did not succeed") cdef Path result = Path() result.path = skresult.value() if fix_winding: winding_from_even_odd(result, clockwise) if keep_starting_points: restore_starting_points(result, first_points) return result cpdef Path simplify( Path path, bint fix_winding=True, bint keep_starting_points=True, bint clockwise=False, ): cdef list first_points if keep_starting_points: first_points = path.firstPoints cdef optional[SkPath] skresult = Simplify(path.path.snapshot()) if not skresult.has_value(): raise PathOpsError("operation did not succeed") cdef Path result = Path() result.path = skresult.value() if fix_winding: winding_from_even_odd(result, clockwise) if keep_starting_points: restore_starting_points(result, first_points) return result cdef class OpBuilder: def __init__( self, bint fix_winding=True, bint keep_starting_points=True, bint clockwise=False, ): self.fix_winding = fix_winding self.keep_starting_points = keep_starting_points self.first_points = [] self.clockwise = clockwise cpdef add(self, Path path, SkPathOp operator): self.builder.add(path.path.snapshot(), operator) if self.keep_starting_points: self.first_points.extend(path.firstPoints) cpdef Path resolve(self): cdef optional[SkPath] skresult = self.builder.resolve() if not skresult.has_value(): raise PathOpsError("operation did not succeed") cdef Path result = Path() result.path = skresult.value() if self.fix_winding: winding_from_even_odd(result, self.clockwise) if self.keep_starting_points: restore_starting_points(result, self.first_points) return result # Doctests def test_collinear(p1, p2, p3): """ >>> test_collinear((0.0, 0.0), (1.0, 1.0), (2.0, 2.0001)) True >>> test_collinear((0.0, 0.0), (1.0, 1.0), (2.0, 2.001)) False """ cdef SkPoint sp1, sp2, sp3 sp1 = SkPoint.Make(p1[0], p1[1]) sp2 = SkPoint.Make(p2[0], p2[1]) sp3 = SkPoint.Make(p3[0], p3[1]) return collinear(sp1, sp2, sp3) skia-pathops-0.9.2/src/python/pathops/_skia/000077500000000000000000000000001514456744000210135ustar00rootroot00000000000000skia-pathops-0.9.2/src/python/pathops/_skia/__init__.pxd000066400000000000000000000000001514456744000232550ustar00rootroot00000000000000skia-pathops-0.9.2/src/python/pathops/_skia/core.pxd000066400000000000000000000160571514456744000224710ustar00rootroot00000000000000from libc.stdint cimport uint8_t from libcpp.optional cimport optional ctypedef float SkScalar cdef extern from "include/core/SkSpan.h": cdef cppclass SkSpan[T]: SkSpan() SkSpan(T* data, size_t size) SkSpan[T] subspan(size_t offset) const T& operator[](size_t) const bint empty() const size_t size() const T* data() const T* begin() const T* end() const cdef extern from "include/core/SkPathTypes.h": enum SkPathFillType: kWinding "SkPathFillType::kWinding", kEvenOdd "SkPathFillType::kEvenOdd", kInverseWinding "SkPathFillType::kInverseWinding", kInverseEvenOdd "SkPathFillType::kInverseEvenOdd" enum SkPathDirection: kCW "SkPathDirection::kCW" kCCW "SkPathDirection::kCCW" enum class SkPathVerb(uint8_t): kMove "SkPathVerb::kMove" kLine "SkPathVerb::kLine" kQuad "SkPathVerb::kQuad" kConic "SkPathVerb::kConic" kCubic "SkPathVerb::kCubic" kClose "SkPathVerb::kClose" cdef extern from "include/core/SkMatrix.h": cdef cppclass SkMatrix: SkMatrix() except + @staticmethod SkMatrix MakeAll( SkScalar scaleX, SkScalar skewX, SkScalar transX, SkScalar skewY, SkScalar scaleY, SkScalar transY, SkScalar pers0, SkScalar pers1, SkScalar pers2, ) cdef extern from "include/core/SkPoint.h": cdef cppclass SkPoint: @staticmethod SkPoint Make(SkScalar x, SkScalar y) SkScalar x() SkScalar y() bint equals(SkScalar x, SkScalar y) bint operator==(const SkPoint& other) bint operator!=(const SkPoint& other) cdef extern from "include/core/SkPath.h": cdef cppclass SkPath: SkPath() except + SkPath(SkPath& path) except + bint operator==(const SkPath& other) bint operator!=(const SkPath& other) void dump() void dumpHex() SkPathFillType getFillType() bint isConvex() bint contains(SkScalar x, SkScalar y) const SkRect& getBounds() SkRect computeTightBounds() int countPoints() SkPoint getPoint(int index) int getPoints(SkPoint points[], int maximum) int countVerbs() bint isEmpty() int getVerbs(uint8_t verbs[], int maximum) bint getLastPt(SkPoint* lastPt) cdef extern from * namespace "SkPath": cdef int ConvertConicToQuads(const SkPoint& p0, const SkPoint& p1, const SkPoint& p2, SkScalar w, SkPoint pts[], int pow2) cdef extern from "include/core/SkPathIter.h": cdef cppclass SkPathIter: cppclass Rec: SkSpan[const SkPoint] fPoints SkPathVerb fVerb float conicWeight() const optional[Rec] next() cdef extern from "include/core/SkPathBuilder.h": enum SkArcSize "SkPathBuilder::ArcSize": kSmall_ArcSize "SkPathBuilder::kSmall_ArcSize" kLarge_ArcSize "SkPathBuilder::kLarge_ArcSize" cdef cppclass SkPathBuilder: SkPathBuilder() except + SkPathBuilder(SkPath& path) except + SkPathBuilder(SkPathBuilder& path) except + SkPathBuilder& operator=(const SkPath&) SkPathBuilder& operator=(const SkPathBuilder&) bint operator==(const SkPathBuilder&) bint operator!=(const SkPathBuilder&) enum class DumpFormat "SkPathBuilder::DumpFormat": kDecimal "SkPathBuilder::DumpFormat::kDecimal", kHex "SkPathBuilder::DumpFormat::kHex" void dump(DumpFormat) void moveTo(SkScalar x, SkScalar y) void moveTo(const SkPoint& p) void lineTo(SkScalar x, SkScalar y) void lineTo(const SkPoint& p) void cubicTo( SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2, SkScalar x3, SkScalar y3) void cubicTo(const SkPoint& p1, const SkPoint& p2, const SkPoint& p3) void quadTo(SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2) void quadTo(const SkPoint& p1, const SkPoint& p2) void conicTo(SkScalar x1, SkScalar y1, SkScalar x2, SkScalar y2, SkScalar w) void conicTo(const SkPoint& p1, const SkPoint& p2, SkScalar w) void arcTo(const SkPoint& r, SkScalar xAxisRotate, SkArcSize largeArc, SkPathDirection sweep, const SkPoint& xy) void close() void transform(const SkMatrix& matrix) void reset() SkPath detach() SkPath snapshot() void setFillType(SkPathFillType ft) SkPathFillType fillType() # TODO also expose optional AddPathMode enum void addPath(const SkPath& src) except + bint contains(SkPoint) optional[SkRect] computeFiniteBounds() optional[SkRect] computeTightBounds() bint isEmpty() const SkSpan[const SkPoint] points() const optional[SkPoint] getLastPt() const SkSpan[const SkPathVerb] verbs() const SkPathIter iter() const cdef extern from "include/core/SkRect.h": cdef cppclass SkRect: SkScalar left() SkScalar top() SkScalar right() SkScalar bottom() @staticmethod bint Intersects(const SkRect& a, const SkRect& b) cdef extern from "include/core/SkScalar.h": cdef enum: SK_ScalarNearlyZero # 'opaque' types used by SkDashPathEffect::Make and SkPaint::setPathEffect cdef extern from "include/core/SkRefCnt.h": cdef cppclass sk_sp[T]: pass cdef extern from "include/core/SkPathEffect.h": cdef cppclass SkPathEffect: pass cdef extern from "include/effects/SkDashPathEffect.h": cdef cppclass SkDashPathEffect: @staticmethod sk_sp[SkPathEffect] Make(SkSpan[const SkScalar] intervals, SkScalar phase) cdef extern from "include/core/SkPaint.h": enum SkPaintStyle "SkPaint::Style": kFill_Style "SkPaint::Style::kFill_Style", kStroke_Style "SkPaint::Style::kStroke_Style", kStrokeAndFill_Style "SkPaint::Style::kStrokeAndFill_Style", enum SkLineCap "SkPaint::Cap": kButt_Cap "SkPaint::Cap::kButt_Cap", kRound_Cap "SkPaint::Cap::kRound_Cap", kSquare_Cap "SkPaint::Cap::kSquare_Cap" enum SkLineJoin "SkPaint::Join": kMiter_Join "SkPaint::Join::kMiter_Join", kRound_Join "SkPaint::Join::kRound_Join", kBevel_Join "SkPaint::Join::kBevel_Join" cdef cppclass SkPaint: SkPaint() void setStyle(SkPaintStyle style) void setStrokeWidth(SkScalar width) void setStrokeCap(SkLineCap cap) void setStrokeJoin(SkLineJoin join) void setStrokeMiter(SkScalar miter) void setPathEffect(sk_sp[SkPathEffect] pathEffect) bint getFillPath(const SkPath& src, SkPath* dst) const cdef extern from "include/core/SkPathUtils.h" namespace "skpathutils": cdef bint FillPathWithPaint(const SkPath& src, const SkPaint& paint, SkPathBuilder* dst) skia-pathops-0.9.2/src/python/pathops/_skia/pathops.pxd000066400000000000000000000015251514456744000232110ustar00rootroot00000000000000from .core cimport SkPath from libcpp.optional cimport optional cdef extern from "include/pathops/SkPathOps.h": enum SkPathOp: kDifference_SkPathOp, # subtract the op path from the first path kIntersect_SkPathOp, # intersect the two paths kUnion_SkPathOp, # union (inclusive-or) the two paths kXOR_SkPathOp, # exclusive-or the two paths kReverseDifference_SkPathOp # subtract the first path from the op path optional[SkPath] Op(const SkPath& one, const SkPath& two, SkPathOp op) optional[SkPath] Simplify(const SkPath& path) optional[SkPath] AsWinding(const SkPath& path) cdef cppclass SkOpBuilder: SkOpBuilder() except + void add(const SkPath& path, SkPathOp _operator) optional[SkPath] resolve() skia-pathops-0.9.2/src/python/pathops/operations.py000066400000000000000000000023311514456744000224610ustar00rootroot00000000000000from functools import partial from . import Path, PathOp, op __all__ = [ "difference", "intersection", "reverse_difference", "union", "xor", ] def _draw(contours): path = Path() pen = path.getPen() for contour in contours: contour.draw(pen) return path def union( contours, outpen, fix_winding=True, keep_starting_points=True, clockwise=False, ): if not contours: return path = _draw(contours) path.simplify( fix_winding=fix_winding, keep_starting_points=keep_starting_points, clockwise=clockwise, ) path.draw(outpen) def _do( operator, subject_contours, clip_contours, outpen, fix_winding=True, keep_starting_points=True, clockwise=False, ): one = _draw(subject_contours) two = _draw(clip_contours) result = op( one, two, operator, fix_winding=fix_winding, keep_starting_points=keep_starting_points, clockwise=clockwise, ) result.draw(outpen) # generate self-similar operations for operation in PathOp: if operation == PathOp.UNION: continue globals()[operation.name.lower()] = partial(_do, operation) skia-pathops-0.9.2/tests/000077500000000000000000000000001514456744000153015ustar00rootroot00000000000000skia-pathops-0.9.2/tests/operations_test.py000066400000000000000000000024321514456744000210760ustar00rootroot00000000000000from pathops import Path, PathVerb from pathops.operations import union, difference, intersection, reverse_difference, xor import pytest @pytest.mark.parametrize( "subject_path, clip_path, expected", [ [ [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((0, 10),)), (PathVerb.LINE, ((10, 10),)), (PathVerb.LINE, ((10, 0),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((5, 5),)), (PathVerb.LINE, ((5, 15),)), (PathVerb.LINE, ((15, 15),)), (PathVerb.LINE, ((15, 5),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((5, 5),)), (PathVerb.LINE, ((10, 5),)), (PathVerb.LINE, ((10, 10),)), (PathVerb.LINE, ((5, 10),)), (PathVerb.CLOSE, ()), ], ] ], ) def test_intersection(subject_path, clip_path, expected): sub = Path() for verb, pts in subject_path: sub.add(verb, *pts) clip = Path() for verb, pts in clip_path: clip.add(verb, *pts) result = Path() intersection([sub], [clip], result.getPen()) assert list(result) == expected skia-pathops-0.9.2/tests/pathops_test.py000066400000000000000000000765361514456744000204110ustar00rootroot00000000000000from pathops import ( Path, PathPen, OpenPathError, OpBuilder, PathOp, PathVerb, FillType, bits2float, float2bits, ArcSize, Direction, simplify, NumberOfPointsError, ) import pytest class PathTest(object): def test_init(self): path = Path() assert isinstance(path, Path) def test_getPen(self): path = Path() pen = path.getPen() assert isinstance(pen, PathPen) assert id(pen) != id(path.getPen()) def test_eq_operator(self): path1 = Path() path2 = Path() assert path1 == path2 path1.moveTo(0, 0) assert path1 != path2 path2.moveTo(0, 0) assert path1 == path2 path1.fillType = FillType.EVEN_ODD assert path1 != path2 def test_copy(self): path1 = Path() path2 = Path(path1) assert path1 == path2 def test_draw(self): path = Path() pen = path.getPen() pen.moveTo((0, 0)) pen.lineTo((1.0, 2.0)) pen.curveTo((3.5, 4), (5, 6), (7, 8)) pen.qCurveTo((9, 10), (11, 12)) pen.closePath() pen.qCurveTo((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None) pen.closePath() # always closed for oncurve-less contour path2 = Path() path.draw(path2.getPen()) assert path == path2 assert list(path.segments) == list(path2.segments) assert list(path.segments)[-2:] == [ ('qCurveTo', ((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None)), ('closePath', ()) ] def test_allow_open_contour(self): path = Path() pen = path.getPen() pen.moveTo((0, 0)) # pen.endPath() is implicit here pen.moveTo((1, 0)) pen.lineTo((1, 1)) pen.curveTo((2, 2), (3, 3), (4, 4)) pen.endPath() # In Skia m143+, consecutive moveTo operations collapse: # the second moveTo overwrites the first, so the single-point # contour at (0, 0) is lost assert list(path.segments) == [ ('moveTo', ((1.0, 0.0),)), ('lineTo', ((1.0, 1.0),)), ('curveTo', ((2.0, 2.0), (3.0, 3.0), (4.0, 4.0))), ('endPath', ()), ] def test_raise_open_contour_error(self): path = Path() pen = path.getPen(allow_open_paths=False) pen.moveTo((0, 0)) with pytest.raises(OpenPathError): pen.endPath() def test_decompose_join_quadratic_segments(self): path = Path() pen = path.getPen() pen.moveTo((0, 0)) pen.qCurveTo((1, 1), (2, 2), (3, 3)) pen.closePath() items = list(path) assert len(items) == 4 # the TrueType quadratic spline with N off-curves is stored internally # as N atomic quadratic Bezier segments assert items[1][0] == PathVerb.QUAD assert items[1][1] == ((1.0, 1.0), (1.5, 1.5)) assert items[2][0] == PathVerb.QUAD assert items[2][1] == ((2.0, 2.0), (3.0, 3.0)) # when drawn back onto a SegmentPen, the implicit on-curves are omitted assert list(path.segments) == [ ('moveTo', ((0.0, 0.0),)), ('qCurveTo', ((1.0, 1.0), (2.0, 2.0), (3.0, 3.0))), ('closePath', ())] def test_decompose_join_oncurveless_quadratic_segments(self): # when qCurveTo ends with None this is interpreted in FontTools pen protocol as # a special TrueType closed contour comprising a single quadratic B-spline in # which all the on-curve points are omitted and implied. # https://github.com/fonttools/skia-pathops/issues/45 path = Path() pen = path.getPen() pen.qCurveTo((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None) # pen.closePath() # closed always implied in this case, so this call is no-op items = list(path) assert len(items) == 6 # the TrueType quadratic spline with N off-curves is stored internally # as N atomic quadratic Bezier segments with explicit on-curve points. # A move on-curve point is also added between the last and first off-curves. assert items == [ (PathVerb.MOVE, ((1.0, 0.0),)), (PathVerb.QUAD, ((1.0, 1.0), (0.0, 1.0))), (PathVerb.QUAD, ((-1.0, 1.0), (-1.0, 0.0))), (PathVerb.QUAD, ((-1.0, -1.0), (0.0, -1.0))), (PathVerb.QUAD, ((1.0, -1.0), (1.0, 0.0))), (PathVerb.CLOSE, ()), ] # when drawn back onto a SegmentPen, implicit on-curves are omitted including # the last/move point assert list(path.segments) == [ ('qCurveTo', ((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None)), ('closePath', ()), ] def test_qCurveTo_varargs(self): path = Path() pen = path.getPen() pen.moveTo((0, 0)) pen.qCurveTo((1, 1)) pen.closePath() items = list(path) assert len(items) == 3 # qcurve without offcurves is stored internally as a line assert items[1][0] == PathVerb.LINE assert items[1][1] == ((1.0, 1.0),) assert list(path.segments) == [ ('moveTo', ((0.0, 0.0),)), ('lineTo', ((1.0, 1.0),)), ('closePath', ()), ] with pytest.raises( NumberOfPointsError, match="qCurveTo requires at least 1 point; got 0" ): pen.qCurveTo() def test_curveTo_varargs(self): path = Path() pen = path.getPen() pen.moveTo((0, 0)) pen.curveTo((1, 1), (2, 2), (3, 3)) # a cubic pen.curveTo((4, 4), (5, 5)) # a quadratic pen.curveTo((6, 6)) # a line pen.closePath() assert list(path.segments) == [ ('moveTo', ((0.0, 0.0),)), ('curveTo', ((1.0, 1.0), (2.0, 2.0), (3.0, 3.0))), ('qCurveTo', ((4.0, 4.0), (5.0, 5.0))), ('lineTo', ((6.0, 6.0),)), ('closePath', ()), ] with pytest.raises( NumberOfPointsError, match="curveTo requires between 1 and 3 points; got 0" ): pen.curveTo() with pytest.raises( NumberOfPointsError, match="curveTo requires between 1 and 3 points; got 4" ): pen.curveTo((0, 0), (1, 1), (2, 2), (3, 3)) def test_last_implicit_lineTo(self): # https://github.com/fonttools/skia-pathops/issues/6 path = Path() pen = path.getPen() pen.moveTo((100, 100)) pen.lineTo((100, 200)) pen.closePath() assert list(path.segments) == [ ('moveTo', ((100.0, 100.0),)), ('lineTo', ((100.0, 200.0),)), # ('lineTo', ((100.0, 100.0),)), ('closePath', ())] @staticmethod def path_difference(path1, path2): assert len(path1) == len(path2) for (v1, pts1), (v2, pts2) in zip(path1, path2): assert len(pts1) == len(pts2) yield v1, v2, tuple( (pt1[0] - pt2[0], pt1[1] - pt2[1]) for (pt1, pt2) in zip(pts1, pts2) ) @classmethod def assert_paths_almost_equal(cls, actual, expected, ndigits): from math import fabs, pow assert actual.fillType == expected.fillType pd = cls.path_difference(actual, expected) print(pd) for (av, ev, deltas) in pd: assert av == ev for delta in deltas: assert max(fabs(delta[0]), fabs(delta[1])) <= pow(10, -ndigits) def test_transform(self): path = Path() path.moveTo(125, 376) path.cubicTo(181, 376, 218, 339, 218, 290) path.cubicTo(218, 225, 179, 206, 125, 206) path.close() # t = Transform().rotate(radians(-45)).translate(-100, 0) matrix = (0.707107, -0.707107, 0.707107, 0.707107, -70.7107, 70.7107) result = path.transform(*matrix) expected = Path() expected.moveTo( bits2float(0x438dc663), # 283.55 bits2float(0x437831ce), # 248.195 ) expected.cubicTo( bits2float(0x43a192ee), # 323.148 bits2float(0x435098b8), # 208.597 bits2float(0x43a192ee), # 323.148 bits2float(0x431c454a), # 156.271 bits2float(0x43903ff5), # 288.5 bits2float(0x42f33ead), # 121.622 ) expected.cubicTo( bits2float(0x437289a8), # 242.538 bits2float(0x42975227), # 75.6605 bits2float(0x43498688), # 201.526 bits2float(0x42b39aee), # 89.8026 bits2float(0x4323577c), # 163.342 bits2float(0x42fff906), # 127.986 ) expected.close() # rounding to 3 decimal digit precision, or else for some reasons # the test fails on >4 digits on linux-aarch64 and >3 digits on AVX platforms self.assert_paths_almost_equal(result, expected, ndigits=3) def test_pen_addComponent_missing_required_glyphSet(self): path = Path() pen = path.getPen() with pytest.raises(TypeError, match="Missing required glyphSet"): pen.addComponent("a", (1, 0, 0, 1, 0, 0)) def test_pen_addComponent_decomposed_from_glyphSet(self): a = Path() a.moveTo(0, 0) a.lineTo(1, 0) a.lineTo(1, 1) a.lineTo(0, 1) a.close() glyphSet = {"a": a} b = Path() pen = b.getPen(glyphSet=glyphSet) pen.addComponent("a", (2, 0, 0, 2, 10, 10)) glyphSet["b"] = b assert list(b) == [ (PathVerb.MOVE, ((10, 10),)), (PathVerb.LINE, ((12, 10),)), (PathVerb.LINE, ((12, 12),)), (PathVerb.LINE, ((10, 12),)), (PathVerb.CLOSE, ()), ] c = Path() pen = c.getPen(glyphSet=glyphSet) pen.addComponent("a", (1, 0, 0, 1, 2, 2)) pen.addComponent("b", (1, 0, 0, 1, -10, -10)) glyphSet["c"] = c assert list(c) == [ (PathVerb.MOVE, ((2, 2),)), (PathVerb.LINE, ((3, 2),)), (PathVerb.LINE, ((3, 3),)), (PathVerb.LINE, ((2, 3),)), (PathVerb.CLOSE, ()), (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((2, 0),)), (PathVerb.LINE, ((2, 2),)), (PathVerb.LINE, ((0, 2),)), (PathVerb.CLOSE, ()), ] class OpBuilderTest(object): def test_init(self): builder = OpBuilder() def test_add(self): path = Path() pen = path.getPen() pen.moveTo((5, -225)) pen.lineTo((-225, 7425)) pen.lineTo((7425, 7425)) pen.lineTo((7425, -225)) pen.lineTo((-225, -225)) pen.closePath() builder = OpBuilder() builder.add(path, PathOp.UNION) def test_resolve(self): path1 = Path() pen1 = path1.getPen() pen1.moveTo((5, -225)) pen1.lineTo((-225, 7425)) pen1.lineTo((7425, 7425)) pen1.lineTo((7425, -225)) pen1.lineTo((-225, -225)) pen1.closePath() path2 = Path() pen2 = path2.getPen() pen2.moveTo((5940, 2790)) pen2.lineTo((5940, 2160)) pen2.lineTo((5970, 1980)) pen2.lineTo((5688, 773669888)) pen2.lineTo((5688, 2160)) pen2.lineTo((5688, 2430)) pen2.lineTo((5400, 4590)) pen2.lineTo((5220, 4590)) pen2.lineTo((5220, 4920)) pen2.curveTo((5182.22900390625, 4948.328125), (5160, 4992.78662109375), (5160, 5040.00048828125)) pen2.lineTo((5940, 2790)) pen2.closePath() builder = OpBuilder(fix_winding=False, keep_starting_points=False) builder.add(path1, PathOp.UNION) builder.add(path2, PathOp.UNION) result = builder.resolve() # In Skia m143+, the PathOps simplification is more aggressive: # the pathological input (path2 with extreme spike at 773669888 units) # produces degenerate artifacts that are now simplified away, # leaving only the main contour assert list(result.segments) == [ ("moveTo", ((5688.0, 7425.0),)), ("lineTo", ((-225.0, 7425.0),)), ("lineTo", ((5.0, -225.0),)), ("lineTo", ((7425.0, -225.0),)), ("lineTo", ((7425.0, 7425.0),)), ("lineTo", ((5688.0, 7425.0),)), ("closePath", ()), ] TEST_DATA = [ ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.LINE, ((2, 2),)), (PathVerb.LINE, ((3, 3),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((3, 3),)), (PathVerb.LINE, ((2, 2),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()) ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.LINE, ((2, 2),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((2, 2),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()) ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.LINE, ((2, 2),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((2, 2),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((1, 1),)), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CUBIC, ((1, 1), (2, 2), (3, 3))), (PathVerb.CUBIC, ((4, 4), (5, 5), (0, 0))), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CUBIC, ((5, 5), (4, 4), (3, 3))), (PathVerb.CUBIC, ((2, 2), (1, 1), (0, 0))), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CUBIC, ((1, 1), (2, 2), (3, 3))), (PathVerb.CUBIC, ((4, 4), (5, 5), (6, 6))), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((6, 6),)), (PathVerb.CUBIC, ((5, 5), (4, 4), (3, 3))), (PathVerb.CUBIC, ((2, 2), (1, 1), (0, 0))), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.CUBIC, ((2, 2), (3, 3), (4, 4))), (PathVerb.CUBIC, ((5, 5), (6, 6), (7, 7))), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((7, 7),)), (PathVerb.CUBIC, ((6, 6), (5, 5), (4, 4))), (PathVerb.CUBIC, ((3, 3), (2, 2), (1, 1))), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.QUAD, ((1, 1), (2.5, 2.5))), (PathVerb.QUAD, ((3, 3), (0, 0))), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.QUAD, ((3, 3), (2.5, 2.5))), (PathVerb.QUAD, ((1, 1), (0, 0))), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.QUAD, ((1, 1), (2.5, 2.5))), (PathVerb.QUAD, ((3, 3), (4, 4))), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((4, 4),)), (PathVerb.QUAD, ((3, 3), (2.5, 2.5))), (PathVerb.QUAD, ((1, 1), (0, 0))), (PathVerb.CLOSE, ()), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.QUAD, ((2, 2), (3, 3))), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((3, 3),)), (PathVerb.QUAD, ((2, 2), (1, 1))), (PathVerb.LINE, ((0, 0),)), (PathVerb.CLOSE, ()), ] ), ( [], [] ), ( [ (PathVerb.MOVE, ((0, 0),)), ], # In Skia m143+, SkPathIter strips trailing moveTo verbs, # so a path with only moveTo becomes empty after reversal [], ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CLOSE, ()), ], [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CLOSE, ()), ], ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), ], [ (PathVerb.MOVE, ((1, 1),)), (PathVerb.LINE, ((0, 0),)), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CUBIC, ((1, 1), (2, 2), (3, 3))), ], [ (PathVerb.MOVE, ((3, 3),)), (PathVerb.CUBIC, ((2, 2), (1, 1), (0, 0))), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.CUBIC, ((1, 1), (2, 2), (3, 3))), (PathVerb.LINE, ((4, 4),)), ], [ (PathVerb.MOVE, ((4, 4),)), (PathVerb.LINE, ((3, 3),)), (PathVerb.CUBIC, ((2, 2), (1, 1), (0, 0))), ] ), ( [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((1, 1),)), (PathVerb.CUBIC, ((2, 2), (3, 3), (4, 4))), ], [ (PathVerb.MOVE, ((4, 4),)), (PathVerb.CUBIC, ((3, 3), (2, 2), (1, 1))), (PathVerb.LINE, ((0, 0),)), ] ), # Test case from: # https://github.com/googlei18n/cu2qu/issues/51#issue-179370514 ( [ (PathVerb.MOVE, ((848, 348),)), (PathVerb.LINE, ((848, 348),)), # duplicate lineTo point after moveTo (PathVerb.QUAD, ((848, 526), (748.5, 615))), (PathVerb.QUAD, ((649, 704), (449, 704))), (PathVerb.QUAD, ((449, 704), (348.5, 704))), (PathVerb.QUAD, ((248, 704), (149, 615))), (PathVerb.QUAD, ((50, 526), (50, 348))), (PathVerb.LINE, ((50, 348),)), (PathVerb.QUAD, ((50, 348), (50, 259.5))), (PathVerb.QUAD, ((50, 171), (149, 84))), (PathVerb.QUAD, ((248, -3), (449, -3))), (PathVerb.QUAD, ((449, -3), (549, -3))), (PathVerb.QUAD, ((649, -3), (748.5, 84))), (PathVerb.QUAD, ((848, 171), (848, 348))), (PathVerb.CLOSE, ()) ], [ (PathVerb.MOVE, ((848, 348),)), (PathVerb.QUAD, ((848, 171), (748.5, 84))), (PathVerb.QUAD, ((649, -3), (549, -3))), (PathVerb.QUAD, ((449, -3), (449, -3))), (PathVerb.QUAD, ((248, -3), (149, 84))), (PathVerb.QUAD, ((50, 171), (50, 259.5))), (PathVerb.QUAD, ((50, 348), (50, 348))), (PathVerb.LINE, ((50, 348),)), (PathVerb.QUAD, ((50, 526), (149, 615))), (PathVerb.QUAD, ((248, 704), (348.5, 704))), (PathVerb.QUAD, ((449, 704), (449, 704))), (PathVerb.QUAD, ((649, 704), (748.5, 615))), (PathVerb.QUAD, ((848, 526), (848, 348))), (PathVerb.LINE, ((848, 348),)), # the duplicate point is kept (PathVerb.CLOSE, ()) ] ) ] @pytest.mark.parametrize("operations, expected", TEST_DATA) def test_reverse_path(operations, expected): path = Path() for verb, pts in operations: path.add(verb, *pts) path.reverse() assert list(path) == expected def test_duplicate_start_point(): # https://github.com/fonttools/skia-pathops/issues/13 path = Path() path.moveTo( bits2float(0x43480000), # 200 bits2float(0x43db8ce9), # 439.101 ) path.lineTo( bits2float(0x43480000), # 200 bits2float(0x4401c000), # 519 ) path.cubicTo( bits2float(0x43480000), # 200 bits2float(0x441f0000), # 636 bits2float(0x43660000), # 230 bits2float(0x44340000), # 720 bits2float(0x43c80000), # 400 bits2float(0x44340000), # 720 ) path.cubicTo( bits2float(0x4404c000), # 531 bits2float(0x44340000), # 720 bits2float(0x440d0000), # 564 bits2float(0x442b8000), # 686 bits2float(0x44118000), # 582 bits2float(0x4416c000), # 603 ) path.lineTo( bits2float(0x442cc000), # 691 bits2float(0x441c8000), # 626 ) path.cubicTo( bits2float(0x44260000), # 664 bits2float(0x443d4000), # 757 bits2float(0x44114000), # 581 bits2float(0x444a8000), # 810 bits2float(0x43c88000), # 401 bits2float(0x444a8000), # 810 ) path.cubicTo( bits2float(0x43350000), # 181 bits2float(0x444a8000), # 810 bits2float(0x42c80000), # 100 bits2float(0x442e0000), # 696 bits2float(0x42c80000), # 100 bits2float(0x4401c000), # 519 ) path.lineTo( bits2float(0x42c80000), # 100 bits2float(0x438a8000), # 277 ) path.cubicTo( bits2float(0x42c80000), # 100 bits2float(0x42cc0000), # 102 bits2float(0x433e0000), # 190 bits2float(0xc1200000), # -10 bits2float(0x43cd0000), # 410 bits2float(0xc1200000), # -10 ) path.cubicTo( bits2float(0x441d8000), # 630 bits2float(0xc1200000), # -10 bits2float(0x442f0000), # 700 bits2float(0x42e60000), # 115 bits2float(0x442f0000), # 700 bits2float(0x437a0000), # 250 ) path.lineTo( bits2float(0x442f0000), # 700 bits2float(0x43880000), # 272 ) path.cubicTo( bits2float(0x442f0000), # 700 bits2float(0x43d18000), # 419 bits2float(0x44164000), # 601 bits2float(0x43fa0000), # 500 bits2float(0x43c88000), # 401 bits2float(0x43fa0000), # 500 ) path.cubicTo( bits2float(0x43964752), # 300.557 bits2float(0x43fa0000), # 500 bits2float(0x436db1ed), # 237.695 bits2float(0x43ef6824), # 478.814 bits2float(0x43480000), # 200 bits2float(0x43db8ce9), # 439.101 ) path.close() path.moveTo( bits2float(0x434805cb), # 200.023 bits2float(0x43881798), # 272.184 ) path.cubicTo( bits2float(0x43493da4), # 201.241 bits2float(0x43b2a869), # 357.316 bits2float(0x437bd6b1), # 251.839 bits2float(0x43cd0000), # 410 bits2float(0x43c80000), # 400 bits2float(0x43cd0000), # 410 ) path.cubicTo( bits2float(0x44098000), # 550 bits2float(0x43cd0000), # 410 bits2float(0x44160000), # 600 bits2float(0x43b20000), # 356 bits2float(0x44160000), # 600 bits2float(0x43868000), # 269 ) path.lineTo( bits2float(0x44160000), # 600 bits2float(0x43808000), # 257 ) path.cubicTo( bits2float(0x44160000), # 600 bits2float(0x43330000), # 179 bits2float(0x44110000), # 580 bits2float(0x429c0000), # 78 bits2float(0x43cd0000), # 410 bits2float(0x429c0000), # 78 ) path.cubicTo( bits2float(0x43725298), # 242.323 bits2float(0x429c0000), # 78 bits2float(0x43491e05), # 201.117 bits2float(0x431ccd43), # 156.802 bits2float(0x434805cb), # 200.023 bits2float(0x43881797), # 272.184 ) path.close() contours = list(path.contours) # on the second contour, the last and first points' Y coordinate only # differ by one bit: 0x43881798 != 0x43881797 points = contours[1].points assert points[0] != points[-1] assert points[0] == pytest.approx(points[-1]) # when "drawn" as segments, almost equal last/first points are treated # as exactly equal, without the need of an extra closing lineTo for contour in path.contours: segments = list(contour.segments) assert segments[-1][0] == "closePath" first_type, first_pts = segments[0] last_type, last_pts = segments[-2] assert first_type == "moveTo" assert last_type == "curveTo" assert last_pts[-1] == first_pts[-1] def test_float2bits(): assert float2bits(17.5) == 0x418c0000 assert float2bits(-10.0) == 0xc1200000 def test_bits2float(): assert bits2float(0x418c0000) == 17.5 assert bits2float(0xc1200000) == -10.0 assert bits2float(-0x3ee00000) == -10.0 # this works too def test_strip_collinear_moveTo(): # https://github.com/fonttools/skia-pathops/issues/12 path = Path() path.moveTo( bits2float(0x440b8000), # 558 bits2float(0x0), # 0 ) path.lineTo( bits2float(0x44098000), # 550 bits2float(0x0), # 0 ) path.lineTo( bits2float(0x440c247f), # 560.57 bits2float(0x41daf87e), # 27.3713 ) path.lineTo( bits2float(0x440e247f), # 568.57 bits2float(0x41daf87e), # 27.3713 ) path.close() path.moveTo( bits2float(0x440b0000), # 556 bits2float(0x40e00000), # 7 ) path.lineTo( bits2float(0x440a4000), # 553 bits2float(0x0), # 0 ) path.lineTo( bits2float(0x44049c26), # 530.44 bits2float(0x0), # 0 ) path.lineTo( bits2float(0x44052891), # 532.634 bits2float(0x40e00000), # 7 ) path.close() path.simplify() expected = Path() expected.moveTo( bits2float(0x440b8000), # 558 bits2float(0x0), # 0 ) expected.lineTo( bits2float(0x440e247f), # 568.57 bits2float(0x41daf87e), # 27.3713 ) expected.lineTo( bits2float(0x440c247f), # 560.57 bits2float(0x41daf87e), # 27.3713 ) expected.lineTo( bits2float(0x440a2d02), # 552.703 bits2float(0x40e00000), # 7 ) expected.lineTo( bits2float(0x44052891), # 532.634 bits2float(0x40e00000), # 7 ) expected.lineTo( bits2float(0x44049c26), # 530.44 bits2float(0x0), # 0 ) # expected.lineTo( # bits2float(0x44098000), # 550 # bits2float(0x0), # 0 # ) expected.close() assert list(path) == list(expected) @pytest.mark.parametrize( "message, operations, expected", [ ( 'stroke_2_wide', ( ('moveTo', (5, 5)), ('lineTo', (10, 5)), ('stroke', (2, 0, 0, 1)), ), ( ('moveTo', ((5., 4.),)), ('lineTo', ((10., 4.),)), ('lineTo', ((10., 6.),)), ('lineTo', ((5., 6.),)), ('lineTo', ((5., 4.),)), ('closePath', ()), ), ), ( 'stroke_dash_array', ( ('moveTo', (5, 5)), ('lineTo', (10, 5)), ('stroke', (2, 0, 0, 1, (1, 1))), ), ( ('moveTo', ((5.0, 4.0),)), ('lineTo', ((6.0, 4.0),)), ('lineTo', ((6.0, 6.0),)), ('lineTo', ((5.0, 6.0),)), ('endPath', ()), ('moveTo', ((7.0, 4.0),)), ('lineTo', ((8.0, 4.0),)), ('lineTo', ((8.0, 6.0),)), ('lineTo', ((7.0, 6.0),)), ('endPath', ()), ('moveTo', ((9.0, 4.0),)), ('lineTo', ((10.0, 4.0),)), ('lineTo', ((10.0, 6.0),)), ('lineTo', ((9.0, 6.0),)), ('endPath', ()), ), ), ( 'stroke_dash_offset', ( ('moveTo', (5, 5)), ('lineTo', (10, 5)), ('stroke', (2, 0, 0, 1, (1, 1), 0.5)), ), ( ('moveTo', ((5.0, 4.0),)), ('lineTo', ((5.5, 4.0),)), ('lineTo', ((5.5, 6.0),)), ('lineTo', ((5.0, 6.0),)), ('endPath', ()), ('moveTo', ((6.5, 4.0),)), ('lineTo', ((7.5, 4.0),)), ('lineTo', ((7.5, 6.0),)), ('lineTo', ((6.5, 6.0),)), ('endPath', ()), ('moveTo', ((8.5, 4.0),)), ('lineTo', ((9.5, 4.0),)), ('lineTo', ((9.5, 6.0),)), ('lineTo', ((8.5, 6.0),)), ('endPath', ()), ), ), ( 'conic_2_quad', ( ('moveTo', (10, 10)), ('conicTo', (20, 20, 10, 30, 3)), ('convertConicsToQuads', ()), ), ( ('moveTo', ((10.0, 10.0),)), ('qCurveTo', ((14.39, 18.79), (17.50, 26.04), (17.50, 28.96), (14.39, 30.00), (10.0, 30.0))), ('endPath', ()) ), ), ( 'arc_to_quads', ( ('moveTo', (7, 5)), ('arcTo', (3, 1, 0, ArcSize.SMALL, Direction.CCW, 7, 2)), ('convertConicsToQuads', ()), ), ( ('moveTo', ((7.0, 5.0),)), ('qCurveTo', ((11.5, 5.0), (11.5, 2.0), (7.0, 2.0))), ('endPath', ()), ) ) ] ) def test_path_operation(message, operations, expected): path = Path() for op, args in operations: getattr(path, op)(*args) # round the values we get back rounded = [] for verb, pts in path.segments: round_pts = [] for pt in pts: round_pts.append(tuple(round(c, 2) for c in pt)) rounded.append((verb, tuple(round_pts))) assert tuple(rounded) == expected, message @pytest.fixture def overlapping_path(): path = Path() path.moveTo(0, 0) path.lineTo(10, 0) path.lineTo(10, 10) path.lineTo(0, 10) path.close() path.moveTo(5, 5) path.lineTo(15, 5) path.lineTo(15, 15) path.lineTo(5, 15) path.close() return path def test_simplify(overlapping_path): result = simplify(overlapping_path) assert overlapping_path != result assert list(result) == [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((10, 0),)), (PathVerb.LINE, ((10, 5),)), (PathVerb.LINE, ((15, 5),)), (PathVerb.LINE, ((15, 15),)), (PathVerb.LINE, ((5, 15),)), (PathVerb.LINE, ((5, 10),)), (PathVerb.LINE, ((0, 10),)), (PathVerb.CLOSE, ()), ] overlapping_path.simplify() assert overlapping_path == result def test_simplify_clockwise(overlapping_path): result = simplify(overlapping_path, clockwise=True) assert overlapping_path != result assert list(result) == [ (PathVerb.MOVE, ((0, 0),)), (PathVerb.LINE, ((0, 10),)), (PathVerb.LINE, ((5, 10),)), (PathVerb.LINE, ((5, 15),)), (PathVerb.LINE, ((15, 15),)), (PathVerb.LINE, ((15, 5),)), (PathVerb.LINE, ((10, 5),)), (PathVerb.LINE, ((10, 0),)), (PathVerb.CLOSE, ()), ] overlapping_path.simplify(clockwise=True) assert overlapping_path == result skia-pathops-0.9.2/tox.ini000066400000000000000000000021031514456744000154460ustar00rootroot00000000000000[tox] envlist = py{38,39,310,311} minversion = 3.0.0 [testenv] extras = testing commands = pytest {posargs} [testenv:htmlcov] setenv = CYTHON_TRACE=1 skip_install = true deps = cython pip >= 18.0 commands = python -c 'import shutil; shutil.rmtree("build", ignore_errors=True)' pip install -e .[testing] coverage run -m pytest {posargs} coverage report coverage html [testenv:wheel] description = build wheel package for upload to PyPI skip_install = true deps = setuptools >= 36.4.0 pip >= 18.0 wheel >= 0.31.0 changedir = {toxinidir} commands = python -c 'import shutil; shutil.rmtree("dist", ignore_errors=True)' pip wheel --no-deps --wheel-dir dist . [pytest] minversion = 3.0 testpaths = src/python/pathops tests python_files = *_test.py python_classes = *Test # NOTE: The -k option is to skip all tests containing the substring "__test__". # This is needed to prevent running doctests embedded in .pyx files twice, # for reasons which I still haven't figured out... addopts = -v -r a -k "not __test__"