diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 972340569..2f5b3604f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,13 @@ name: Test on: push: - branches: [main,tests-build,v*] + branches: [main, tests-build, v*] pull_request: - branches: [main,v*] + branches: [main, v*] + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true jobs: test: @@ -14,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -43,22 +47,14 @@ jobs: if: steps.cache-pango.outputs.cache-hit != 'true' run: | source packing/build_pango_tests.sh - - name: Install python dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt + - name: Build Project + run: python setup.py build_ext -i - name: Run Tests - run: | - python setup.py build_ext -i - python setup.py sdist - pip install . - pytest - - name: Coverage - run: | - coverage report - coverage html - coverage xml + run: pytest -s - uses: codecov/codecov-action@v3 with: file: ./.coverage/coverage.xml @@ -68,78 +64,44 @@ jobs: path: .pytest_temp/ msvc: - name: ${{matrix.os}} - ${{matrix.python-version}} + name: ${{matrix.os}} - ${{matrix.python-version}} - ${{matrix.architecture}} runs-on: ${{matrix.os}} strategy: fail-fast: false matrix: os: [windows-2022] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + architecture: ["x64", "x86"] steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} for x64 + - name: Set up Python ${{ matrix.python-version }} for ${{matrix.architecture}} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - architecture: "x64" + architecture: ${{matrix.architecture}} allow-prereleases: true - - name: Cache Windows - id: cache-windows - uses: actions/cache@v3 - with: - path: C:\cibw\pkg-config - key: ${{ hashFiles('packing/download_dlls.py') }}-${{ hashFiles('packing/build_pkgconfig.ps1') }}-1 - name: Download Binary run: | python packing/download_dlls.py - - name: Set Path + - name: Set Path for pkg-config run: | $env:Path = "C:\cibw\pkg-config\bin;C:\cibw\vendor\bin;$($env:PATH)" echo "$env:Path" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Test x64 + - name: Install Python Dependencies run: | python -m pip install -U pip pip install -U setuptools wheel - $env:PKG_CONFIG_PATH="C:\cibw\vendor\lib\pkgconfig" pip install -r requirements-dev.txt - python setup.py build_ext -i - pytest - - name: Coverage + - name: Build Project + env: + PKG_CONFIG_PATH: C:\cibw\vendor\lib\pkgconfig + run: python setup.py build_ext -i + - name: Run tests run: | - coverage report - coverage html - coverage xml - - name: Set up Python ${{ matrix.python-version }} for x86 - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - architecture: "x86" - allow-prereleases: true - - name: Download Binary - run: | - python packing/download_dlls.py - - name: Build x86 Build - run: | - python -m pip install -U pip - $env:PATH="$env:PATH;C:\cibw\vendor\pkg-config\bin;C:\cibw\vendor\bin" - $env:PKG_CONFIG_PATH="C:\cibw\vendor\lib\pkgconfig" - pip install -r requirements-dev.txt - python setup.py build_ext -i - python setup.py sdist - python -m pip install dist/* - $env:PATH="C:\cibw\vendor\bin;$env:PATH" - pytest - - name: Coverage - run: | - coverage report - coverage html - coverage xml - - uses: codecov/codecov-action@v3 - with: - file: ./.coverage/coverage.xml + pytest -s - uses: actions/upload-artifact@v3 with: - name: test-artifacts-${{matrix.os}}-${{matrix.python-version}} + name: test-artifacts-${{matrix.os}}-${{matrix.python-version}} ${{matrix.architecture}} path: .pytest_temp/ success-win: needs: [msvc] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e82a8912e..4b606189a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: check-yaml - id: end-of-file-fixer @@ -9,19 +9,18 @@ repos: - id: mixed-line-ending - id: check-merge-conflict - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.8.0 hooks: - id: black language_version: python3.11 - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 7.1.1 hooks: - id: flake8 - additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 93d465b48..e1bdf1610 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,9 +1,17 @@ version: 2 -formats: all -conda: - environment: environment.yml +build: + os: ubuntu-22.04 + + tools: + python: "3.11" + + apt_packages: + - libpango1.0-dev + python: - version: 3.8 install: + - requirements: docs/requirements.txt - method: pip path: . + +formats: all diff --git a/docs/reference.rst b/docs/reference.rst index 00af64639..fe57f0128 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -10,6 +10,8 @@ Manimpango Reference manimpango.MarkupUtils manimpango.register_font manimpango.unregister_font + manimpango.fc_register_font + manimpango.fc_unregister_font manimpango.list_fonts Enums diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..174b0b13a --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,9 @@ +furo +sphinx +sphinxcontrib-applehelp +sphinxcontrib-devhelp +sphinxcontrib-htmlhelp +sphinxcontrib-jsmath +sphinxcontrib-qthelp +sphinxcontrib-serializinghtml +sphinxext-opengraph diff --git a/manimpango/__init__.py b/manimpango/__init__.py index 16667d3e5..709874512 100644 --- a/manimpango/__init__.py +++ b/manimpango/__init__.py @@ -11,9 +11,9 @@ f"{os.environ['PATH']}" ) try: + from .register_font import * # isort:skip # noqa: F403,F401 from .cmanimpango import * # noqa: F403,F401 from .enums import * # noqa: F403,F401 - from .register_font import * # noqa: F403,F401 except ImportError as ie: # pragma: no cover py_ver = ".".join(map(str, sys.version_info[:3])) msg = f""" diff --git a/manimpango/register_font.pxd b/manimpango/_register_font.pxd similarity index 85% rename from manimpango/register_font.pxd rename to manimpango/_register_font.pxd index 176a5753d..a5ff3f55d 100644 --- a/manimpango/register_font.pxd +++ b/manimpango/_register_font.pxd @@ -1,5 +1,5 @@ - from libc.stddef cimport wchar_t +from pango cimport * cdef extern from "Python.h": @@ -35,6 +35,13 @@ IF UNAME_SYSNAME == "Windows": DWORD fl, unsigned int pdv ) + + ctypedef void* HANDLE + HANDLE CreateMutexA(void* lpMutexAttributes, int bInitialOwner, const char* lpName) + int ReleaseMutex(HANDLE hMutex) + int WaitForSingleObject(HANDLE hHandle, unsigned long dwMilliseconds) + int CloseHandle(HANDLE hObject) + ELIF UNAME_SYSNAME == "Darwin": cdef extern from "Carbon/Carbon.h": ctypedef struct CFURLRef: diff --git a/manimpango/_register_font.pyx b/manimpango/_register_font.pyx new file mode 100644 index 000000000..06d44976e --- /dev/null +++ b/manimpango/_register_font.pyx @@ -0,0 +1,160 @@ +from pathlib import Path +from pango cimport * + +import os +from dataclasses import dataclass + +include "utils.pxi" + +@dataclass(frozen=True) +class RegisteredFont: + """A class to represent a font file. + + Attributes + ---------- + path : :class:`str` + The path to the font file. + """ + + path: str + type: "fontconfig" | "win32" | "macos" + +cpdef bint _fc_register_font(set registered_fonts, str font_path): + a = Path(font_path) + assert a.exists(), f"font doesn't exist at {a.absolute()}" + font_path = os.fspath(a.absolute()) + font_path_bytes = font_path.encode('utf-8') + cdef const unsigned char* fontPath = font_path_bytes + fontAddStatus = FcConfigAppFontAddFile(FcConfigGetCurrent(), fontPath) + if fontAddStatus: + registered_fonts.add(RegisteredFont(font_path, "fontconfig")) + return True + else: + return False + + +cpdef bint _fc_unregister_font(set registered_fonts, str font_path): + FcConfigAppFontClear(NULL) + # remove all type "fontconfig" files + copy = registered_fonts.copy() + for font in copy: + if font.type == 'fontconfig': + registered_fonts.remove(font) + + return True + + +IF UNAME_SYSNAME == "Linux": + _register_font = _fc_register_font + _unregister_font = _fc_unregister_font + + +ELIF UNAME_SYSNAME == "Windows": + cpdef bint _register_font(set registered_fonts, str font_path): + a = Path(font_path) + assert a.exists(), f"font doesn't exist at {a.absolute()}" + font_path = os.fspath(a.absolute()) + cdef LPCWSTR wchar_path = PyUnicode_AsWideCharString(font_path, NULL) + fontAddStatus = AddFontResourceExW( + wchar_path, + FR_PRIVATE, + 0 + ) + + if fontAddStatus > 0: + registered_fonts.add(RegisteredFont(font_path, "win32")) + return True + else: + return False + + + cpdef bint _unregister_font(set registered_fonts, str font_path): + a = Path(font_path) + assert a.exists(), f"font doesn't exist at {a.absolute()}" + font_path = os.fspath(a.absolute()) + + font = RegisteredFont(font_path, "win32") + if font in registered_fonts: + registered_fonts.remove(font) + + cdef LPCWSTR wchar_path = PyUnicode_AsWideCharString(font_path, NULL) + return RemoveFontResourceExW( + wchar_path, + FR_PRIVATE, + 0 + ) + + +ELIF UNAME_SYSNAME == "Darwin": + cpdef bint _register_font(set registered_fonts, str font_path): + a = Path(font_path) + assert a.exists(), f"font doesn't exist at {a.absolute()}" + font_path_bytes_py = str(a.absolute().as_uri()).encode('utf-8') + cdef unsigned char* font_path_bytes = font_path_bytes_py + b = len(a.absolute().as_uri()) + cdef CFURLRef cf_url = CFURLCreateWithBytes(NULL, font_path_bytes, b, 0x08000100, NULL) + res = CTFontManagerRegisterFontsForURL( + cf_url, + kCTFontManagerScopeProcess, + NULL + ) + if res: + registered_fonts.add(RegisteredFont(os.fspath(a.absolute()), "macos")) + return True + else: + return False + + + cpdef bint _unregister_font(set registered_fonts, str font_path): + a = Path(font_path) + assert a.exists(), f"font doesn't exist at {a.absolute()}" + font_path_bytes_py = str(a.absolute().as_uri()).encode('utf-8') + cdef unsigned char* font_path_bytes = font_path_bytes_py + b = len(a.absolute().as_uri()) + cdef CFURLRef cf_url = CFURLCreateWithBytes(NULL, font_path_bytes, b, 0x08000100, NULL) + res = CTFontManagerUnregisterFontsForURL( + cf_url, + kCTFontManagerScopeProcess, + NULL + ) + if res: + font = RegisteredFont(os.fspath(a.absolute()), "macos") + if font in registered_fonts: + registered_fonts.remove(font) + return True + else: + return False + + +cpdef list _list_fonts(tuple registered_fonts): + cdef PangoFontMap* fontmap = pango_cairo_font_map_new() + if fontmap == NULL: + raise MemoryError("Pango.FontMap can't be created.") + + for font in registered_fonts: + if font.type == 'win32': + add_to_fontmap(fontmap, font.path) + + cdef int n_families=0 + cdef PangoFontFamily** families=NULL + pango_font_map_list_families( + fontmap, + &families, + &n_families + ) + if families is NULL or n_families == 0: + raise MemoryError("Pango returned unexpected length on families.") + + family_list = [] + for i in range(n_families): + name = pango_font_family_get_name(families[i]) + # according to pango's docs, the `char *` returned from + # `pango_font_family_get_name`is owned by pango, and python + # shouldn't interfere with it. I hope Cython handles it. + # https://cython.readthedocs.io/en/stable/src/tutorial/strings.html#dealing-with-const + family_list.append(name.decode()) + + g_free(families) + g_object_unref(fontmap) + family_list.sort() + return family_list diff --git a/manimpango/cmanimpango.pyx b/manimpango/cmanimpango.pyx index 8ede399ff..6369f4dd6 100644 --- a/manimpango/cmanimpango.pyx +++ b/manimpango/cmanimpango.pyx @@ -2,9 +2,11 @@ import typing import warnings from xml.sax.saxutils import escape +from . import registered_fonts from .enums import Alignment from .utils import * +include "utils.pxi" class TextSetting: """Formatting for slices of a :class:`manim.mobject.svg.text_mobject.Text` object.""" @@ -48,6 +50,7 @@ def text2svg( cdef double font_size_c = size cdef cairo_status_t status cdef int temp_width + cdef PangoFontMap* fontmap file_name_bytes = file_name.encode("utf-8") surface = cairo_svg_surface_create(file_name_bytes,width,height) @@ -72,6 +75,11 @@ def text2svg( last_line_num = 0 layout = pango_cairo_create_layout(cr) + fontmap = pango_context_get_font_map (pango_layout_get_context (layout)); + + for font_item in registered_fonts: + if font_item.type == 'win32': + add_to_fontmap(fontmap, font_item.path) if layout == NULL: cairo_destroy(cr) @@ -206,6 +214,7 @@ class MarkupUtils: cdef cairo_status_t status cdef double font_size = size cdef int temp_int # a temporary C integer for conversion + cdef PangoFontMap* fontmap file_name_bytes = file_name.encode("utf-8") @@ -235,6 +244,12 @@ class MarkupUtils: cairo_surface_destroy(surface) raise MemoryError("Pango.Layout can't be created from Cairo Context.") + fontmap = pango_context_get_font_map (pango_layout_get_context (layout)); + + for font_item in registered_fonts: + if font_item.type == 'win32': + add_to_fontmap(fontmap, font_item.path) + if pango_width is None: pango_layout_set_width(layout, pango_units_from_double(width)) else: diff --git a/manimpango/pango.pxd b/manimpango/pango.pxd index 397fe3ecf..75254848e 100644 --- a/manimpango/pango.pxd +++ b/manimpango/pango.pxd @@ -139,9 +139,20 @@ cdef extern from "pango/pangocairo.h": PangoLayout *layout, PangoAlignment alignment ) + PangoFontMap* pango_context_get_font_map( + PangoContext* context + ) + PangoContext* pango_layout_get_context( + PangoLayout* layout + ) + + cdef extern from *: """ + #if _WIN32 + #include + #endif #if PANGO_VERSION_CHECK(1,44,0) int set_line_width(PangoLayout *layout,float spacing) { @@ -151,6 +162,23 @@ cdef extern from *: #else int set_line_width(PangoLayout *layout,float spacing){return 0;} #endif + + #if _WIN32 && PANGO_VERSION_CHECK(1,52,0) + gboolean font_map_add_font_file(PangoFontMap *font_map, + const char *font_file_path, + GError **error) + { + return pango_win32_font_map_add_font_file(font_map, font_file_path, error); + } + #else + gboolean font_map_add_font_file(PangoFontMap *font_map, + const char *font_file_path, + GError **error) + { + return 1; + } + #endif + """ # The above docs string is C which is used to # check for the Pango Version there at run time. @@ -158,3 +186,8 @@ cdef extern from *: # pango>=1.44.0 but we support pango>=1.30.0 that why this # conditionals. bint set_line_width(PangoLayout *layout,float spacing) + + # only for windows and 1.52.0+ + gboolean font_map_add_font_file(PangoFontMap *font_map, + const char *font_file_path, + GError **error) diff --git a/manimpango/register_font.py b/manimpango/register_font.py new file mode 100644 index 000000000..623c76115 --- /dev/null +++ b/manimpango/register_font.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from functools import lru_cache + +from ._register_font import ( + RegisteredFont, + _fc_register_font, + _fc_unregister_font, + _list_fonts, + _register_font, + _unregister_font, +) + +__all__ = [ + "fc_register_font", + "fc_unregister_font", + "list_fonts", + "register_font", + "unregister_font", + "registered_fonts", + "RegisteredFont", +] + +# An set of all registered font paths +registered_fonts: set[RegisteredFont] = set() + + +def fc_register_font(font_path: str) -> None: + """This function registers the font file using ``fontconfig`` so that + it is available for use by Pango. On Linux it is aliased to + :func:`register_font` and on Windows and macOS this would work only when + using ``fontconfig`` backend. + + Parameters + ========== + font_path : :class:`str` + Relative or absolute path to font file. + + Returns + ======= + :class:`bool` + True means it worked without any error. + False means there was an unknown error + + Examples + ======== + >>> register_font("/home/roboto.tff") + True + + Raises + ====== + AssertionError + The :param:`font_path` specified doesn't exist. + """ + return _fc_register_font(registered_fonts, font_path) + + +def fc_unregister_font(font_path: str) -> None: + """This function unregister (removes) the font file using + ``fontconfig``. It is mostly optional to call this. + Mainly used in tests. On Linux it is aliased to + :func:`unregister_font` and on Windows and macOS this + would work only when using ``fontconfig`` backend. + + Parameters + ========== + font_path: :class:`str` + For compatibility with the windows function. + + Returns + ======= + :class:`bool` + True means it worked without any error. + False means there was an unknown error + + """ + return _fc_unregister_font(registered_fonts, font_path) + + +def register_font(font_path: str) -> None: + """This function registers the font file using native OS API + to make the font available for use by Pango. On Linux it is + aliased to :func:`fc_register_font` and on Windows and macOS + it uses the native API. + + Parameters + ========== + font_path: :class:`str` + Relative or absolute path to font file. + + Returns + ======= + :class:`bool` + True means it worked without any error. + False means there was an unknown error + + Examples + ======== + >>> register_font("C:/home/roboto.tff") + True + + Raises + ====== + AssertionError + The :param:`font_path` specified doesn't exist. + """ + return _register_font(registered_fonts, font_path) + + +def unregister_font(font_path: str) -> None: + """This function unregister (removes) the font file using native OS API. + It is mostly optional to call this. Mainly used in tests. On Linux it is + aliased to :func:`fc_unregister_font` and on Windows and macOS it uses + the native API. + + Parameters + ========== + font_path: :class:`str` + Relative or absolute path to font file. + + Returns + ======= + :class:`bool` + True means it worked without any error. + False means there was an unknown error + + Examples + ======== + >>> unregister_font("C:/home/roboto.tff") + True + + Raises + ====== + AssertionError + The :param:`font_path` specified doesn't exist. + + """ + return _unregister_font(registered_fonts, font_path) + + +def list_fonts() -> list: + """Lists the fonts available to Pango. + This is usually same as system fonts but it also + includes the fonts added through :func:`register_font` + or :func:`fc_register_font`. + + Returns + ------- + + :class:`list` : + List of fonts sorted alphabetically. + """ + return lru_cache(maxsize=None)(_list_fonts)( + tuple(sorted(registered_fonts, key=lambda x: x.path)) + ) diff --git a/manimpango/register_font.pyx b/manimpango/register_font.pyx deleted file mode 100644 index 5e121f04b..000000000 --- a/manimpango/register_font.pyx +++ /dev/null @@ -1,241 +0,0 @@ -from pathlib import Path - -from pango cimport * - -import os - - -cpdef bint fc_register_font(str font_path): - """This function registers the font file using ``fontconfig`` so that - it is available for use by Pango. On Linux it is aliased to - :func:`register_font` and on Windows and macOS this would work only when - using ``fontconfig`` backend. - - Parameters - ========== - font_path : :class:`str` - Relative or absolute path to font file. - Returns - ======= - :class:`bool` - True means it worked without any error. - False means there was an unknown error - Examples - -------- - >>> register_font("/home/roboto.tff") - True - - Raises - ------ - AssertionError - Font is missing. - """ - a = Path(font_path) - assert a.exists(), f"font doesn't exist at {a.absolute()}" - font_path = os.fspath(a.absolute()) - font_path_bytes=font_path.encode('utf-8') - cdef const unsigned char* fontPath = font_path_bytes - fontAddStatus = FcConfigAppFontAddFile(FcConfigGetCurrent(), fontPath) - if fontAddStatus: - return True - else: - return False - - -cpdef bint fc_unregister_font(str font_path): - """This function unregisters(removes) the font file using - ``fontconfig``. It is mostly optional to call this. - Mainly used in tests. On Linux it is aliased to - :func:`unregister_font` and on Windows and macOS this - would work only when using ``fontconfig`` backend. - - Parameters - ========== - - font_path: :class:`str` - For compatibility with the windows function. - - Returns - ======= - :class:`bool` - True means it worked without any error. - False means there was an unknown error - - """ - FcConfigAppFontClear(NULL) - return True - - -IF UNAME_SYSNAME == "Linux": - register_font = fc_register_font - unregister_font = fc_unregister_font - - -ELIF UNAME_SYSNAME == "Windows": - cpdef bint register_font(str font_path): - """This function registers the font file using native windows API - so that it is available for use by Pango. - - Parameters - ========== - font_path : :class:`str` - Relative or absolute path to font file. - Returns - ======= - :class:`bool` - True means it worked without any error. - False means there was an unknown error - Examples - -------- - >>> register_font("C:/home/roboto.tff") - True - Raises - ------ - AssertionError - Font is missing. - """ - a=Path(font_path) - assert a.exists(), f"font doesn't exist at {a.absolute()}" - font_path = os.fspath(a.absolute()) - cdef LPCWSTR wchar_path = PyUnicode_AsWideCharString(font_path, NULL) - fontAddStatus = AddFontResourceExW( - wchar_path, - FR_PRIVATE, - 0 - ) - if fontAddStatus > 0: - return True - else: - return False - - - cpdef bint unregister_font(str font_path): - """This function unregisters(removes) the font file using - native Windows API. It is mostly optional to call this. - Mainly used in tests. - Parameters - ========== - font_path : :class:`str` - Relative or absolute path to font file. - Returns - ======= - :class:`bool` - True means it worked without any error. - False means there was an unknown error - Raises - ------ - AssertionError - Font is missing. - """ - a=Path(font_path) - assert a.exists(), f"font doesn't exist at {a.absolute()}" - font_path = os.fspath(a.absolute()) - cdef LPCWSTR wchar_path = PyUnicode_AsWideCharString(font_path, NULL) - return RemoveFontResourceExW( - wchar_path, - FR_PRIVATE, - 0 - ) - - -ELIF UNAME_SYSNAME == "Darwin": - cpdef bint register_font(str font_path): - """This function registers the font file using ``CoreText`` API so that - it is available for use by Pango. - Parameters - ========== - font_path : :class:`str` - Relative or absolute path to font file. - Returns - ======= - :class:`bool` - True means it worked without any error. - False means there was an unknown error - Examples - -------- - >>> register_font("/home/roboto.tff") - True - Raises - ------ - AssertionError - Font is missing. - """ - a = Path(font_path) - assert a.exists(), f"font doesn't exist at {a.absolute()}" - font_path_bytes_py = str(a.absolute().as_uri()).encode('utf-8') - cdef unsigned char* font_path_bytes = font_path_bytes_py - b = len(a.absolute().as_uri()) - cdef CFURLRef cf_url = CFURLCreateWithBytes(NULL, font_path_bytes, b, 0x08000100, NULL) - return CTFontManagerRegisterFontsForURL( - cf_url, - kCTFontManagerScopeProcess, - NULL - ) - - - cpdef bint unregister_font(str font_path): - """This function unregisters(removes) the font file using - native ``CoreText`` API. It is mostly optional to call this. - Mainly used in tests. - Parameters - ========== - font_path : :class:`str` - Relative or absolute path to font file. - Returns - ======= - :class:`bool` - True means it worked without any error. - False means there was an unknown error - Raises - ------ - AssertionError - Font is missing. - """ - a = Path(font_path) - assert a.exists(), f"font doesn't exist at {a.absolute()}" - font_path_bytes_py = str(a.absolute().as_uri()).encode('utf-8') - cdef unsigned char* font_path_bytes = font_path_bytes_py - b = len(a.absolute().as_uri()) - cdef CFURLRef cf_url = CFURLCreateWithBytes(NULL, font_path_bytes, b, 0x08000100, NULL) - return CTFontManagerUnregisterFontsForURL( - cf_url, - kCTFontManagerScopeProcess, - NULL - ) - - -cpdef list list_fonts(): - """Lists the fonts available to Pango. - This is usually same as system fonts but it also - includes the fonts added through :func:`register_font`. - - Returns - ------- - - :class:`list` : - List of fonts sorted alphabetically. - """ - cdef PangoFontMap* fontmap = pango_cairo_font_map_new() - if fontmap == NULL: - raise MemoryError("Pango.FontMap can't be created.") - cdef int n_families=0 - cdef PangoFontFamily** families=NULL - pango_font_map_list_families( - fontmap, - &families, - &n_families - ) - if families is NULL or n_families == 0: - raise MemoryError("Pango returned unexpected length on families.") - family_list = [] - for i in range(n_families): - name = pango_font_family_get_name(families[i]) - # according to pango's docs, the `char *` returned from - # `pango_font_family_get_name`is owned by pango, and python - # shouldn't interfere with it. I hope Cython handles it. - # https://cython.readthedocs.io/en/stable/src/tutorial/strings.html#dealing-with-const - family_list.append(name.decode()) - g_free(families) - g_object_unref(fontmap) - family_list.sort() - return family_list diff --git a/manimpango/utils.pxi b/manimpango/utils.pxi new file mode 100644 index 000000000..19ed405a2 --- /dev/null +++ b/manimpango/utils.pxi @@ -0,0 +1,23 @@ +from _register_font cimport * + +import warnings + + +cdef inline add_to_fontmap(PangoFontMap* fontmap, str font_path): + cdef GError *err = NULL + error_message = "" + font_path_bytes = font_path.encode('utf-8') + success = font_map_add_font_file(fontmap, font_path_bytes, &err) + if err == NULL: + error_message = "Unknown error" + else: + error_message = err.message.decode('utf-8') + + if not success: + warnings.warn( + f"Failed to add font at {font_path} to fontmap. Reason: {error_message}", + RuntimeWarning, + stacklevel=2 + ) + + return success diff --git a/packing/build_pango_tests.sh b/packing/build_pango_tests.sh index 96fc4e38f..9740de665 100644 --- a/packing/build_pango_tests.sh +++ b/packing/build_pango_tests.sh @@ -2,11 +2,7 @@ # build and install pango set -e -PANGO_VERSION=1.50.11 -GLIB_VERSION=2.74.0 -FRIBIDI_VERSION=1.0.10 -CAIRO_VERSION=1.17.6 -HARFBUZZ_VERSION=5.3.1 +PANGO_VERSION=1.54.0 FILE_PATH=$PWD PREFIX="$HOME/pangoprefix" @@ -18,14 +14,7 @@ mkdir pango cd pango echo "::group::Downloading Files" -python -m pip install requests python $FILE_PATH/packing/download_and_extract.py "http://download.gnome.org/sources/pango/${PANGO_VERSION%.*}/pango-${PANGO_VERSION}.tar.xz" pango -python $FILE_PATH/packing/download_and_extract.py "http://download.gnome.org/sources/glib/${GLIB_VERSION%.*}/glib-${GLIB_VERSION}.tar.xz" glib -python $FILE_PATH/packing/download_and_extract.py "https://github.com/fribidi/fribidi/releases/download/v${FRIBIDI_VERSION}/fribidi-${FRIBIDI_VERSION}.tar.xz" fribidi -python $FILE_PATH/packing/download_and_extract.py "https://gitlab.freedesktop.org/cairo/cairo/-/archive/${CAIRO_VERSION}/cairo-${CAIRO_VERSION}.tar.gz" cairo -python $FILE_PATH/packing/download_and_extract.py "https://github.com/harfbuzz/harfbuzz/releases/download/${HARFBUZZ_VERSION}/harfbuzz-${HARFBUZZ_VERSION}.tar.xz" harfbuzz - -python -m pip uninstall -y requests echo "::endgroup::" @@ -37,38 +26,12 @@ echo "Installing Meson and Ninja" pip3 install -U meson ninja echo "::endgroup::" -echo "::group::Building and Install Glib" -meson setup --prefix=$PREFIX --buildtype=release -Dselinux=disabled -Dlibmount=disabled glib_builddir glib -meson compile -C glib_builddir -meson install -C glib_builddir -echo "::endgroup::" - -echo "::group::Building and Install Fribidi" -meson setup --prefix=$PREFIX --buildtype=release fribidi_builddir fribidi -meson compile -C fribidi_builddir -meson install -C fribidi_builddir -echo "::endgroup::" - -echo "::group::Building and Installing Cairo" -echo "Getting patch" -curl -L https://gitlab.freedesktop.org/cairo/cairo/-/commit/cdb7c298c7b89307ad69b94a1126221bd7c06579.patch -o test.diff -cd cairo -patch -Nbp1 -i "$PWD/../test.diff" || true -# it is fine to fail because the CI config is missing. -cd .. -meson setup --prefix=$PREFIX --default-library=shared --buildtype=release -Dfontconfig=enabled -Dfreetype=enabled -Dglib=enabled -Dzlib=enabled -Dtee=enabled cairo_builddir cairo -meson compile -C cairo_builddir -meson install --no-rebuild -C cairo_builddir -echo "::endgroup::" - -echo "::group::Building and Installing Harfbuzz" -meson setup --prefix=$PREFIX -Dcoretext=enabled --buildtype=release -Dtests=disabled -Ddocs=disabled harfbuzz_builddir harfbuzz -meson compile -C harfbuzz_builddir -meson install -C harfbuzz_builddir -echo "::endgroup::" - echo "::group::Buildling and Installing Pango" -meson setup --prefix=$PREFIX --buildtype=release -Dintrospection=disabled pango_builddir pango +meson setup --prefix=$PREFIX --buildtype=release \ + -Dintrospection=disabled \ + -Dfontconfig=enabled \ + --force-fallback-for=fontconfig \ + pango_builddir pango meson compile -C pango_builddir meson install -C pango_builddir echo "::endgroup::" diff --git a/packing/build_pkgconfig.ps1 b/packing/build_pkgconfig.ps1 deleted file mode 100644 index ffd39f38f..000000000 --- a/packing/build_pkgconfig.ps1 +++ /dev/null @@ -1,28 +0,0 @@ -param($output) -$ErrorActionPreference = 'Stop' -if (Test-Path -Path $output){ - Write-Output "File alread Exists. Exiting." - exit 0 -} -$currentloc = $PWD -$host_arch = "amd64" -$arch = "amd64" -Set-Location $env:TEMP -Write-Output "Getting pkg-config" -Invoke-WebRequest https://github.com/pkgconf/pkgconf/archive/pkgconf-1.7.0.zip -o pkgconf.zip -7z x pkgconf.zip -Move-Item -Path pkgconf-* -Destination pkgconf -Force -$installationPath = vswhere.exe -prerelease -latest -property installationPath -if ($installationPath -and (test-path "$installationPath\Common7\Tools\vsdevcmd.bat")) { - & "${env:COMSPEC}" /s /c "`"$installationPath\Common7\Tools\vsdevcmd.bat`" -no_logo -host_arch=$host_arch -arch=$arch && set" | foreach-object { - $name, $value = $_ -split '=', 2 - set-content env:\"$name" $value - } -} -pip install --upgrade meson==0.55.3 ninja -$env:PKG_CONFIG_PATH="" -meson setup --prefix=$output --buildtype=release -Dtests=false pkg_conf_build pkgconf -meson compile -C pkg_conf_build -meson install --no-rebuild -C pkg_conf_build -Rename-Item $output\bin\pkgconf.exe $output\bin\pkg-config.exe -Force -Set-Location $currentloc diff --git a/packing/download_and_extract.py b/packing/download_and_extract.py index 6b3ee4d5e..a9baec61b 100644 --- a/packing/download_and_extract.py +++ b/packing/download_and_extract.py @@ -6,35 +6,31 @@ import shutil import tarfile import tempfile +import urllib.request from pathlib import Path -import requests - logging.basicConfig(format="%(levelname)s - %(message)s", level=logging.INFO) parser = argparse.ArgumentParser(description="Download things.") parser.add_argument("url", help="the url to download") parser.add_argument("folder", help="folder name to extract") args = parser.parse_args() -a = requests.get(args.url) -logging.info("Download Complete Extracting") -assert a.status_code != 404, "Does not exist." with tempfile.TemporaryDirectory() as tmpdirname: - tmpdirname = Path(tmpdirname) - fname = Path(args.url).name - for i in tmpdirname.iterdir(): - print(i) - with open(tmpdirname / fname, "wb") as f: - logging.info(f"Saving to {tmpdirname / fname}") - f.write(a.content) - with tarfile.open(tmpdirname / fname, "r") as tar: - logging.info(f"Extracting {tmpdirname / fname} to {tmpdirname}") + fname = Path(tmpdirname, Path(args.url).name) + + logging.info(f"Download & Saving file to {fname}") + + urllib.request.urlretrieve(args.url, fname) + + with tarfile.open(fname, "r") as tar: + logging.info(f"Extracting {fname} to {tmpdirname}") tar.extractall(tmpdirname) + logging.info( - f"Moving {str(tmpdirname / Path(Path(args.url).stem).stem)} " + f"Moving {Path(tmpdirname, Path(Path(args.url).stem).stem)} " f"to {str(args.folder)}" ) shutil.move( - str(tmpdirname / Path(Path(args.url).stem).stem), + Path(tmpdirname, Path(Path(args.url).stem).stem), str(args.folder), ) diff --git a/packing/download_dlls.py b/packing/download_dlls.py index 1d04b8d6c..733e6db01 100644 --- a/packing/download_dlls.py +++ b/packing/download_dlls.py @@ -3,23 +3,21 @@ import logging import os import re -import shlex import shutil import struct -import subprocess import tempfile import zipfile from pathlib import Path from urllib.request import urlretrieve as download -PANGO_VERSION = "1.50.5" +PANGO_VERSION = "1.54.0-v1" def get_platform(): if (struct.calcsize("P") * 8) == 32: - return "32" + return "x86" else: - return "64" + return "x64" logging.basicConfig(format="%(levelname)s - %(message)s", level=logging.DEBUG) @@ -30,7 +28,7 @@ def get_platform(): download_url = ( "https://github.com/naveen521kk/pango-build/releases" - f"/download/v{PANGO_VERSION}/pango-build-win{plat}.zip" + f"/download/v{PANGO_VERSION}/pango-v{PANGO_VERSION}-{plat}.zip" ) final_location = Path(r"C:\cibw\vendor") download_location = Path(tempfile.mkdtemp()) @@ -52,7 +50,7 @@ def get_platform(): logging.info("Completed Extracting.") logging.info("Moving Files accordingly.") -plat_location = download_location +plat_location = download_location / f"pango-{plat}" for src_file in plat_location.glob("*"): logging.debug(f"Moving {src_file} to {final_location}...") shutil.move(str(src_file), str(final_location)) @@ -63,7 +61,7 @@ def get_platform(): rex = re.compile("^prefix=(.*)") -def new_place(some): +def new_place(_: re.Match[str]) -> str: return f"prefix={str(final_location.as_posix())}" @@ -76,12 +74,31 @@ def new_place(some): with open(i, "w") as f: f.write(final) -logging.info("Building pkg-config") +logging.info("Getting pkg-config") +download( + url="https://github.com/naveen521kk/pango-build" + f"/releases/download/v{PANGO_VERSION}/pkgconf.zip", + filename=download_file, +) +with zipfile.ZipFile( + download_file, mode="r", compression=zipfile.ZIP_DEFLATED +) as file: # noqa: E501 + file.extractall(download_location) -pkg_config_log = r"C:\cibw\pkg-config" -build_file_loc = str( - (Path(__file__).parent.resolve() / "build_pkgconfig.ps1").absolute() +os.makedirs(str(final_location / "bin"), exist_ok=True) +shutil.move( + str(download_location / "pkgconf" / "bin" / "pkgconf.exe"), + str(final_location / "bin"), +) +# alias pkgconf to pkg-config +shutil.copy( + final_location / "bin" / "pkgconf.exe", final_location / "bin" / "pkg-config.exe" ) -command = f'powershell -nologo -noexit -file "{build_file_loc}" "{pkg_config_log}"' -print(command) -subprocess.check_call(shlex.split(command), shell=True) + +# On MSVC, meson would create static libraries as +# libcairo.a but setuptools doens't know about it. +libreg = re.compile(r"lib(?P\S*)\.a") +libdir = final_location / "lib" +for lib in libdir.glob("lib*.a"): + name = libreg.match(lib.name).group("name") + ".lib" + shutil.move(lib, libdir / name) diff --git a/pyproject.toml b/pyproject.toml index 9a0f7bf50..55ae6ce21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "setuptools.build_meta" -requires = ["Cython>=3.0.2", "setuptools>=59.2.0", "wheel"] +requires = ["Cython>=3.0.2,<3.1", "setuptools>=59.2.0", "wheel"] [tool.isort] # from https://black.readthedocs.io/en/stable/compatible_configs.html diff --git a/requirements-dev.txt b/requirements-dev.txt index 46b2b8a86..fd3f9782c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -pytest-cov>=4.0,<5.0 +pytest-cov>=4.0 pytest>=7.0.0,<8.0.0 Cython>=3.0.2 -coverage>=7.3.2,<8.0.0 +coverage>=7.3.2 setuptools>=59.2.0 diff --git a/setup.py b/setup.py index 35188c428..90b48770d 100644 --- a/setup.py +++ b/setup.py @@ -32,10 +32,11 @@ def get_version(): + dic = {} version_file = "manimpango/_version.py" with open(version_file) as f: - exec(compile(f.read(), version_file, "exec")) - return locals()["__version__"] + exec(compile(f.read(), version_file, "exec"), dic) + return dic["__version__"] NAME = "ManimPango" @@ -43,7 +44,7 @@ def get_version(): MINIMUM_PANGO_VERSION = "1.30.0" DEBUG = False -if sys.platform == "win32" and sys.version_info >= (3, 13): +if sys.platform == "win32" and sys.version_info >= (3, 14): import atexit atexit.register( @@ -208,11 +209,15 @@ def update_dict(dict1: dict, dict2: dict): returns = {} returns["libraries"] = NEEDED_LIBS if sys.platform == "win32": - returns["libraries"] += ["Gdi32"] + _pkg_config_pangowin32 = PKG_CONFIG("pangowin32") + returns = update_dict(returns, _pkg_config_pangowin32.setuptools_args) + + returns["libraries"] += ["Gdi32", "User32", "Advapi32", "Shell32", "Ole32"] if not sysconfig.get_platform().startswith("mingw"): # MSVC compilers returns["libraries"] = list( set(returns["libraries"]).difference(IGNORE_LIBS_WIN) ) + returns["extra_compile_args"] = ["-DCAIRO_WIN32_STATIC_BUILD"] if hasattr(returns, "define_macros"): returns["define_macros"] += [("UNICODE", 1)] else: @@ -233,8 +238,8 @@ def update_dict(dict1: dict, dict2: dict): **returns, ), Extension( - "manimpango.register_font", - [str(base_file / ("register_font" + ext))], + "manimpango._register_font", + [str(base_file / ("_register_font" + ext))], **returns, ), ] diff --git a/tests/test_fonts.py b/tests/test_fonts.py index 3608c6c0e..c66767097 100644 --- a/tests/test_fonts.py +++ b/tests/test_fonts.py @@ -118,8 +118,8 @@ def test_adding_dummy_font(tmpdir): dummy = tmpdir / "font.ttf" with open(dummy, "wb") as f: f.write(b"dummy") + assert not manimpango.register_font(str(dummy)), "Registered a dummy font?" - assert not manimpango.fc_register_font(str(dummy)), "Registered a dummy font?" def test_simple_fonts_render(tmpdir): @@ -132,8 +132,14 @@ def test_simple_fonts_render(tmpdir): not sys.platform.startswith("linux"), reason="unsupported api other than linux" ) def test_both_fc_and_register_font_are_same(): - assert manimpango.fc_register_font == manimpango.register_font - assert manimpango.fc_unregister_font == manimpango.unregister_font + assert ( + manimpango._register_font._fc_register_font + == manimpango._register_font._register_font + ) + assert ( + manimpango._register_font._fc_unregister_font + == manimpango._register_font._unregister_font + ) @pytest.mark.parametrize("font_file", font_lists_dict) diff --git a/tests/test_version.py b/tests/test_version.py index a75047473..1bcc5e1c0 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -3,11 +3,11 @@ def test_pango_version(): import manimpango v = manimpango.pango_version() - assert type(v) == str + assert isinstance(v, str) def test_cairo_version(): import manimpango v = manimpango.cairo_version() - assert type(v) == str + assert isinstance(v, str)