diff --git a/.travis.yml b/.travis.yml index 4a2c179..b9594bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,13 @@ env: global: - - DOCKER_IMAGE=anthrotype/manylinux:gcc9.2.0 # directory containing the project source - REPO_DIR=. # pip dependencies to _test_ project - TEST_DEPENDS="tox" - PLAT=x86_64 - UNICODE_WIDTH=32 + # use 'manylinux2014' since Skia requires C++14 support + - MB_ML_VER=2014 - TWINE_USERNAME="anthrotype" - secure: O0cS/1sCRfjuDVdlMihyqyX6b7so3qZ41OhqDC5O5f4bvk87vno9GGk7U0ZfdoUqjnjXLd7QvEnax4vWJx/tvSEh/wJC07U2pcFDNehkYJIEZCf/MQWzESWd905fUSWP1/BbKgCWvfq7WZOH/3iKpDyQP5DKlrnoq3E2H2gYR3xKd7ASAZHtUYariE4bMEnjg4SDANfm7SHnlD5a/S4/IjgxU0DjCKKbkX7HbGUiCAjjr3j3z9amAhxCmoWyOKvNHjKegG2okEb08ERtcbyYWan0Eu5FqCDMkWwhQmACC1lXz0xHyHW4VZWDyQC1cDrSTirN9rNdamTnfqJPP1eURxGNmNqazrem77HAUKIuh5WjXLFZwKzp+KWMb5TTXYWIsh8gx/IAjGfPoi8nKOWd+bxWLeakDM4kka7pLJDsuRnWSWKzDaDDpMuFm76RzDJjTWCsva93l3EZ8/fkXQ3sGrVC7f8MAjaqBEs+vV6YZMv3WZuSfZkv2AVoPLKkPxWB3RDekKjAw/O8qovqDSGHVgV8XU+6AQVEWhEu8dEtEbXn0UPMQ/bSjQeugl5AkmmBC4iIisuP8rtPB+xdV/iQALEM/RdLJHzC76VQNuXMke69roK5+ZhA6TCCix7I3TIq+XtNQ78SnAUGuooND+WHxVVSQ5GK9DGiCMetRlOQkIA= diff --git a/appveyor.yml b/appveyor.yml index 8cfb706..18c67b8 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -image: Visual Studio 2017 +image: Visual Studio 2019 platform: x64 configuration: Release @@ -7,31 +7,31 @@ environment: TWINE_USERNAME: "anthrotype" TWINE_PASSWORD: secure: 9L/DdqoIILlN7qCh0lotvA== + PYTHON2_EXE: "C:\\Python27\\python.exe" matrix: + - PYTHON: "C:\\Python36" + PYTHON_VERSION: "3.6" + PYTHON_ARCH: "32" + - PYTHON: "C:\\Python36-x64" PYTHON_VERSION: "3.6" PYTHON_ARCH: "64" - - PYTHON: "C:\\Python37-x64" + - PYTHON: "C:\\Python37" PYTHON_VERSION: "3.7" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python38-x64" - PYTHON_VERSION: "3.8" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python36" - PYTHON_VERSION: "3.6" PYTHON_ARCH: "32" - - PYTHON: "C:\\Python37" + - PYTHON: "C:\\Python37-x64" PYTHON_VERSION: "3.7" - PYTHON_ARCH: "32" + PYTHON_ARCH: "64" - PYTHON: "C:\\Python38" PYTHON_VERSION: "3.8" PYTHON_ARCH: "32" + - PYTHON: "C:\\Python38-x64" + PYTHON_VERSION: "3.8" + PYTHON_ARCH: "64" init: - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%" @@ -58,7 +58,7 @@ install: - pip install --upgrade tox # build wheel - - pip wheel --no-deps --wheel-dir dist . + - pip wheel -v --no-deps --wheel-dir dist . # get the full path to the compiled wheel (ugly but works) - dir /s /b dist\skia_pathops*.whl > wheel.pth - set /p WHEEL_PATH= (2, 7): + sys.exit("python 2.7 is required; this is {}.{}".format(*py_ver)) + +import argparse +import glob +import os +import subprocess + + +# script to bootstrap virtualenv without requiring pip +GET_VIRTUALENV_URL = "https://asottile.github.io/get-virtualenv.py" + +EXE_EXT = ".exe" if sys.platform == "win32" else "" + +ROOT_DIR = os.path.abspath(os.path.dirname(__file__)) + +SKIA_SRC_DIR = os.path.join(ROOT_DIR, "src", "cpp", "skia") +SKIA_BUILD_ARGS = [ + "is_official_build=true", + "is_debug=false", + "skia_enable_pdf=false", + "skia_enable_discrete_gpu=false", + "skia_enable_nvpr=false", + "skia_enable_skottie=false", + "skia_enable_skshaper=false", + "skia_use_dng_sdk=false", + "skia_use_expat=false", + "skia_use_freetype=false", + "skia_use_fontconfig=false", + "skia_use_fonthost_mac=false", + "skia_use_harfbuzz=false", + "skia_use_icu=false", + "skia_use_libgifcodec=false", + "skia_use_libjpeg_turbo=false", + "skia_use_libpng=false", + "skia_use_libwebp=false", + "skia_use_piex=false", + "skia_use_sfntly=false", + "skia_use_xps=false", + "skia_use_zlib=false", +] +if sys.platform != "win32": + # On Linux, I need this flag otherwise I get undefined symbol upon importing; + # on Windows, defining this flag creates other linker issues (SkFontMgr being + # redefined by SkFontMgr_win_dw_factory.obj)... + SKIA_BUILD_ARGS.append("skia_enable_fontmgr_empty=true") + # We don't need GPU or GL support, but disabling this on Windows creates lots + # of undefined symbols upon linking the skia.dll, so I keep them for Windows... + SKIA_BUILD_ARGS.append("skia_enable_gpu=false") + SKIA_BUILD_ARGS.append("skia_use_gl=false") + SKIA_BUILD_ARGS.append("skia_enable_ccpr=false") + + +def make_virtualenv(venv_dir): + from contextlib import closing + import io + from urllib2 import urlopen + + bin_dir = "Scripts" if sys.platform == "win32" else "bin" + venv_bin_dir = os.path.join(venv_dir, bin_dir) + python_exe = os.path.join(venv_bin_dir, "python" + EXE_EXT) + + # bootstrap virtualenv if not already present + if not os.path.exists(python_exe): + tmp = io.BytesIO() + with closing(urlopen(GET_VIRTUALENV_URL)) as response: + tmp.write(response.read()) + + p = subprocess.Popen( + [sys.executable, "-", "--no-download", venv_dir], stdin=subprocess.PIPE + ) + p.communicate(tmp.getvalue()) + if p.returncode != 0: + sys.exit("failed to create virtualenv") + assert os.path.exists(python_exe) + + # pip install ninja + ninja_exe = os.path.join(venv_bin_dir, "ninja" + EXE_EXT) + if not os.path.exists(ninja_exe): + subprocess.check_call( + [ + os.path.join(venv_bin_dir, "pip" + EXE_EXT), + "install", + "--only-binary=ninja", + "ninja", + ] + ) + + # place virtualenv bin in front of $PATH, like 'source venv/bin/activate' + env = os.environ.copy() + env["PATH"] = os.pathsep.join([venv_bin_dir, env.get("PATH", "")]) + + return env + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "build_dir", + default=os.path.join("build", "skia"), + nargs="?", + help="directory where to build libskia (default: %(default)s)", + ) + parser.add_argument( + "-s", + "--shared-lib", + action="store_true", + help="build a shared library (default: static)" + ) + parser.add_argument( + "--target-cpu", + default=None, + help="The desired CPU architecture for the build (default: host)", + choices=["x86", "x64", "arm", "arm64", "mipsel"] + ) + args = parser.parse_args() + + build_dir = os.path.abspath(args.build_dir) + venv_dir = os.path.join(build_dir, "venv2") + + env = make_virtualenv(venv_dir) + + subprocess.check_call( + ["python", os.path.join("tools", "git-sync-deps")], env=env, cwd=SKIA_SRC_DIR + ) + + build_args = list(SKIA_BUILD_ARGS) + if args.shared_lib: + build_args.append("is_component_build=true") + if args.target_cpu: + build_args.append('target_cpu="{}"'.format(args.target_cpu)) + + subprocess.check_call( + [ + os.path.join(SKIA_SRC_DIR, "bin", "gn" + EXE_EXT), + "gen", + build_dir, + "--args={}".format(" ".join(build_args)), + ], + env=env, + cwd=SKIA_SRC_DIR, + ) + + subprocess.check_call(["ninja", "-C", build_dir], env=env) + + # when building skia.dll on windows with gn and ninja, the DLL import file + # is written as 'skia.dll.lib'; however, when linking it with the extension + # module, setuptools expects it to be named 'skia.lib'. + if sys.platform == "win32" and args.shared_lib: + for f in glob.glob(os.path.join(build_dir, "skia.dll.*")): + os.rename(f, f.replace(".dll", "")) diff --git a/config.sh b/config.sh index 1f659cd..20a32c7 100644 --- a/config.sh +++ b/config.sh @@ -4,11 +4,7 @@ function pre_build { # Any stuff that you need to do before you start building the wheels # Runs in the root directory of this repository. - if [ -z "$IS_OSX" ]; then - export CFLAGS="-static-libstdc++" - export CC=/usr/local/gcc-9.2.0/bin/gcc-9.2.0 - export CXX=/usr/local/gcc-9.2.0/bin/g++-9.2.0 - fi + : } function run_tests { diff --git a/setup.py b/setup.py index 2b3f722..0eba27b 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,14 @@ from __future__ import print_function from setuptools import setup, find_packages, Extension from setuptools.command.build_ext import build_ext -from distutils.command.build_clib import build_clib from distutils.errors import DistutilsSetupError from distutils import log -from distutils.dep_util import newer_group, newer_pairwise +from distutils.dep_util import newer_group +from distutils.dir_util import mkpath +from distutils.file_util import copy_file import pkg_resources +import struct +import subprocess import sys import os import platform @@ -14,6 +17,17 @@ import re +# 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") + + +# Building libskia with its 'gn' build tool requires python2; if 'python2' +# executable is not in your $PATH, you can export PYTHON2_EXE=... before +# running setup.py script. +PYTHON2_EXE = os.environ.get("PYTHON2_EXE", "python2") + # check if minimum required Cython is available cython_version_re = re.compile('\s*"cython\s*>=\s*([0-9][0-9\w\.]*)\s*"') with open("pyproject.toml", "r", encoding="utf-8") as fp: @@ -47,6 +61,8 @@ 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 @@ -61,6 +77,20 @@ class custom_build_ext(build_ext): 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 @@ -170,181 +200,85 @@ def build_extension(self, ext): 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): - # Setuptools `develop` command (used by `pip install -e .`) only calls - # `build_ext`, unlike the `install` command which in turn calls `build` - # and all its related sub-commands. Linking the Cython extension module - # with the Skia static library fails because the `build_clib` command - # is not automatically called when doing an editable install. - # Here we make sure that `build_clib` command is always run before the - # the extension module is compiled, even when doing editable install. - # https://github.com/pypa/setuptools/issues/1040 - self.run_command("build_clib") 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) + mkpath(dest_dir, verbose=self.verbose, dry_run=self.dry_run) + copy_file( + dll_fullpath, + os.path.join(dest_dir, dll_filename), + verbose=self.verbose, + dry_run=self.dry_run, + ) -class custom_build_clib(build_clib): - """ Custom build_clib command which allows to pass compiler-specific - 'macros' and 'cflags' when compiling C libraries. - In the setup 'libraries' option, the 'macros' and 'cflags' can be - provided as dict with the compiler type as the key (e.g. "unix", - "mingw32", "msvc") and the value containing the list of macros/cflags. - A special empty string '' key may be used for default options that - apply to all the other compiler types except for those explicitly - listed. - """ +def build_skia(build_base): + log.info("building 'skia' library") + build_dir = os.path.join(build_base, "src", "cpp", "skia") + build_cmd = [PYTHON2_EXE, "build_skia.py", build_dir] - def finalize_options(self): - build_clib.finalize_options(self) - if self.compiler is None: - # we use this variable with tox to build using GCC on Windows. - # https://bitbucket.org/hpk42/tox/issues/274/specify-compiler - self.compiler = os.environ.get("DISTUTILS_COMPILER", None) - - def build_libraries(self, libraries): - for (lib_name, build_info) in libraries: - sources = build_info.get('sources') - if sources is None or not isinstance(sources, (list, tuple)): - raise DistutilsSetupError( - "in 'libraries' option (library '%s'), " - "'sources' must be present and must be " - "a list of source filenames" % lib_name) - sources = list(sources) - - # detect target language - language = 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") - - # get compiler-specific preprocessor definitions - macros = build_info.get("macros", []) - if isinstance(macros, dict): - if compiler_type in macros: - macros = macros[compiler_type] - else: - macros = macros.get("", []) + env = os.environ.copy() + if sys.platform == "win32": + from distutils._msvccompiler import _get_vc_env - include_dirs = build_info.get('include_dirs') + # 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") - # get compiler-specific compile flags - cflags = build_info.get("cflags", []) - if isinstance(cflags, dict): - if compiler_type in cflags: - cflags = cflags[compiler_type] - else: - cflags = cflags.get("", []) - - expected_objects = self.compiler.object_filenames( - sources, - output_dir=self.build_temp) - - # TODO: also support objects' dependencies - if (self.force or - newer_pairwise(sources, expected_objects) != ([], [])): - log.info("building '%s' library", lib_name) - # compile the source code to object files - objects = self.compiler.compile(sources, - output_dir=self.build_temp, - macros=macros, - include_dirs=include_dirs, - extra_postargs=cflags, - debug=self.debug) - else: - log.debug( - "skipping build '%s' objects (up-to-date)" % lib_name) - objects = expected_objects + # update Visual C++ toolchain environment depending on python architecture + arch = "x64" if struct.calcsize("P") * 8 == 64 else "x86" + env.update(_get_vc_env(arch)) + + build_cmd.extend(["--target-cpu", arch]) + + subprocess.run(build_cmd, check=True, env=env) + return build_dir - # Now "link" the object files together into a static library. - # (On Unix at least, this isn't really linking -- it just - # builds an archive. Whatever.) - self.compiler.create_static_lib(objects, lib_name, - output_dir=self.build_clib, - debug=self.debug) + +if BUILD_SKIA_FROM_SOURCE: + custom_build_ext.register_library_builder("skia", build_skia) pkg_dir = os.path.join("src", "python") cpp_dir = os.path.join("src", "cpp") skia_dir = os.path.join(cpp_dir, "skia") +skia_src_dir = os.path.join(skia_dir, "src") # allow access to internals -skia_src = [ - os.path.join(skia_dir, "src", "core", "SkArenaAlloc.cpp"), - os.path.join(skia_dir, "src", "core", "SkBuffer.cpp"), - os.path.join(skia_dir, "src", "core", "SkCubicClipper.cpp"), - os.path.join(skia_dir, "src", "core", "SkData.cpp"), - os.path.join(skia_dir, "src", "core", "SkEdgeClipper.cpp"), - os.path.join(skia_dir, "src", "core", "SkGeometry.cpp"), - os.path.join(skia_dir, "src", "core", "SkLineClipper.cpp"), - os.path.join(skia_dir, "src", "core", "SkMath.cpp"), - os.path.join(skia_dir, "src", "core", "SkMatrix.cpp"), - os.path.join(skia_dir, "src", "core", "SkPath.cpp"), - os.path.join(skia_dir, "src", "core", "SkPathRef.cpp"), - os.path.join(skia_dir, "src", "core", "SkPoint.cpp"), - os.path.join(skia_dir, "src", "core", "SkRect.cpp"), - os.path.join(skia_dir, "src", "core", "SkRRect.cpp"), - os.path.join(skia_dir, "src", "core", "SkSemaphore.cpp"), - os.path.join(skia_dir, "src", "core", "SkString.cpp"), - os.path.join(skia_dir, "src", "core", "SkStringUtils.cpp"), - os.path.join(skia_dir, "src", "core", "SkUtils.cpp"), - os.path.join(skia_dir, "src", "core", "SkThreadID.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkAddIntersections.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkDConicLineIntersection.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkDCubicLineIntersection.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkDCubicToQuads.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkDLineIntersection.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkDQuadLineIntersection.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkIntersections.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpAngle.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpBuilder.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpCoincidence.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpContour.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpCubicHull.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpEdgeBuilder.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpSegment.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkOpSpan.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsAsWinding.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsCommon.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsConic.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsCubic.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsCurve.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsDebug.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsLine.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsOp.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsQuad.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsRect.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsSimplify.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsTightBounds.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsTSect.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsTypes.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathOpsWinding.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkPathWriter.cpp"), - os.path.join(skia_dir, "src", "pathops", "SkReduceOrder.cpp"), - os.path.join(skia_dir, "src", "utils", "SkUTF.cpp"), - os.path.join(skia_dir, "src", "ports", "SkDebug_stdio.cpp"), - os.path.join(skia_dir, "src", "ports", "SkMemory_malloc.cpp"), - os.path.join(skia_dir, "src", "ports", "SkOSFile_stdio.cpp"), - os.path.join(cpp_dir, "SkMallocThrow.cpp"), -] - -if os.name == "nt": - skia_src += [ - os.path.join(skia_dir, "src", "ports", "SkDebug_win.cpp"), - os.path.join(skia_dir, "src", "ports", "SkOSFile_win.cpp"), - ] -elif os.name == "posix": - skia_src += [ - os.path.join(skia_dir, "src", "ports", "SkOSFile_posix.cpp"), - ] -else: - raise RuntimeError("unsupported OS: %r" % os.name) - -include_dirs = [os.path.join(skia_dir)] +include_dirs = [skia_dir, skia_src_dir] extra_compile_args = { '': [ @@ -360,23 +294,7 @@ def build_libraries(self, libraries): ], } -shared_macros = [ - ("SK_SUPPORT_GPU", "0"), -] -define_macros = { - "": shared_macros, -} - -libraries = [ - ( - 'skia', { - 'sources': skia_src, - 'include_dirs': include_dirs, - 'cflags': extra_compile_args, - 'macros': define_macros, - }, - ), -] +library_dirs = [SKIA_LIBRARY_DIR] if SKIA_LIBRARY_DIR is not None else [] extensions = [ Extension( @@ -387,9 +305,10 @@ def build_libraries(self, libraries): depends=[ os.path.join(skia_dir, 'include', 'pathops', 'SkPathOps.h'), ], - define_macros=define_macros, include_dirs=include_dirs, extra_compile_args=extra_compile_args, + libraries=["skia"], + library_dirs=library_dirs, language="c++", ), ] @@ -402,7 +321,7 @@ def build_libraries(self, libraries): setup_params = dict( name="skia-pathops", use_scm_version={"write_to": version_file}, - description="Boolean operations on paths using the Skia library", + 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", @@ -411,13 +330,11 @@ def build_libraries(self, libraries): license="BSD-3-Clause", package_dir={"": pkg_dir}, packages=find_packages(pkg_dir), - libraries=libraries, ext_modules=extensions, cmdclass={ 'build_ext': custom_build_ext, - 'build_clib': custom_build_clib, }, - setup_requires=["setuptools_scm"] + wheel, + setup_requires=["setuptools_scm"] + setuptools_git_ls_files + wheel, install_requires=[ ], extras_require={ diff --git a/src/cpp/SkMallocThrow.cpp b/src/cpp/SkMallocThrow.cpp deleted file mode 100644 index bd11e5c..0000000 --- a/src/cpp/SkMallocThrow.cpp +++ /dev/null @@ -1,10 +0,0 @@ -// This is to avoid importing src/core/SkMallocPixelRef.cpp (which implements -// among other things the `sk_malloc_throw` function below), as the latter -// has a long list of dependencies which we don't need for skia-pathops. -// The function is used by SkTArray.h, included by pathops/SkPathWriter.h. -#include "include/private/SkMalloc.h" -#include "src/core/SkSafeMath.h" - -void* sk_malloc_throw(size_t count, size_t elemSize) { - return sk_malloc_throw(SkSafeMath::Mul(count, elemSize)); -} diff --git a/src/cpp/skia b/src/cpp/skia index 3517aa7..1c9ebb5 160000 --- a/src/cpp/skia +++ b/src/cpp/skia @@ -1 +1 @@ -Subproject commit 3517aa7b14ad52aa662bf38932f2ba358a9f8318 +Subproject commit 1c9ebb50024f80f3bf289838298e15185d8f6966 diff --git a/src/python/pathops/__init__.py b/src/python/pathops/__init__.py index 5646f20..5102972 100644 --- a/src/python/pathops/__init__.py +++ b/src/python/pathops/__init__.py @@ -4,6 +4,10 @@ PathVerb, PathOp, FillType, + LineCap, + LineJoin, + ArcSize, + Direction, op, simplify, OpBuilder, @@ -12,6 +16,7 @@ OpenPathError, bits2float, float2bits, + decompose_quadratic_segment, ) from .operations import ( diff --git a/src/python/pathops/_pathops.pxd b/src/python/pathops/_pathops.pxd index b028ffc..74b66c0 100644 --- a/src/python/pathops/_pathops.pxd +++ b/src/python/pathops/_pathops.pxd @@ -1,4 +1,6 @@ from ._skia.core cimport ( + SkLineCap, + SkLineJoin, SkPath, SkPathFillType, SkPoint, @@ -10,6 +12,9 @@ from ._skia.core cimport ( kCubic_Verb, kClose_Verb, kDone_Verb, + kSmall_ArcSize, + kLarge_ArcSize, + SkPathDirection, ) from ._skia.pathops cimport ( SkOpBuilder, @@ -38,6 +43,27 @@ cpdef enum FillType: 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 = kSmall_ArcSize + LARGE = kLarge_ArcSize + + +cpdef enum Direction: + CW = SkPathDirection.kCW + CCW = SkPathDirection.kCCW + + cdef union FloatIntUnion: float Float int32_t SignBitInt @@ -108,6 +134,17 @@ cdef class Path: 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) @@ -122,6 +159,10 @@ cdef class Path: cpdef simplify(self, bint fix_winding=*, keep_starting_points=*) + cpdef convertConicsToQuads(self, float tolerance=*) + + cpdef stroke(self, SkScalar width, LineCap cap, LineJoin join, SkScalar miter_limit) + cdef list getVerbs(self) cdef list getPoints(self) @@ -227,7 +268,7 @@ cpdef int restore_starting_points(Path path, list points) except -1 cpdef bint winding_from_even_odd(Path path, bint truetype=*) except False -cdef list decompose_quadratic_segment(tuple points) +cdef list _decompose_quadratic_segment(tuple points) cdef int find_oncurve_point( diff --git a/src/python/pathops/_pathops.pyx b/src/python/pathops/_pathops.pyx index e40fca7..f0bb562 100644 --- a/src/python/pathops/_pathops.pyx +++ b/src/python/pathops/_pathops.pyx @@ -1,9 +1,14 @@ from ._skia.core cimport ( + SkConic, SkPath, SkPathFillType, SkPoint, SkScalar, + SkStrokeRec, SkRect, + SkLineCap, + SkLineJoin, + SkPathDirection, kMove_Verb, kLine_Verb, kQuad_Verb, @@ -11,7 +16,9 @@ from ._skia.core cimport ( kCubic_Verb, kClose_Verb, kDone_Verb, + kFill_InitStyle, SK_ScalarNearlyZero, + ConvertConicToQuads, ) from ._skia.pathops cimport ( Op, @@ -205,6 +212,18 @@ cdef class Path: ): 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(rx, ry, xAxisRotate, largeArc, sweep, x, y) + cpdef void close(self): self.path.close() @@ -242,9 +261,14 @@ cdef class Path: 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)" % (method, coords_to_string(coords)) + line = "path.%s(%s)%s" % (method, coords_to_string(coords), suffix) s.append(line) return "\n".join(s) @@ -332,6 +356,107 @@ cdef class Path: 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(kConic_Verb): + 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 SkPath temp + cdef SkPathFillType fillType = self.path.getFillType() + temp.setFillType(fillType) + + cdef SkConic conic + cdef SkPoint p0 + cdef SkPoint p1 + cdef SkPoint p2 + cdef SkScalar weight + cdef pow2 + + try: + prev = (0., 0.) + for verb, pts in self: + if verb != kConic_Verb: + if verb != kClose_Verb: + prev_verb = verb + prev = pts[-1] + + # TODO cython got angry when I tried to make this a fn + if verb == kMove_Verb: + temp.moveTo(pts[0][0], pts[0][1]) + elif verb == kLine_Verb: + temp.lineTo(pts[0][0], pts[0][1]) + elif verb == kQuad_Verb: + temp.quadTo(pts[0][0], pts[0][1], + pts[1][0], pts[1][1]) + elif verb == kCubic_Verb: + temp.cubicTo(pts[0][0], pts[0][1], + pts[1][0], pts[1][1], + pts[2][0], pts[2][1]) + elif verb == kClose_Verb: + 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] + + conic.set(p0, p1, p2, weight) + pow2 = conic.computeQuadPOW2(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): + # Do stroke + stroke_rec = new SkStrokeRec(kFill_InitStyle) + try: + stroke_rec.setStrokeStyle(width, False) + stroke_rec.setStrokeParams(cap, join, miter_limit) + stroke_rec.applyToPath(&self.path, self.path) + finally: + del stroke_rec + + # Nuke any conics that snuck in + self.convertConicsToQuads() + + cdef list getVerbs(self): cdef int i, count cdef uint8_t *verbs @@ -696,7 +821,7 @@ cdef class PathPen: pt3[0], pt3[1]) def qCurveTo(self, *points): - for pt1, pt2 in decompose_quadratic_segment(points): + for pt1, pt2 in _decompose_quadratic_segment(points): self._qCurveToOne(pt1, pt2) cdef _qCurveToOne(self, pt1, pt2): @@ -1021,7 +1146,11 @@ cpdef bint winding_from_even_odd(Path path, bint truetype=False) except False: return True -cdef list decompose_quadratic_segment(tuple points): +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 = [] diff --git a/src/python/pathops/_skia/core.pxd b/src/python/pathops/_skia/core.pxd index 37c1602..5a1cf7d 100644 --- a/src/python/pathops/_skia/core.pxd +++ b/src/python/pathops/_skia/core.pxd @@ -3,6 +3,11 @@ from libc.stdint cimport uint8_t ctypedef float SkScalar +cdef extern from "core/SkGeometry.h": + cdef struct SkConic: + SkConic() + void set(const SkPoint& p0, const SkPoint& p1, const SkPoint& p2, SkScalar w) + int computeQuadPOW2(SkScalar tol) cdef extern from "include/core/SkPathTypes.h": @@ -12,6 +17,10 @@ cdef extern from "include/core/SkPathTypes.h": kInverseWinding "SkPathFillType::kInverseWinding", kInverseEvenOdd "SkPathFillType::kInverseEvenOdd" + enum SkPathDirection: + kCW "SkPathDirection::kCW" + kCCW "SkPathDirection::kCCW" + cdef extern from "include/core/SkPath.h": @@ -57,6 +66,11 @@ cdef extern from "include/core/SkPath.h": SkScalar w) void conicTo(const SkPoint& p1, const SkPoint& p2, SkScalar w) + void arcTo(SkScalar rx, SkScalar ry, SkScalar xAxisRotate, ArcSize largeArc, + SkPathDirection sweep, SkScalar x, SkScalar y) + void arcTo(SkPoint& r, SkScalar xAxisRotate, ArcSize largeArc, + SkPathDirection sweep, SkPoint& xy) + void close() void dump() @@ -131,6 +145,14 @@ cdef extern from * namespace "SkPath": kClose_Verb, kDone_Verb + cdef int ConvertConicToQuads(const SkPoint& p0, const SkPoint& p1, + const SkPoint& p2, SkScalar w, + SkPoint pts[], int pow2) + + enum ArcSize: + kSmall_ArcSize + kLarge_ArcSize + cdef extern from "include/core/SkRect.h": @@ -149,3 +171,29 @@ cdef extern from "include/core/SkScalar.h": cdef enum: SK_ScalarNearlyZero + + +cdef extern from "include/core/SkPaint.h": + 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 extern from "include/core/SkStrokeRec.h": + cdef cppclass SkStrokeRec: + SkStrokeRec(InitStyle style) + void setStrokeStyle(SkScalar width, bint strokeAndFill) + void setStrokeParams(SkLineCap cap, SkLineJoin join, SkScalar miterLimit) + bint applyToPath(SkPath* dst, const SkPath& src) const; + + +cdef extern from * namespace "SkStrokeRec": + enum InitStyle: + kHairline_InitStyle, + kFill_InitStyle diff --git a/tests/pathops_test.py b/tests/pathops_test.py index 99d1fb9..2417a95 100644 --- a/tests/pathops_test.py +++ b/tests/pathops_test.py @@ -8,6 +8,8 @@ FillType, bits2float, float2bits, + ArcSize, + Direction, ) import pytest @@ -689,3 +691,66 @@ def test_strip_collinear_moveTo(): 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', ()), + ), + ), + ( + '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 + +