pax_global_header00006660000000000000000000000064152366005430014516gustar00rootroot0000000000000052 comment=4e2ade0ab833eab1cfb6006edbfc96af5aa1d61e nanoemoji-0.16.0/000077500000000000000000000000001523660054300135615ustar00rootroot00000000000000nanoemoji-0.16.0/.editorconfig000066400000000000000000000002511523660054300162340ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true [*.py] profile = black nanoemoji-0.16.0/.git_archival.txt000066400000000000000000000002031523660054300170270ustar00rootroot00000000000000node: 4e2ade0ab833eab1cfb6006edbfc96af5aa1d61e node-date: 2026-08-11T13:02:59+02:00 describe-name: v0.16.0 ref-names: tag: v0.16.0 nanoemoji-0.16.0/.gitattributes000066400000000000000000000000401523660054300164460ustar00rootroot00000000000000.git_archival.txt export-subst nanoemoji-0.16.0/.github/000077500000000000000000000000001523660054300151215ustar00rootroot00000000000000nanoemoji-0.16.0/.github/dependabot.yml000066400000000000000000000004631523660054300177540ustar00rootroot00000000000000# Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" open-pull-requests-limit: 10 nanoemoji-0.16.0/.github/workflows/000077500000000000000000000000001523660054300171565ustar00rootroot00000000000000nanoemoji-0.16.0/.github/workflows/ci.yml000066400000000000000000000102301523660054300202700ustar00rootroot00000000000000name: Continuous Test + Deploy on: push: branches: [main] tags: ["v*.*.*"] pull_request: branches: ['**'] permissions: contents: read jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python 3.12 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install tox run: pip install tox - name: Run style and typing checks run: tox -e lint test: runs-on: ${{ matrix.config.platform }} strategy: matrix: config: - {platform: ubuntu-latest, python-version: "3.8"} - {platform: ubuntu-latest, python-version: "3.9"} - {platform: ubuntu-latest, python-version: "3.10"} - {platform: ubuntu-latest, python-version: "3.11"} - {platform: windows-latest, python-version: "3.11"} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.config.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.config.python-version }} - name: Install tox run: pip install tox - name: Run the tests run: tox -e py deploy: # only run if the commit is tagged... if: startsWith(github.ref, 'refs/tags/v') # ... and both the lint and test jobs completed successfully needs: - lint - test runs-on: ubuntu-latest # This is required to create a release using Github integration token # https://github.com/softprops/action-gh-release?tab=readme-ov-file#permissions permissions: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # setuptools_scm requires the git clone to not be 'shallow' fetch-depth: 0 persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install build twine - 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@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: body_path: "${{ runner.temp }}/release_notes.md" draft: false prerelease: ${{ env.IS_PRERELEASE }} - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | if [ "$IS_PRERELEASE" == true ]; then echo "DEBUG: This is a pre-release" else echo "DEBUG: This is a final release" fi python -m build twine upload dist/* nanoemoji-0.16.0/.gitignore000066400000000000000000000002431523660054300155500ustar00rootroot00000000000000*.egg-info/ .pytype/ __pycache__/ .venv/ venv/ build/ dist/ .eggs/ src/nanoemoji/_version.py .logpile/ .vscode/ ninja/.ninja_deps ninja/.ninja_log .DS_Store .tox/ nanoemoji-0.16.0/LICENSE000066400000000000000000000261361523660054300145760ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. nanoemoji-0.16.0/README.md000066400000000000000000000075551523660054300150540ustar00rootroot00000000000000[![CI Build Status](https://github.com/googlefonts/nanoemoji/workflows/Continuous%20Test%20+%20Deploy/badge.svg?branch=main)](https://github.com/googlefonts/nanoemoji/actions/workflows/ci.yml?query=workflow%3ATest+branch%3Amain) [![PyPI](https://img.shields.io/pypi/v/nanoemoji.svg)](https://pypi.org/project/nanoemoji/) [![pyup](https://pyup.io/repos/github/googlefonts/nanoemoji/shield.svg)](https://pyup.io/repos/github/googlefonts/nanoemoji) # nanoemoji A wee tool to build color fonts, including the proposed [COLRv1](https://github.com/googlefonts/colr-gradients-spec/blob/main/colr-gradients-spec.md). Relies heavily on Skia via [picosvg](https://github.com/googlefonts/picosvg), as well as [`resvg`](https://github.com/RazrFalcon/resvg) to rasterize SVG to PNG for the bitmap color formats. For example, to build a COLRv1 font with a focus on [handwriting](https://rsheeter.github.io/android_fonts/emoji.html?q=u:270d) do the following in a [venv](https://docs.python.org/3/library/venv.html): ```bash pip install -e . nanoemoji --helpfull nanoemoji --color_format glyf_colr_1 $(find ../noto-emoji/svg -name 'emoji_u270d*.svg') ``` Requires Python 3.8 or greater. ## Color table support | Format | Support | Notes | | --- | --- | --- | | [COLRv1](https://docs.microsoft.com/en-us/typography/opentype/spec/colr#colr-formats) | Yes | x-glyph reuse | | [COLRv0](https://docs.microsoft.com/en-us/typography/opentype/spec/colr#colr-formats) | Yes | x-glyph reuse (limited), no gradients | | [SVG](https://docs.microsoft.com/en-us/typography/opentype/spec/svg) | Yes | x-glyph reuse | | [sbix](https://docs.microsoft.com/en-us/typography/opentype/spec/sbix) | Yes | Only for Mac Safari due to https://github.com/harfbuzz/harfbuzz/issues/2679#issuecomment-1021419864. Only square bitmaps. Uses [`resvg`](https://github.com/RazrFalcon/resvg).| | [CBDT](https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt) | Yes | Only square bitmaps. Uses [`resvg`](https://github.com/RazrFalcon/resvg).| ### Adding color tables to existing fonts :warning: _under active development, doubtless full of bugs_ Given at least one vector color table (COLR or SVG) the other vector color table and bitmap table(s) can be generated: ```shell # Adds COLR to a font with SVG and vice versa maximum_color my_colr_font.ttf # Adds COLR to a font with SVG and vice versa, and generates a CBDT table maximum_color --bitmaps my_colr_font.ttf ``` The intended result is a font that will Just Work in any modern browser: | Color table | Target browser | Notes | | --- | --- | --- | | COLR | Chrome 98+ | https://developer.chrome.com/blog/colrv1-fonts/ | | SVG | Firefox, Safari | | | CBDT | Chrome <98 | Only generated if you pass `--bitmaps` to `maximum_color`| Note that at time of writing Chrome 98+ prefers CBDT to COLR. Same for any environment, such as Android, that relies on Skia, which in turns depends on FreeType to parse color tables (cf. [Skia's issue 12945][skia-12945] and [FreeType's issue 1142][ft-1142]). Also CBDT is huge. So ... maybe take the resulting font and subset it per-browser if at all possible. Wouldn't it be nice if Google Fonts did that for you? [skia-12945]: https://bugs.chromium.org/p/skia/issues/detail?id=12945 [ft-1142]: https://gitlab.freedesktop.org/freetype/freetype/-/issues/1142 ## Releasing See https://googlefonts.github.io/python#make-a-release. ## QA To help confirm valid output `nanoemoji` can optionally perform image diffs between browser rendering of the original SVGs and rendering from the compiled font. Chrome must be installed in the normal location. Usage: ``` # Get some svgs to play with git clone --recursive git@github.com:googlefonts/color-fonts.git # Now run nanoemoji, render some hands, and see how we do! # https://rsheeter.github.io/android_fonts/emoji.html?q=u:270b nanoemoji --gen_svg_font_diffs \ $(find color-fonts/font-srcs/noto-emoji/svg -name 'emoji_u270b*.svg') ``` nanoemoji-0.16.0/docs/000077500000000000000000000000001523660054300145115ustar00rootroot00000000000000nanoemoji-0.16.0/docs/contributing.md000066400000000000000000000021111523660054300175350ustar00rootroot00000000000000# How to Contribute We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. ## Contributor License Agreement Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project. Head over to to see your current agreements on file or to sign a new one. You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different project), you probably don't need to do it again. ## Code reviews All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. ## Community Guidelines This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). nanoemoji-0.16.0/precommit.sh000077500000000000000000000013561523660054300161240ustar00rootroot00000000000000#!/usr/bin/env bash # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Things that should be run (and pass) before submitting code. # # everything should succeed set -e black src tests pytest pytype echo "Seems OK :)" nanoemoji-0.16.0/pyproject.toml000066400000000000000000000004551523660054300165010ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.2", "setuptools_scm"] build-backend = "setuptools.build_meta" [tool.pytype] inputs = ["src/nanoemoji"] # generated by setuptools_scm at build time; its PEP 604 annotations # (`str | None`) aren't analyzable by pytype exclude = ["src/nanoemoji/_version.py"] nanoemoji-0.16.0/setup.py000066400000000000000000000037431523660054300153020ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from setuptools import setup, find_packages extras_require={ "test": [ "pytest", ], "lint": [ "black", "pytype", ], } extras_require["dev"] = extras_require["test"] + extras_require["lint"] setup( name="nanoemoji", use_scm_version={"write_to": "src/nanoemoji/_version.py"}, package_dir={"": "src"}, packages=find_packages(where="src"), entry_points={"console_scripts": [ "nanoemoji=nanoemoji.nanoemoji:main", "maximum_color=nanoemoji.maximum_color:main", ]}, setup_requires=["setuptools_scm"], include_package_data=True, install_requires=[ "absl-py>=0.9.0", "fonttools[ufo]>=4.36.0", "importlib_resources>=3.3.0; python_version < '3.9'", "lxml>=4.0", "ninja>=1.10.0.post1", "picosvg>=0.22.1", "pillow>=7.2.0", "regex>=2020.4.4", "toml>=0.10.1", "ufo2ft[cffsubr]>=2.24.0", "ufoLib2>=0.6.2", "resvg-cli>=0.22.0.post3", "zopfli>=0.2.1", "pngquant-cli>=2.17.0.post5", ], extras_require=extras_require, python_requires=">=3.8", # this is for type checker to use our inline type hints: # https://www.python.org/dev/peps/pep-0561/#id18 package_data={"picosvg": ["py.typed"]}, # metadata to display on PyPI author="Rod S", author_email="rsheeter@google.com", description=("Compiler for color fonts"), ) nanoemoji-0.16.0/src/000077500000000000000000000000001523660054300143505ustar00rootroot00000000000000nanoemoji-0.16.0/src/nanoemoji/000077500000000000000000000000001523660054300163275ustar00rootroot00000000000000nanoemoji-0.16.0/src/nanoemoji/__init__.py000066400000000000000000000012741523660054300204440ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. try: from ._version import version as __version__ # type: ignore except ImportError: __version__ = "0.0.0+unknown" nanoemoji-0.16.0/src/nanoemoji/bitmap_tables.py000066400000000000000000000274731523660054300215240ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helps with bitmap tables. CBDT inspired by https://github.com/googlefonts/noto-emoji/blob/main/third_party/color_emoji/emoji_builder.py. """ from collections import defaultdict from fontTools import ttLib from fontTools.ttLib.tables.BitmapGlyphMetrics import BigGlyphMetrics, SmallGlyphMetrics from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph from fontTools.ttLib.tables.sbixStrike import Strike as SbixStrike from fontTools.ttLib.tables.E_B_L_C_ import ( Strike as CblcStrike, SbitLineMetrics as CblcSbitLineMetrics, eblc_index_sub_table_1 as CblcIndexSubTable1, eblc_index_sub_table_2 as CblcIndexSubTable2, ) from fontTools.ttLib.tables.C_B_D_T_ import cbdt_bitmap_format_17 as CbdtBitmapFormat17 from functools import reduce from io import BytesIO from nanoemoji.config import FontConfig from nanoemoji.color_glyph import ColorGlyph from nanoemoji.png import PNG from nanoemoji.util import only from typing import ( List, NamedTuple, Sequence, Tuple, ) import sys _INT8_RANGE = range(-128, 127 + 1) _UINT8_RANGE = range(0, 255 + 1) _INT16_RANGE = range(-32768, 32767 + 1) _UINT16_RANGE = range(0, 65536 + 1) # https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt#table-structure CBDT_HEADER_SIZE = 4 # https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt#format-17-small-metrics-png-image-data _CBDT_SMALL_METRIC_PNGS = 17 # SmallGlyphMetrics(5) + dataLen(4) _CBDT_SMALL_METRIC_PNG_HEADER_SIZE = 5 + 4 def _nudge_into_range(arange: range, value: int, max_move: int = 1) -> int: if value in arange: return value if value > max(arange) and value - max_move <= max(arange): return max(arange) if value < min(arange) and value + max_move >= min(arange): return min(arange) return value class BitmapMetrics(NamedTuple): x_offset: int y_offset: int line_height: int line_ascent: int @classmethod def create( cls, config: FontConfig, bitmap_resolution: int, image_data: PNG, ppem: int, offset_range: range, resolution_range: range, ) -> "BitmapMetrics": ascent = config.ascender descent = -config.descender line_height = round((ascent + descent) * ppem / float(config.upem)) line_ascent = ascent * ppem / float(config.upem) # center within advance metrics = BitmapMetrics( x_offset=_nudge_into_range( offset_range, max( round( (_width_in_pixels(config, image_data) - bitmap_resolution) / 2 ), 0, ), ), y_offset=_nudge_into_range( offset_range, round(line_ascent - 0.5 * (line_height - bitmap_resolution)), ), line_height=line_height, line_ascent=round(line_ascent), ) # The FontTools errors when values are out of bounds are a bit nasty # so check here for earlier and more helpful termination assert ( bitmap_resolution in resolution_range ), f"bitmap_resolution out of bounds: {bitmap_resolution}" assert metrics.y_offset in offset_range, f"y_offset out of bounds: {metrics}" return metrics def _pixels_to_funits(config: FontConfig, bitmap_pixel_height: int) -> Tuple[int, int]: # the bitmap is vertically scaled to fill the space desc to asc # this gives us a ratio between pixels and upem funits = config.ascender - config.descender return (bitmap_pixel_height, funits) def _width_in_pixels(config: FontConfig, image_data: PNG) -> int: pixels, funits = _pixels_to_funits(config, image_data.size[1]) width_funits = image_data.size[0] * funits / pixels width_funits = max(config.width, width_funits) assert width_funits > 0 return round(width_funits * pixels / funits) def _ppem(config: FontConfig, bitmap_pixel_height: int) -> int: pixels, funits = _pixels_to_funits(config, bitmap_pixel_height) return round(config.upem * pixels / funits) def _cbdt_record_size(image_format: int, image_data: bytes) -> int: assert image_format == _CBDT_SMALL_METRIC_PNGS, "Unrecognized format" return _CBDT_SMALL_METRIC_PNG_HEADER_SIZE + len(image_data) def _cbdt_bitmapdata_offsets( initial_offset: int, image_format: int, color_glyphs: Sequence[ColorGlyph] ) -> List[Tuple[int, int]]: # TODO is this right? - feels dumb. But ... compile crashes if locations are unknown. offsets = [] offset = initial_offset for color_glyph in color_glyphs: offsets.append(offset) offset += _cbdt_record_size(image_format, color_glyph.bitmap) offsets.append(offset) # capture end of stream return list(zip(offsets, offsets[1:])) def _cbdt_bitmap_data( config: FontConfig, metrics: BitmapMetrics, image_data: PNG ) -> CbdtBitmapFormat17: bitmap_data = CbdtBitmapFormat17(b"", None) bitmap_data.metrics = SmallGlyphMetrics() bitmap_data.metrics.width, bitmap_data.metrics.height = image_data.size bitmap_data.metrics.BearingX = metrics.x_offset bitmap_data.metrics.BearingY = metrics.y_offset bitmap_data.metrics.Advance = _width_in_pixels(config, image_data) bitmap_data.imageData = image_data return bitmap_data def make_sbix_table( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph], ): sbix = ttLib.newTable("sbix") ttfont[sbix.tableTag] = sbix glyphs_by_bitmap_size = defaultdict(list) for color_glyph in color_glyphs: glyphs_by_bitmap_size[color_glyph.bitmap.size[1]].append(color_glyph) for bitmap_pixel_height in sorted(glyphs_by_bitmap_size.keys()): ppem = _ppem(config, bitmap_pixel_height) strike = SbixStrike() strike.ppem = ppem strike.resolution = 72 # pixels per inch sbix.strikes[strike.ppem] = strike for color_glyph in glyphs_by_bitmap_size[bitmap_pixel_height]: # TODO: if we've seen these bytes before set graphicType "dupe", referenceGlyphName image_data = color_glyph.bitmap metrics = BitmapMetrics.create( config, bitmap_pixel_height, image_data, strike.ppem, _INT16_RANGE, _UINT16_RANGE, ) glyph_name = ttfont.getGlyphName(color_glyph.glyph_id) glyph = SbixGlyph( graphicType="png", glyphName=glyph_name, imageData=image_data, originOffsetX=metrics.x_offset, originOffsetY=metrics.line_ascent - metrics.line_height, ) strike.glyphs[glyph_name] = glyph def _make_cbdt_strike( config: FontConfig, ttfont: ttLib.TTFont, data_offset: int, color_glyphs: Sequence[ColorGlyph], ): min_gid, max_gid = color_glyphs[0].glyph_id, color_glyphs[-1].glyph_id assert max_gid - min_gid + 1 == len( color_glyphs ), "Below assumes color gyphs gids are consecutive" bitmap_pixel_height = only({c.bitmap.size[1] for c in color_glyphs}) ppem = _ppem(config, bitmap_pixel_height) strike = CblcStrike() strike.bitmapSizeTable.startGlyphIndex = min_gid strike.bitmapSizeTable.endGlyphIndex = max_gid strike.bitmapSizeTable.ppemX = ppem strike.bitmapSizeTable.ppemY = ppem strike.bitmapSizeTable.colorRef = 0 strike.bitmapSizeTable.bitDepth = 32 # https://docs.microsoft.com/en-us/typography/opentype/spec/eblc#bitmapFlags strike.bitmapSizeTable.flags = 1 # HORIZONTAL_METRICS line_metrics = CblcSbitLineMetrics() line_metrics.caretSlopeNumerator = 0 line_metrics.caretSlopeDenominator = 0 line_metrics.caretSlopeDenominator = 0 line_metrics.caretOffset = 0 line_metrics.minOriginSB = 0 line_metrics.minAdvanceSB = 0 line_metrics.maxBeforeBL = 0 line_metrics.minAfterBL = 0 line_metrics.pad1 = 0 line_metrics.pad2 = 0 metrics = { c.glyph_id: BitmapMetrics.create( config, only(config.bitmap_resolutions), c.bitmap, ppem, _INT8_RANGE, _UINT8_RANGE, ) for c in color_glyphs } data = { ttfont.getGlyphName(c.glyph_id): _cbdt_bitmap_data( config, metrics[c.glyph_id], c.bitmap ) for c in color_glyphs } line_height = only({m.line_height for m in metrics.values()}) line_metrics.ascender = round(config.ascender * ppem / config.upem) line_metrics.descender = -(line_height - line_metrics.ascender) line_metrics.widthMax = max(d.metrics.Advance for d in data.values()) strike.bitmapSizeTable.hori = line_metrics strike.bitmapSizeTable.vert = line_metrics # https://docs.microsoft.com/en-us/typography/opentype/spec/eblc#indexsubtables # Apparently you cannot build a CBLC index subtable w/o providing bytes & font?! # If we build from empty bytes and fill in the fields all is well index_subtable = CblcIndexSubTable1(b"", ttfont) # CBLC image format matches https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt#glyph-bitmap-data-formats # We are using small metrics and PNG images exclusively for now index_subtable.indexFormat = 1 index_subtable.imageFormat = _CBDT_SMALL_METRIC_PNGS index_subtable.imageSize = only(config.bitmap_resolutions) index_subtable.names = [ttfont.getGlyphName(c.glyph_id) for c in color_glyphs] index_subtable.locations = _cbdt_bitmapdata_offsets( data_offset, index_subtable.imageFormat, color_glyphs ) strike.indexSubTables = [index_subtable] return strike, data def raise_if_too_big_for_cbdt(color_glyphs: Sequence[ColorGlyph]): too_big = sorted( (c for c in color_glyphs if max(c.bitmap.size) not in _UINT8_RANGE), key=lambda c: c.bitmap_filename, ) if not too_big: return raise ValueError( "Bitmap is too big for CBDT, try lowering bitmap_resolution: " + ",".join(c.bitmap_filename for c in too_big) ) def make_cbdt_table( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph], ): # CBDT is a wee bit limited in pixel size raise_if_too_big_for_cbdt(color_glyphs) # bitmap tables don't like it when we're out of order color_glyphs = sorted(color_glyphs, key=lambda c: c.glyph_id) cbdt = ttLib.newTable("CBDT") ttfont[cbdt.tableTag] = cbdt cblc = ttLib.newTable("CBLC") ttfont[cblc.tableTag] = cblc cblc.version = cbdt.version = 3.0 cblc.strikes = [] cbdt.strikeData = [] data_offset = CBDT_HEADER_SIZE while color_glyphs: # grab the next run w/consecutive gids min_gid = color_glyphs[0].glyph_id end = 1 while ( len(color_glyphs) > end and color_glyphs[end].glyph_id == color_glyphs[end - 1].glyph_id + 1 ): end += 1 color_glyph_run = color_glyphs[:end] color_glyphs = color_glyphs[end:] strike, data = _make_cbdt_strike(config, ttfont, data_offset, color_glyph_run) for sub_table in strike.indexSubTables: data_offset = max(sub_table.locations[-1][-1], data_offset) cblc.strikes.append(strike) cbdt.strikeData.append(data) nanoemoji-0.16.0/src/nanoemoji/codepoints.py000066400000000000000000000025051523660054300210520ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helps deal with emoji codepoints.""" import os import regex import absl def from_filename(filename): match = regex.search(r"(?:^emoji_u)?(?:[-_]?([0-9a-fA-F]{1,}))+", filename) if not match: raise ValueError(f"Bad filename {filename}; unable to extract codepoints") return tuple(int(s, 16) for s in match.captures(1)) def string(codepoints): return ",".join("%04x" % c for c in codepoints) def csv_line(filename): filename = os.path.basename(filename) return f"{filename},{string(from_filename(filename))}" def parse_csv_line(line): parts = line.split(",") return (parts[0], tuple(int(p, 16) for p in parts[1:])) def parse_csv(filename): with open(filename) as f: return tuple(parse_csv_line(l) for l in f) nanoemoji-0.16.0/src/nanoemoji/color_glyph.py000066400000000000000000000412321523660054300212240ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import math from absl import logging import dataclasses from itertools import chain, groupby, combinations from lxml import etree # type: ignore from nanoemoji.colors import Color from nanoemoji.config import FontConfig from nanoemoji.paint import ( Extend, ColorStop, CompositeMode, Paint, PaintColrLayers, PaintComposite, PaintGlyph, PaintLinearGradient, PaintRadialGradient, PaintSolid, ) from nanoemoji.png import PNG from picosvg.geometric_types import Point, Rect from picosvg.svg_meta import number_or_percentage from picosvg.svg_reuse import normalize, affine_between from picosvg.svg_transform import Affine2D from picosvg.svg import SVG, SVGTraverseContext from picosvg.svg_types import ( SVGPath, SVGLinearGradient, SVGRadialGradient, intersection, ) from typing import Generator, NamedTuple, Optional, Sequence, Tuple import ufoLib2 from ufoLib2.objects.glyph import Glyph as UfoGlyph import pathops def scale_viewbox_to_font_metrics( view_box: Rect, ascender: int, descender: int, width: int ): assert descender <= 0 # scale height to (ascender - descender) scale = (ascender - descender) / view_box.h # shift so width is centered dx = (width - scale * view_box.w) / 2 return Affine2D.compose_ltr( ( # first normalize viewbox origin Affine2D(1, 0, 0, 1, -view_box.x, -view_box.y), Affine2D(scale, 0, 0, scale, dx, 0), ) ) def map_viewbox_to_font_space( view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D ) -> Affine2D: return Affine2D.compose_ltr( [ scale_viewbox_to_font_metrics(view_box, ascender, descender, width), # flip y axis and shift so things are in the right place Affine2D(1, 0, 0, -1, 0, ascender), user_transform, ] ) # https://docs.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics def map_viewbox_to_otsvg_space( view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D ) -> Affine2D: return Affine2D.compose_ltr( [ scale_viewbox_to_font_metrics(view_box, ascender, descender, width), # shift things in the [+x,-y] quadrant where OT-SVG expects them Affine2D(1, 0, 0, 1, 0, -ascender), user_transform, ] ) def _get_gradient_transform( config: FontConfig, grad_el: etree.Element, shape_bbox: Rect, view_box: Rect, glyph_width: int, ) -> Affine2D: transform = map_viewbox_to_font_space( view_box, config.ascender, config.descender, glyph_width, config.transform ) gradient_units = grad_el.attrib.get("gradientUnits", "objectBoundingBox") if gradient_units == "objectBoundingBox": bbox_space = Rect(0, 0, 1, 1) bbox_transform = Affine2D.rect_to_rect(bbox_space, shape_bbox) transform = Affine2D.compose_ltr((bbox_transform, transform)) if "gradientTransform" in grad_el.attrib: gradient_transform = Affine2D.fromstring(grad_el.attrib["gradientTransform"]) transform = Affine2D.compose_ltr((gradient_transform, transform)) return transform def _parse_linear_gradient( config: FontConfig, grad_el: etree.Element, shape_bbox: Rect, view_box: Rect, glyph_width: int, shape_opacity: float = 1.0, ): gradient = SVGLinearGradient.from_element(grad_el, view_box) p0 = Point(gradient.x1, gradient.y1) p1 = Point(gradient.x2, gradient.y2) # Set P2 to P1 rotated 90 degrees counter-clockwise around P0 p2 = p0 + (p1 - p0).perpendicular() common_args = _common_gradient_parts(grad_el, shape_opacity) transform = _get_gradient_transform( config, grad_el, shape_bbox, view_box, glyph_width ) return PaintLinearGradient( # pytype: disable=wrong-arg-types p0=p0, p1=p1, p2=p2, **common_args ).apply_transform(transform) def _parse_radial_gradient( config: FontConfig, grad_el: etree.Element, shape_bbox: Rect, view_box: Rect, glyph_width: int, shape_opacity: float = 1.0, ): gradient = SVGRadialGradient.from_element(grad_el, view_box) c0 = Point(gradient.fx, gradient.fy) r0 = gradient.fr c1 = Point(gradient.cx, gradient.cy) r1 = gradient.r gradient_args = {"c0": c0, "c1": c1, "r0": r0, "r1": r1} gradient_args.update(_common_gradient_parts(grad_el, shape_opacity)) transform = _get_gradient_transform( config, grad_el, shape_bbox, view_box, glyph_width ) return PaintRadialGradient( # pytype: disable=wrong-arg-types **gradient_args ).apply_transform(transform) _GRADIENT_INFO = { "linearGradient": _parse_linear_gradient, "radialGradient": _parse_radial_gradient, } def _color_stop(stop_el, shape_opacity=1.0) -> ColorStop: offset = number_or_percentage(stop_el.attrib.get("offset", "0")) color = Color.fromstring(stop_el.attrib.get("stop-color", "black")) opacity = number_or_percentage(stop_el.attrib.get("stop-opacity", "1")) color = color._replace(alpha=color.alpha * opacity * shape_opacity) return ColorStop(stopOffset=offset, color=color) def _common_gradient_parts(el, shape_opacity=1.0): spread_method = el.attrib.get("spreadMethod", "pad").upper() if spread_method not in Extend.__members__: raise ValueError(f"Unknown spreadMethod {spread_method}") return { "extend": Extend.__members__[spread_method], "stops": tuple(_color_stop(stop, shape_opacity) for stop in el), } def _paint( debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath, glyph_width: int ) -> Paint: if shape.fill.startswith("url("): el = picosvg.resolve_url(shape.fill, "*") try: return _GRADIENT_INFO[etree.QName(el).localname]( config, el, shape.bounding_box(), picosvg.view_box(), glyph_width, shape.opacity, ) except ValueError as e: raise ValueError( f"parse failed for {debug_hint}, {etree.tostring(el)[:128]}" ) from e return PaintSolid(color=Color.fromstring(shape.fill, alpha=shape.opacity)) def _paint_glyph( debug_hint: str, config: FontConfig, picosvg: SVG, context: SVGTraverseContext, glyph_width: int, ) -> Paint: shape = context.shape() if shape.fill.startswith("url("): fill_el = picosvg.resolve_url(shape.fill, "*") try: glyph_paint = _GRADIENT_INFO[etree.QName(fill_el).localname]( config, fill_el, shape.bounding_box(), picosvg.view_box(), glyph_width, shape.opacity, ) except ValueError as e: raise ValueError( f"parse failed for {debug_hint}, {etree.tostring(fill_el)[:128]}" ) from e else: glyph_paint = PaintSolid( color=Color.fromstring(shape.fill, alpha=shape.opacity) ) return PaintGlyph(glyph=shape.as_path().d, paint=glyph_paint) def _intersect(path1: SVGPath, path2: SVGPath) -> bool: # Try computing intersection using pathops; if for whatever reason it fails # (probably some bug) then be on the safe side and assume we do have one... try: return bool(intersection((path1, path2))) except pathops.PathOpsError: logging.error( "pathops failed to compute intersection:\n- %s\n- %s", path1.d, path2.d ) return True def _any_overlap_with_reversing_transform( debug_hint: str, paths: Sequence[SVGPath], transforms: Sequence[Affine2D] ) -> bool: reverses_direction = [t.determinant() < 0 for t in transforms] for i, j in combinations(range(len(paths)), 2): if (reverses_direction[i] or reverses_direction[j]) and _intersect( paths[i], paths[j] ): logging.info( "%s contains reusable paths that overlap and have a reversing " "transform; decomposed to avoid winding issues:\n" "- %s\n transform: %s\n" "- %s\n transform: %s", debug_hint, paths[i].d, transforms[i], paths[j].d, transforms[j], ) return True return False def _painted_layers( debug_hint: str, config: FontConfig, picosvg: SVG, glyph_width: int, ) -> Tuple[Paint, ...]: defs_seen = False layers = [] # Reverse to get leaves first because that makes building Paint's easier # shapes *must* be leaves per picosvg for context in reversed(tuple(picosvg.depth_first())): if context.depth() == 0: continue # svg root # picosvg will deliver us exactly one defs if context.path == "/svg[0]/defs[0]": assert not defs_seen defs_seen = True continue # defs are pulled in by the consuming paints if context.is_shape(): while len(layers) < context.depth(): layers.append([]) assert len(layers) == context.depth() layers[context.depth() - 1].append( _paint_glyph(debug_hint, config, picosvg, context, glyph_width) ) if context.is_group(): # flush child shapes into a new group opacity = float(context.element.get("opacity", 1.0)) assert ( 0.0 < opacity < 1.0 ), f"{debug_hint} {context.path} should be transparent" assert ( len(layers) == context.depth() + 1 ), "Should have a list of child nodes" child_nodes = layers.pop(context.depth()) assert ( len(child_nodes) > 1 ), f"{debug_hint} {context.path} should have 2+ children" assert {"opacity"} == set( context.element.attrib.keys() ), f"{debug_hint} {context.path} only attribute should be opacity. Found {context.element.attrib.keys()}" # insert reversed to undo the reversed at the top of loop paint = PaintComposite( mode=CompositeMode.SRC_IN, source=PaintColrLayers(tuple(reversed(child_nodes))), backdrop=PaintSolid(Color(0, 0, 0, opacity)), ) layers[context.depth() - 1].append(paint) assert defs_seen, f"{debug_hint} we never saw defs, what's up with that?!" if not layers: return () assert len(layers) == 1, f"Unexpected layers: {[len(l) for l in layers]}" # undo the reversed at the top of loop layers = reversed(layers[0]) return tuple(layers) def _advance_width(view_box: Rect, config: FontConfig) -> int: # Scale advance width proportionally to viewbox aspect ratio. # Use the default advance width if it's larger than the proportional one. font_height = config.ascender - config.descender # descender <= 0 return max(config.width, round(font_height * view_box.w / view_box.h)) def _mutating_traverse(paint, mutator): paint = mutator(paint) assert paint is not None, "Return the input for no change, not None" try: fields = dataclasses.fields(paint) except TypeError as e: raise ValueError(f"{paint} is not a dataclass?") from e changes = {} for field in fields: try: is_paint = issubclass(field.type, Paint) except TypeError: # typing.Tuple and friends helpfully fail issubclass is_paint = False if is_paint: current = getattr(paint, field.name) modified = _mutating_traverse(current, mutator) if current is not modified: changes[field.name] = modified # PaintColrLayers, uniquely, has a tuple of paint if isinstance(paint, PaintColrLayers): new_layers = list(paint.layers) for i, current in enumerate(paint.layers): modified = _mutating_traverse(current, mutator) if current is not modified: new_layers[i] = modified new_layers = tuple(new_layers) if new_layers != paint.layers: changes["layers"] = tuple(new_layers) if changes: paint = dataclasses.replace(paint, **changes) return paint class ColorGlyph(NamedTuple): ufo: ufoLib2.Font svg_filename: str # empty string means no svg or bitmap filenames bitmap_filename: str ufo_glyph_name: ( str # only if config has keep_glyph_names will this match in font binary ) glyph_id: int codepoints: Tuple[int, ...] painted_layers: Optional[Tuple[Paint, ...]] # None for untouched and bitmap formats svg: Optional[SVG] # None for bitmap formats user_transform: Affine2D bitmap: Optional[PNG] # None for vector formats @staticmethod def create( font_config: FontConfig, ufo: ufoLib2.Font, svg_filename: str, glyph_id: int, ufo_glyph_name: str, codepoints: Tuple[int, ...], svg: Optional[SVG], bitmap_filename: str = "", bitmap: Optional[PNG] = None, ) -> "ColorGlyph": logging.debug( " ColorGlyph for %s (%s)", svg_filename or bitmap_filename, codepoints ) if ufo_glyph_name not in ufo: base_glyph = ufo.newGlyph(ufo_glyph_name) else: base_glyph = ufo[ufo_glyph_name] # non-square aspect ratio == proportional width; square == monospace view_box = None if svg: view_box = svg.view_box() elif bitmap: view_box = Rect(0, 0, *bitmap.size) if view_box is not None: base_glyph.width = _advance_width(view_box, font_config) else: base_glyph.width = font_config.width # Setup direct access to the glyph if possible if len(codepoints) == 1: base_glyph.unicode = next(iter(codepoints)) # Grab the transform + (color, glyph) layers unless they aren't to be touched # or cannot possibly paint painted_layers = () if not font_config.transform.is_degenerate(): if font_config.has_picosvgs: painted_layers = tuple( _painted_layers(svg_filename, font_config, svg, base_glyph.width) ) return ColorGlyph( ufo, svg_filename, bitmap_filename, ufo_glyph_name, glyph_id, codepoints, painted_layers, svg, font_config.transform, bitmap, ) def _has_viewbox_for_transform(self) -> bool: view_box = None if self.svg: view_box = self.svg.view_box() if view_box is None: logging.warning( f"{self.ufo.info.familyName} has no viewBox; no transform will be applied" ) return view_box is not None def _transform(self, map_fn): if not self._has_viewbox_for_transform(): return Affine2D.identity() return map_fn( self.svg.view_box(), self.ufo.info.ascender, self.ufo.info.descender, self.ufo_glyph.width, self.user_transform, ) def transform_for_otsvg_space(self): return self._transform(map_viewbox_to_otsvg_space) def transform_for_font_space(self): return self._transform(map_viewbox_to_font_space) @property def ufo_glyph(self) -> UfoGlyph: return self.ufo[self.ufo_glyph_name] def colors(self): """Set of Color used by this glyph.""" all_colors = set() self.traverse(lambda paint: all_colors.update(paint.colors())) return all_colors def traverse(self, visitor): def _traverse_callback(paint): visitor(paint) return paint for p in self.painted_layers: _mutating_traverse(p, _traverse_callback) def mutating_traverse(self, mutator) -> "ColorGlyph": return self._replace( painted_layers=tuple( _mutating_traverse(p, mutator) for p in self.painted_layers ) ) nanoemoji-0.16.0/src/nanoemoji/colors.py000066400000000000000000000344121523660054300202060ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import dataclasses import re from collections import deque from typing import ClassVar, Iterable, List, Optional, Sequence, Tuple # See https://www.w3.org/TR/css-color-4/#named-colors # Chrome DevTools: # copy($$('.named-color-table tbody tr').map((tr) => { name = tr.children[2].innerText; hex = tr.children[3].innerText; return " '" + name + "': (0x" + hex.substring(1, 3) + ", 0x" + hex.substring(3,5) + ", 0x" + hex.substring(5,7) + ")," }).join('\n')) _CSS_COLORS = { "aliceblue": (0xF0, 0xF8, 0xFF), "antiquewhite": (0xFA, 0xEB, 0xD7), "aqua": (0x00, 0xFF, 0xFF), "aquamarine": (0x7F, 0xFF, 0xD4), "azure": (0xF0, 0xFF, 0xFF), "beige": (0xF5, 0xF5, 0xDC), "bisque": (0xFF, 0xE4, 0xC4), "black": (0x00, 0x00, 0x00), "blanchedalmond": (0xFF, 0xEB, 0xCD), "blue": (0x00, 0x00, 0xFF), "blueviolet": (0x8A, 0x2B, 0xE2), "brown": (0xA5, 0x2A, 0x2A), "burlywood": (0xDE, 0xB8, 0x87), "cadetblue": (0x5F, 0x9E, 0xA0), "chartreuse": (0x7F, 0xFF, 0x00), "chocolate": (0xD2, 0x69, 0x1E), "coral": (0xFF, 0x7F, 0x50), "cornflowerblue": (0x64, 0x95, 0xED), "cornsilk": (0xFF, 0xF8, 0xDC), "crimson": (0xDC, 0x14, 0x3C), "cyan": (0x00, 0xFF, 0xFF), "darkblue": (0x00, 0x00, 0x8B), "darkcyan": (0x00, 0x8B, 0x8B), "darkgoldenrod": (0xB8, 0x86, 0x0B), "darkgray": (0xA9, 0xA9, 0xA9), "darkgreen": (0x00, 0x64, 0x00), "darkgrey": (0xA9, 0xA9, 0xA9), "darkkhaki": (0xBD, 0xB7, 0x6B), "darkmagenta": (0x8B, 0x00, 0x8B), "darkolivegreen": (0x55, 0x6B, 0x2F), "darkorange": (0xFF, 0x8C, 0x00), "darkorchid": (0x99, 0x32, 0xCC), "darkred": (0x8B, 0x00, 0x00), "darksalmon": (0xE9, 0x96, 0x7A), "darkseagreen": (0x8F, 0xBC, 0x8F), "darkslateblue": (0x48, 0x3D, 0x8B), "darkslategray": (0x2F, 0x4F, 0x4F), "darkslategrey": (0x2F, 0x4F, 0x4F), "darkturquoise": (0x00, 0xCE, 0xD1), "darkviolet": (0x94, 0x00, 0xD3), "deeppink": (0xFF, 0x14, 0x93), "deepskyblue": (0x00, 0xBF, 0xFF), "dimgray": (0x69, 0x69, 0x69), "dimgrey": (0x69, 0x69, 0x69), "dodgerblue": (0x1E, 0x90, 0xFF), "firebrick": (0xB2, 0x22, 0x22), "floralwhite": (0xFF, 0xFA, 0xF0), "forestgreen": (0x22, 0x8B, 0x22), "fuchsia": (0xFF, 0x00, 0xFF), "gainsboro": (0xDC, 0xDC, 0xDC), "ghostwhite": (0xF8, 0xF8, 0xFF), "gold": (0xFF, 0xD7, 0x00), "goldenrod": (0xDA, 0xA5, 0x20), "gray": (0x80, 0x80, 0x80), "green": (0x00, 0x80, 0x00), "greenyellow": (0xAD, 0xFF, 0x2F), "grey": (0x80, 0x80, 0x80), "honeydew": (0xF0, 0xFF, 0xF0), "hotpink": (0xFF, 0x69, 0xB4), "indianred": (0xCD, 0x5C, 0x5C), "indigo": (0x4B, 0x00, 0x82), "ivory": (0xFF, 0xFF, 0xF0), "khaki": (0xF0, 0xE6, 0x8C), "lavender": (0xE6, 0xE6, 0xFA), "lavenderblush": (0xFF, 0xF0, 0xF5), "lawngreen": (0x7C, 0xFC, 0x00), "lemonchiffon": (0xFF, 0xFA, 0xCD), "lightblue": (0xAD, 0xD8, 0xE6), "lightcoral": (0xF0, 0x80, 0x80), "lightcyan": (0xE0, 0xFF, 0xFF), "lightgoldenrodyellow": (0xFA, 0xFA, 0xD2), "lightgray": (0xD3, 0xD3, 0xD3), "lightgreen": (0x90, 0xEE, 0x90), "lightgrey": (0xD3, 0xD3, 0xD3), "lightpink": (0xFF, 0xB6, 0xC1), "lightsalmon": (0xFF, 0xA0, 0x7A), "lightseagreen": (0x20, 0xB2, 0xAA), "lightskyblue": (0x87, 0xCE, 0xFA), "lightslategray": (0x77, 0x88, 0x99), "lightslategrey": (0x77, 0x88, 0x99), "lightsteelblue": (0xB0, 0xC4, 0xDE), "lightyellow": (0xFF, 0xFF, 0xE0), "lime": (0x00, 0xFF, 0x00), "limegreen": (0x32, 0xCD, 0x32), "linen": (0xFA, 0xF0, 0xE6), "magenta": (0xFF, 0x00, 0xFF), "maroon": (0x80, 0x00, 0x00), "mediumaquamarine": (0x66, 0xCD, 0xAA), "mediumblue": (0x00, 0x00, 0xCD), "mediumorchid": (0xBA, 0x55, 0xD3), "mediumpurple": (0x93, 0x70, 0xDB), "mediumseagreen": (0x3C, 0xB3, 0x71), "mediumslateblue": (0x7B, 0x68, 0xEE), "mediumspringgreen": (0x00, 0xFA, 0x9A), "mediumturquoise": (0x48, 0xD1, 0xCC), "mediumvioletred": (0xC7, 0x15, 0x85), "midnightblue": (0x19, 0x19, 0x70), "mintcream": (0xF5, 0xFF, 0xFA), "mistyrose": (0xFF, 0xE4, 0xE1), "moccasin": (0xFF, 0xE4, 0xB5), "navajowhite": (0xFF, 0xDE, 0xAD), "navy": (0x00, 0x00, 0x80), "oldlace": (0xFD, 0xF5, 0xE6), "olive": (0x80, 0x80, 0x00), "olivedrab": (0x6B, 0x8E, 0x23), "orange": (0xFF, 0xA5, 0x00), "orangered": (0xFF, 0x45, 0x00), "orchid": (0xDA, 0x70, 0xD6), "palegoldenrod": (0xEE, 0xE8, 0xAA), "palegreen": (0x98, 0xFB, 0x98), "paleturquoise": (0xAF, 0xEE, 0xEE), "palevioletred": (0xDB, 0x70, 0x93), "papayawhip": (0xFF, 0xEF, 0xD5), "peachpuff": (0xFF, 0xDA, 0xB9), "peru": (0xCD, 0x85, 0x3F), "pink": (0xFF, 0xC0, 0xCB), "plum": (0xDD, 0xA0, 0xDD), "powderblue": (0xB0, 0xE0, 0xE6), "purple": (0x80, 0x00, 0x80), "rebeccapurple": (0x66, 0x33, 0x99), "red": (0xFF, 0x00, 0x00), "rosybrown": (0xBC, 0x8F, 0x8F), "royalblue": (0x41, 0x69, 0xE1), "saddlebrown": (0x8B, 0x45, 0x13), "salmon": (0xFA, 0x80, 0x72), "sandybrown": (0xF4, 0xA4, 0x60), "seagreen": (0x2E, 0x8B, 0x57), "seashell": (0xFF, 0xF5, 0xEE), "sienna": (0xA0, 0x52, 0x2D), "silver": (0xC0, 0xC0, 0xC0), "skyblue": (0x87, 0xCE, 0xEB), "slateblue": (0x6A, 0x5A, 0xCD), "slategray": (0x70, 0x80, 0x90), "slategrey": (0x70, 0x80, 0x90), "snow": (0xFF, 0xFA, 0xFA), "springgreen": (0x00, 0xFF, 0x7F), "steelblue": (0x46, 0x82, 0xB4), "tan": (0xD2, 0xB4, 0x8C), "teal": (0x00, 0x80, 0x80), "thistle": (0xD8, 0xBF, 0xD8), "tomato": (0xFF, 0x63, 0x47), "turquoise": (0x40, 0xE0, 0xD0), "violet": (0xEE, 0x82, 0xEE), "wheat": (0xF5, 0xDE, 0xB3), "white": (0xFF, 0xFF, 0xFF), "whitesmoke": (0xF5, 0xF5, 0xF5), "yellow": (0xFF, 0xFF, 0x00), "yellowgreen": (0x9A, 0xCD, 0x32), } def css_colors(): return _CSS_COLORS def css_color(name) -> Optional[Tuple[int, int, int]]: return _CSS_COLORS.get(name, None) def color_name(rgb) -> Optional[str]: for name, value in _CSS_COLORS.items(): if value == rgb: return name return None def _parse_rgb_channel(val: str) -> int: if val.endswith("%"): v = float(val[:-1]) * 255.0 / 100.0 else: v = float(val) return max(0, min(255, round(v))) def _parse_alpha_channel(val: str) -> float: if val.endswith("%"): v = float(val[:-1]) / 100.0 else: v = float(val) return max(0.0, min(1.0, v)) @dataclasses.dataclass(frozen=True, order=True) class Color(Sequence): red: int green: int blue: int alpha: float = 1.0 palette_index: Optional[int] = None # the default color is optional but for now we require one for simplicity _COLOR_VARIABLE_RE: ClassVar[re.Pattern] = re.compile( r"var\s*\(\s*--color([0-9]+)\s*,\s*(#?\w+)\s*\)" ) _NUM_OR_PCT: ClassVar[str] = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?%?" _COLOR_RGB_RE: ClassVar[re.Pattern] = re.compile( rf"^rgba?\(\s*" rf"({_NUM_OR_PCT})" rf"(?:" rf"(?:\s*,\s*({_NUM_OR_PCT})\s*,\s*({_NUM_OR_PCT})(?:\s*,\s*({_NUM_OR_PCT}))?)" rf"|" rf"(?:\s+({_NUM_OR_PCT})\s+({_NUM_OR_PCT})(?:\s*(?:\/|\s)\s*({_NUM_OR_PCT}))?)" rf")" rf"\s*\)$", re.IGNORECASE, ) def __getitem__(self, i): fields = dataclasses.fields(self) if isinstance(i, slice): return tuple(getattr(self, f.name) for f in fields[i]) return getattr(self, fields[i].name) def __len__(self): return len(dataclasses.fields(self)) def _replace(self, **kwargs): return dataclasses.replace(self, **kwargs) @classmethod def fromstring(cls, s: str, alpha: float = 1.0) -> "Color": s = s.strip() # CSS variables referencing palette entries as custom properties: # https://docs.microsoft.com/en-us/typography/opentype/spec/svg#color-palettes m = cls._COLOR_VARIABLE_RE.match(s) if m: palette_index = int(m.group(1)) default_color = m.group(2) tmp = cls.fromstring(default_color, alpha=alpha) return tmp._replace(palette_index=palette_index) if s == "currentColor": # For the 'currentColor' special keyword, we return a sentinel value (with # negative invalid R G B values) that we'll convert to the 0xFFFF foreground # CPAL color palette index. # https://docs.microsoft.com/en-us/typography/opentype/spec/SVG#colors return cls.current_color(alpha=alpha) if s.startswith("#"): # https://www.w3.org/TR/css-color-4/#hex-notation ss = s[1:] if len(ss) in (3, 4): ss = "".join((s + s for s in ss)) if len(ss) in (6, 8): red = int(ss[0:2], 16) green = int(ss[2:4], 16) blue = int(ss[4:6], 16) if len(ss) == 8: alpha = int(ss[6:8], 16) / 255 else: raise ValueError(f"expected 3, 4, 6, or 8 hex digits, found {s!r}") elif s in _CSS_COLORS: red, green, blue = _CSS_COLORS[s] else: m = cls._COLOR_RGB_RE.match(s) if m: r_str = m.group(1) g_str = m.group(2) or m.group(5) b_str = m.group(3) or m.group(6) a_str = m.group(4) or m.group(7) red = _parse_rgb_channel(r_str) green = _parse_rgb_channel(g_str) blue = _parse_rgb_channel(b_str) if a_str is not None: alpha = _parse_alpha_channel(a_str) else: raise ValueError(f"invalid or unsupported color string: {s!r}") return cls(red, green, blue, alpha) def to_ufo_color(self) -> Tuple[float, float, float, float]: # UFO stores colors as RGBA tuples of decimal [0..1] floats. Here we round to 3 # decimal digits, which are sufficient to round-trip Fixed0.8 numbers return ( float(f"{self.red / 255:.03f}"), float(f"{self.green / 255:.03f}"), float(f"{self.blue / 255:.03f}"), self.alpha, ) def opaque(self) -> "Color": return self._replace(alpha=1.0) def to_string(self) -> str: # A CSS or SVG friendly string if self.palette_index is not None: return f"var(--color{self.palette_index}, {self.without_palette_index().to_string()})" if self.is_current_color(): if self.alpha != 1.0: raise ValueError("'currentColor' can't encode alpha != 1.0") return "currentColor" rgb = self[0:3] string = None if self.alpha == 1.0: string = color_name(rgb) if not string: string = "#" + "".join(f"{v:02X}" for v in rgb) if self.alpha != 1.0: string += f"{int(self.alpha * 255):02X}" return string @classmethod def current_color(cls, alpha=alpha) -> "Color": # sentinel value for "currentColor" (text foreground color) return cls(-1, -1, -1, alpha=alpha) def is_current_color(self): return self[:3] == self.current_color()[:3] def without_palette_index(self) -> "Color": if self.palette_index is None: return self return self._replace(palette_index=None) def index_from(self, palette: Sequence["Color"]) -> int: if self.is_current_color(): return 0xFFFF return palette.index(self) def uniq_sort_cpal_colors(colors: Iterable[Color]) -> List[Color]: """Return list of unique colors sorted by CPAL palette entry index. Keep colors with explicit index in the original position, and place the unindexed colors in the empty slots or after the indexed ones, sorted by > RGBA value. """ black = Color.fromstring("black") all_colors = set(colors) # Chrome 98 doesn't like when COLRv1 font has empty CPAL palette so make sure we # have at least one # TODO(anthrotype): File a bug and remove hack once the bug is fixed upstream if not all_colors: all_colors = {black} # Check that color palette entry indices unambiguously map to only one color indexed_colors = {} for color in all_colors: if color.palette_index is not None: if color.palette_index in indexed_colors: raise ValueError( f"Palette entry {color.palette_index} already maps to " f"{indexed_colors[color.palette_index]}; can't also map to {color}" ) indexed_colors[color.palette_index] = color # We need enough slots for all the colors, or to reach the max index, whichever is greater cpal_slots = max(len(all_colors), max(indexed_colors, default=-1) + 1) # cpal_slots is > the highest index so it will push all unindexed items right # this can be written as a ternary but it's pretty illegible that way def _color_sort_key(c: Color): if c.palette_index is not None: return (c.palette_index,) # negate value of colors so when we popright we get them in ascending order return (cpal_slots,) + tuple(-v for v in c[:4]) # Push colors into CPAL, either at their index or at the next open slot cpal_colors = deque(sorted(all_colors, key=_color_sort_key)) result = [black] * cpal_slots for i in range(cpal_slots): if i == cpal_colors[0].palette_index: result[i] = cpal_colors.popleft() elif cpal_colors[-1].palette_index is None: result[i] = cpal_colors.pop() # We have more gaps in indices than unindexed items; leave it black assert not cpal_colors, f"Should be empty: {cpal_colors}" return result nanoemoji-0.16.0/src/nanoemoji/colr.py000066400000000000000000000021361523660054300176420ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helpers for dealing with COLR.""" from fontTools.ttLib.tables import otTables as ot from fontTools import ttLib from typing import Iterable def paints_of_type( font: ttLib.TTFont, paint_format: ot.PaintFormat ) -> Iterable[ot.Paint]: result = [] def _callback(paint): if paint.Format == paint_format: result.append(paint) colr_table = font["COLR"].table for record in colr_table.BaseGlyphList.BaseGlyphPaintRecord: record.Paint.traverse(colr_table, _callback) return tuple(result) nanoemoji-0.16.0/src/nanoemoji/colr_to_svg.py000066400000000000000000000341271523660054300212300ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from absl import logging from nanoemoji import colors from nanoemoji import color_glyph from nanoemoji.glyph_reuse import GlyphReuseCache from nanoemoji.paint import ( ColorStop, CompositeMode, Extend, Paint, PaintSolid, PaintLinearGradient, PaintRadialGradient, PaintGlyph, PaintColrGlyph, PaintComposite, PaintColrLayers, is_transform, _decompose_uniform_transform, ) from nanoemoji.svg import ( _svg_matrix, _apply_solid_paint, _apply_gradient_paint, _map_gradient_coordinates, _GradientPaint, ReuseCache, ) from nanoemoji.svg_path import SVGPathPen from nanoemoji.util import only from picosvg.svg import SVG from picosvg.svg_meta import ntos from picosvg.svg_transform import Affine2D from fontTools import ttLib from fontTools.ttLib.tables import C_P_A_L_ from picosvg.geometric_types import Point, Rect from lxml import etree from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple from fontTools.pens import transformPen from fontTools.ttLib.tables import otTables _FOREGROUND_COLOR_INDEX = 0xFFFF _GRADIENT_PAINT_FORMATS = (PaintLinearGradient.format, PaintRadialGradient.format) _COLR_TO_SVG_TEMPLATE = r'' ViewboxCallback = Callable[[str], Rect] # f(glyph_name) -> Rect def map_font_space_to_viewbox(view_box: Rect, glyph_region: Rect) -> Affine2D: # SVG, as some of us are very fond of forgetting, has +y going down assert glyph_region.y <= 0 ascender = -glyph_region.y descender = -(glyph_region.h - ascender) assert descender <= 0 width = glyph_region.w return color_glyph.map_viewbox_to_font_space( view_box, ascender, descender, width, Affine2D.identity() ).inverse() def _svg_root(view_box: Rect) -> etree.Element: svg_root = etree.fromstring(_COLR_TO_SVG_TEMPLATE) vbox = (view_box.x, view_box.y, view_box.w, view_box.h) svg_root.attrib["viewBox"] = " ".join(ntos(v) for v in vbox) return svg_root def _draw_svg_path( svg_path: etree.Element, glyph_set: ttLib.ttGlyphSet._TTGlyphSet, glyph_name: str, font_to_vbox: Affine2D, ): # use glyph set to resolve references in composite glyphs svg_pen = SVGPathPen(glyph_set) # wrap svg pen with "filter" pen mapping coordinates from UPEM to SVG space transform_pen = transformPen.TransformPen(svg_pen, font_to_vbox) glyph = glyph_set[glyph_name] glyph.draw(transform_pen) svg_path.attrib["d"] = svg_pen.path.d def _default_cpal_palette(ttfont: ttLib.TTFont) -> List[C_P_A_L_.Color]: # Return the first palette as the default try: return ttfont["CPAL"].palettes[0] except KeyError: raise ValueError("No CPAL table found in font") except IndexError: raise ValueError("At least one CPAL palette is required, but none was found") def _color(ttfont: ttLib.TTFont, palette_index, alpha=1.0) -> colors.Color: if palette_index == _FOREGROUND_COLOR_INDEX: return colors.Color.fromstring("currentColor", alpha=alpha) palette = _default_cpal_palette(ttfont) if palette_index >= len(palette): raise IndexError(f"{palette_index} illegal in palette of {len(palette)}") cpal_color = palette[palette_index] return colors.Color( red=cpal_color.red, green=cpal_color.green, blue=cpal_color.blue, alpha=alpha * cpal_color.alpha / 255, palette_index=palette_index if len(ttfont["CPAL"].palettes) > 1 else None, ) def _gradient_paint(ttfont: ttLib.TTFont, ot_paint: otTables.Paint) -> _GradientPaint: stops = tuple( ColorStop( stop.StopOffset, _color(ttfont, stop.PaletteIndex, stop.Alpha), ) for stop in ot_paint.ColorLine.ColorStop ) extend = Extend((ot_paint.ColorLine.Extend,)) if ot_paint.Format == PaintLinearGradient.format: return PaintLinearGradient( stops=stops, extend=extend, p0=Point(ot_paint.x0, ot_paint.y0), p1=Point(ot_paint.x1, ot_paint.y1), p2=Point(ot_paint.x2, ot_paint.y2), ) elif ot_paint.Format == PaintRadialGradient.format: return PaintRadialGradient( stops=stops, extend=extend, c0=Point(ot_paint.x0, ot_paint.y0), c1=Point(ot_paint.x1, ot_paint.y1), r0=ot_paint.r0, r1=ot_paint.r1, ) else: raise ValueError( f"Expected one of Paint formats {_GRADIENT_PAINT_FORMATS}; " f"found {ot_paint.Format}" ) def _apply_solid_ot_paint( svg_path: etree.Element, ttfont: ttLib.TTFont, ot_paint: otTables.Paint, ): color = _color(ttfont, ot_paint.PaletteIndex, ot_paint.Alpha) _apply_solid_paint(svg_path, PaintSolid(color)) def _apply_gradient_ot_paint( svg_defs: etree.Element, svg_path: etree.Element, ttfont: ttLib.TTFont, font_to_vbox: Affine2D, ot_paint: otTables.Paint, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): paint = _gradient_paint(ttfont, ot_paint) # For radial gradients we want to keep cirlces as such, so we must decompose into # a uniform scale+translate plus a remainder to encode as gradientTransform. # Whereas for linear gradients, we can simply apply the whole combined transform to # start/end points and omit gradientTransform attribute. coord_transform = Affine2D.compose_ltr((transform, font_to_vbox)) remaining_transform = Affine2D.identity() if paint.format == PaintRadialGradient.format: coord_transform, remaining_transform = _decompose_uniform_transform( coord_transform ) paint = _map_gradient_coordinates(paint, coord_transform) _apply_gradient_paint( svg_defs, svg_path, paint, reuse_cache, transform=remaining_transform ) def _colr_v0_glyph_to_svg( ttfont: ttLib.TTFont, glyph_set: ttLib.ttGlyphSet._TTGlyphSet, view_box_callback: ViewboxCallback, glyph_name: str, ) -> etree.Element: view_box, font_to_vbox = _view_box_and_transform( ttfont, view_box_callback, glyph_name ) svg_root = _svg_root(view_box) for glyph_layer in ttfont["COLR"].ColorLayers[glyph_name]: svg_path = etree.SubElement(svg_root, "path") paint = PaintSolid(_color(ttfont, glyph_layer.colorID)) _apply_solid_paint(svg_path, paint) _draw_svg_path(svg_path, glyph_set, glyph_layer.name, font_to_vbox) return svg_root def _apply_transform( transform: Affine2D, font_to_vbox: Affine2D, el: etree.Element ) -> Affine2D: if transform == Affine2D.identity(): return Affine2D.identity() svg_transform = Affine2D.compose_ltr( (font_to_vbox.inverse(), transform, font_to_vbox) ) el.attrib["transform"] = _svg_matrix(svg_transform) # we must reset the current user space when setting the 'transform' # attribute on a , since that already affects the gradients used # and we don't want the transform to be applied twice to gradients: # https://github.com/googlefonts/nanoemoji/issues/334 return Affine2D.identity() def _colr_v1_paint_to_svg( ttfont: ttLib.TTFont, glyph_set: Mapping[str, Any], parent_el: etree.Element, svg_defs: etree.Element, font_to_vbox: Affine2D, ot_paint: otTables.Paint, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): def descend(parent: etree.Element, paint: otTables.Paint): _colr_v1_paint_to_svg( ttfont, glyph_set, parent, svg_defs, font_to_vbox, paint, reuse_cache, transform=transform, ) if ot_paint.Format == PaintSolid.format: _apply_solid_ot_paint(parent_el, ttfont, ot_paint) elif ot_paint.Format in _GRADIENT_PAINT_FORMATS: _apply_gradient_ot_paint( svg_defs, parent_el, ttfont, font_to_vbox, ot_paint, reuse_cache, transform ) elif ot_paint.Format == PaintGlyph.format: layer_glyph = ot_paint.Glyph svg_path = etree.SubElement(parent_el, "path") # Transform only occurs with reuse; we could wire up use. But for now ... not. transform = _apply_transform(transform, font_to_vbox, svg_path) descend(svg_path, ot_paint.Paint) _draw_svg_path(svg_path, glyph_set, layer_glyph, font_to_vbox) elif is_transform(ot_paint.Format): paint = Paint.from_ot(ot_paint) transform @= paint.gettransform() descend(parent_el, ot_paint.Paint) elif ot_paint.Format == PaintColrLayers.format: layerList = ttfont["COLR"].table.LayerList.Paint assert layerList, "Paint layers without a layer list :(" for child_paint in layerList[ ot_paint.FirstLayerIndex : ot_paint.FirstLayerIndex + ot_paint.NumLayers ]: descend(parent_el, child_paint) elif ot_paint.Format == PaintComposite.format: if ( ot_paint.CompositeMode == CompositeMode.SRC_IN and ot_paint.BackdropPaint.Format == PaintSolid.format ): # Special-case simple PaintComposite for group opacity color = _color( ttfont, ot_paint.BackdropPaint.PaletteIndex, ot_paint.BackdropPaint.Alpha, ) if color[:3] == (0, 0, 0): g = etree.SubElement(parent_el, "g") g.attrib["opacity"] = ntos(color.alpha) descend(g, ot_paint.SourcePaint) return # https://github.com/googlefonts/nanoemoji/issues/409 logging.warning( "PaintComposite => SVG not supported at the moment; " "only BackdropPaint is kept." ) descend(parent_el, ot_paint.BackdropPaint) elif ot_paint.Format == PaintColrGlyph.format: el = parent_el if transform != Affine2D.identity(): el = etree.SubElement(parent_el, "g") # Transform only occurs with reuse; we could wire up use. But for now ... not. transform = _apply_transform(transform, font_to_vbox, el) base_rec = only( r for r in ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord if r.BaseGlyph == ot_paint.Glyph ) descend(el, base_rec.Paint) else: raise NotImplementedError(ot_paint.Format) def glyph_region(ttfont: ttLib.TTFont, glyph_name: str) -> Rect: """The area occupied by the glyph, NOT factoring in that Y flips. map_font_space_to_viewbox handles font +y goes up => svg +y goes down.""" width = ttfont["hmtx"][glyph_name][0] return Rect( 0, -ttfont["OS/2"].sTypoAscender, width, ttfont["OS/2"].sTypoAscender - ttfont["OS/2"].sTypoDescender, ) def _view_box_and_transform( ttfont: ttLib.TTFont, view_box_callback: ViewboxCallback, glyph_name: str ) -> Tuple[Rect, Affine2D]: view_box = view_box_callback(glyph_name) region = glyph_region(ttfont, glyph_name) font_to_vbox = map_font_space_to_viewbox(view_box, region) return (view_box, font_to_vbox) def _colr_v1_glyph_to_svg( ttfont: ttLib.TTFont, glyph_set: ttLib.ttGlyphSet._TTGlyphSet, view_box_callback: ViewboxCallback, glyph: otTables.BaseGlyphRecord, ) -> etree.Element: view_box, font_to_vbox = _view_box_and_transform( ttfont, view_box_callback, glyph.BaseGlyph ) svg_root = _svg_root(view_box) svg_defs = svg_root[0] reuse_cache = _new_reuse_cache() glyph_set = ttfont.getGlyphSet() _colr_v1_paint_to_svg( ttfont, glyph_set, svg_root, svg_defs, font_to_vbox, glyph.Paint, reuse_cache ) return svg_root def _new_reuse_cache() -> ReuseCache: return ReuseCache(0.1, GlyphReuseCache(0.1)) def colr_glyphs(font: ttLib.TTFont) -> Iterable[int]: colr = font["COLR"] if colr.version == 0: for glyph_name in colr.ColorLayers: yield font.getGlyphID(glyph_name) else: assert colr.version == 1 assert not getattr(colr, "ColorLayers", ()), "TODO: mixed v0/v1 support" for base_glyph in font["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord: yield font.getGlyphID(base_glyph.BaseGlyph) def _colr_v0_to_svgs( view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont ) -> Dict[str, SVG]: glyph_set = ttfont.getGlyphSet() return { g: SVG.fromstring( etree.tostring( _colr_v0_glyph_to_svg(ttfont, glyph_set, view_box_callback, g) ) ) for g in ttfont["COLR"].ColorLayers } def _colr_v1_to_svgs( view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont ) -> Dict[str, SVG]: glyph_set = ttfont.getGlyphSet() return { g.BaseGlyph: SVG.fromstring( etree.tostring( _colr_v1_glyph_to_svg(ttfont, glyph_set, view_box_callback, g) ) ) for g in ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord } def colr_to_svg( view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont, rounding_ndigits: Optional[int] = None, ) -> Dict[str, SVG]: """For testing only, don't use for real!""" colr_version = ttfont["COLR"].version if colr_version == 0: svgs = _colr_v0_to_svgs(view_box_callback, ttfont) elif colr_version == 1: svgs = _colr_v1_to_svgs(view_box_callback, ttfont) else: raise NotImplementedError(colr_version) if rounding_ndigits is None: return svgs return {g: svg.round_floats(rounding_ndigits) for g, svg in svgs.items()} nanoemoji-0.16.0/src/nanoemoji/config.py000066400000000000000000000403271523660054300201540ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from absl import flags try: import importlib.resources as resources # pytype: disable=import-error except ImportError: import importlib_resources as resources # pytype: disable=import-error import itertools from pathlib import Path from picosvg.svg_transform import Affine2D import toml from typing import Any, Iterable, MutableMapping, NamedTuple, Optional, Tuple, Sequence from nanoemoji import util FLAGS = flags.FLAGS _DEFAULT_CONFIG_FILE = "_default.toml" # NOTE: this must be kept in sync with nanoemoji.write_font._COLOR_FORMAT_GENERATORS _COLOR_FORMATS = [ "glyf", "glyf_colr_0", "glyf_colr_1", "cff_colr_0", "cff_colr_1", "cff2_colr_0", "cff2_colr_1", "picosvg", "picosvgz", "untouchedsvg", "untouchedsvgz", "cbdt", "sbix", ] # we use None as a sentinel for flag not set; FontConfig class has the actual defaults. # CLI flags override config file (which overrides default FontConfig). flags.DEFINE_integer("upem", None, "Units per em.") flags.DEFINE_integer("width", None, "Width.") flags.DEFINE_integer("ascender", None, "Ascender") flags.DEFINE_integer("descender", None, "Descender.") flags.DEFINE_integer("linegap", None, "Line gap.") flags.DEFINE_string("transform", None, "User transform, in font coordinates.") flags.DEFINE_integer("version_major", None, "Major version.") flags.DEFINE_integer("version_minor", None, "Minor version.") flags.DEFINE_string("family", None, "Family name.") flags.DEFINE_string("output_file", None, "Output filename.") flags.DEFINE_enum( "color_format", None, sorted(_COLOR_FORMATS), "Type of font to generate.", ) flags.DEFINE_bool( "keep_glyph_names", None, "Whether or not to store glyph names in the font." ) flags.DEFINE_bool("clip_to_viewbox", None, "Whether to clip content outside viewbox.") flags.DEFINE_float( "reuse_tolerance", None, "Allowable absolute difference in reused shape in input coordinates (e.g. svg)." " Normalized shapes snap to whole multiples of tolerance;" " A negative value means that shape reuse is disabled.", ) # https://github.com/googlefonts/picosvg/issues/138 flags.DEFINE_bool( "ignore_reuse_error", None, "Whether to fail or continue with a warning when picosvg cannot compute " "affine between paths that normalize the same.", ) flags.DEFINE_integer( "clipbox_quantization", None, "Whether to quantize COLR clip boxes to multiples of positive integer, i.e. " "rounding {x,y}Min => -Inf (floor) and {x,y}Max => +Inf (ceiling). " "By default, it's 2% of UPEM (e.g. multiples of 20 units out of 1024).", lower_bound=1, ) flags.DEFINE_bool( "pretty_print", None, "Whether to prefer pretty printed content whenever possible (for testing).", ) flags.DEFINE_string("fea_file", None, "Feature file.") flags.DEFINE_string( "glyphmap_generator", None, "A program that takes a list of filenames and outputs a file csv whose rows contain filename, codepoint(s), glyph name.", ) flags.DEFINE_list( "bitmap_resolution", None, "Resolution of bitmap in pixels. Always square for now. Specify many times for multiple strikes.", ) flags.DEFINE_bool( "use_zopflipng", None, "Whether or not to compress PNGs using zopfli." ) flags.DEFINE_bool( "use_pngquant", None, "Whether or not to quantize PNGs using pngquant." ) flags.DEFINE_string( "pngquant_flags", None, "Additional options to pass on to pngquant." ) class Axis(NamedTuple): axisTag: str name: str default: float class AxisPosition(NamedTuple): axisTag: str position: float class MasterConfig(NamedTuple): name: str style_name: str output_ufo: str position: Tuple[AxisPosition, ...] sources: Tuple[Path, ...] def pos(self, axisTag: str) -> float: position = [ap.position for ap in self.position if ap.axisTag == axisTag] assert ( len(position) == 1 ), f"Unable to find 1 position for {axisTag}, got {position}" return position[0] class FontConfig(NamedTuple): family: str = "An Emoji Family" output_file: str = "AnEmojiFamily.ttf" color_format: str = "glyf_colr_1" # metrics default based on Noto Emoji upem: int = 1024 width: int = 1275 ascender: int = 950 descender: int = -250 linegap: int = 0 transform: Affine2D = Affine2D.identity() version_major: int = 1 version_minor: int = 0 reuse_tolerance: float = 0.1 ignore_reuse_error: bool = True keep_glyph_names: bool = False clip_to_viewbox: bool = True clipbox_quantization: Optional[int] = None fea_file: str = "features.fea" glyphmap_generator: str = "nanoemoji.write_glyphmap" # legacy field bitmap_resolution: int = 0 bitmap_resolutions: Tuple[int, ...] = (128,) use_zopflipng: bool = True use_pngquant: bool = True # we default to the same PNGQUANTFLAGS used in noto-emoji's Makefile: # https://github.com/googlefonts/noto-emoji/blob/9a5261d/Makefile#L24 pngquant_flags: str = "--speed 1 --skip-if-larger --quality 85-95" pretty_print: bool = False axes: Tuple[Axis, ...] = () masters: Tuple[MasterConfig, ...] = () source_names: Tuple[str, ...] = () def _has_any(self, *color_formats) -> bool: return bool(set(color_formats).intersection(self.color_format.split("_"))) @property def output_format(self): return Path(self.output_file).suffix @property def has_bitmaps(self) -> bool: return self._has_any("sbix", "cbdt") @property def has_picosvgs(self) -> bool: return self._has_any("glyf", "colr", "picosvg", "picosvgz") @property def has_untouchedsvgs(self) -> bool: return self._has_any("untouchedsvg", "untouchedsvgz") @property def has_svgs(self) -> bool: return self.has_picosvgs or self.has_untouchedsvgs @property def is_vf(self) -> bool: return len(self.masters) > 1 @property def is_ot_svg(self) -> bool: return self._has_any( "".join(p) for p in itertools.product(("picosvg", "untouchedsvg"), ("", "z")) ) def validate(self): for attr_name in ( "upem", "width", "ascender", "linegap", "version_major", "version_minor", ): value = getattr(self, attr_name) if value < 0: raise ValueError(f"'{attr_name}' must be zero or positive") if self.descender > 0: raise ValueError("'descender' must be zero or negative") if self.clipbox_quantization is not None and self.clipbox_quantization < 1: raise ValueError("If set, 'clipbox_quantization' must be 1 or positive") # sanity check assert self.has_svgs or self.has_bitmaps if self.is_vf: if self.has_bitmaps: raise ValueError("bitmap formats cannot have multiple masters") if self.is_ot_svg: raise ValueError("OT-SVG formats cannot have multiple masters") return self def default(self) -> MasterConfig: for master in self.masters: if all(master.pos(axis.axisTag) == axis.default for axis in self.axes): return master raise ValueError("Must have a default master") def write(dest: Path, config: FontConfig): toml_cfg = { "family": config.family, "output_file": config.output_file, "color_format": config.color_format, "upem": config.upem, "width": config.width, "ascender": config.ascender, "descender": config.descender, "linegap": config.linegap, "transform": config.transform.tostring(), "version_major": config.version_major, "version_minor": config.version_minor, "reuse_tolerance": config.reuse_tolerance, "ignore_reuse_error": config.ignore_reuse_error, "keep_glyph_names": config.keep_glyph_names, "clip_to_viewbox": config.clip_to_viewbox, "clipbox_quantization": config.clipbox_quantization, "pretty_print": config.pretty_print, "fea_file": config.fea_file, "glyphmap_generator": config.glyphmap_generator, "bitmap_resolutions": tuple(sorted(config.bitmap_resolutions)), "use_zopflipng": config.use_zopflipng, "use_pngquant": config.use_pngquant, "pngquant_flags": config.pngquant_flags, "axis": { a.axisTag: { "name": a.name, "default": a.default, } for a in config.axes }, "master": { m.name: { "style_name": m.style_name, "position": {p.axisTag: p.position for p in m.position}, "srcs": [str(p) for p in m.sources], } for m in config.masters }, } dest.write_text(toml.dumps(toml_cfg)) def _resolve_config( config_file: Optional[Path] = None, ) -> Tuple[Optional[Path], MutableMapping[str, Any]]: if config_file is None: with resources.path("nanoemoji.data", _DEFAULT_CONFIG_FILE) as config_file: # no config_dir in this context; bad input if we need it return None, toml.load(config_file) return config_file.parent, toml.load(config_file) def _resolve_src(relative_base: Optional[Path], src: str) -> Iterable[Path]: src_path = Path(src) if src_path.is_absolute(): if "*" in src: root, *stem = src_path.parts return tuple(Path(root).glob("/".join(stem))) return (src_path,) if relative_base is None: raise ValueError(f"No relative_base, unable to resolve {src_path}") if "*" in src: return tuple(relative_base.glob(src)) return (relative_base.joinpath(src_path),) _DEFAULT_CONFIG = FontConfig() def _pop_flag( config: MutableMapping[str, Any], config_name: str, flag_name: str = "" ) -> Any: if not flag_name: flag_name = config_name # grab both values to ensure we call pop on config value = getattr(FLAGS, flag_name) config_value = config.pop(config_name, None) # prefer flag values if value is not None: return value # failing that, config value value = config_value if value is None: # failing that, default config value value = getattr(_DEFAULT_CONFIG, config_name) return value def load( config_file: Optional[Path] = None, additional_srcs: Optional[Tuple[Path]] = None ) -> FontConfig: config_dir, config = _resolve_config(config_file) # CLI flags will take precedence over the config file family = _pop_flag(config, "family") output_file = _pop_flag(config, "output_file") color_format = _pop_flag(config, "color_format") upem = int(_pop_flag(config, "upem")) width = int(_pop_flag(config, "width")) ascender = int(_pop_flag(config, "ascender")) descender = int(_pop_flag(config, "descender")) linegap = int(_pop_flag(config, "linegap")) transform = _pop_flag(config, "transform") if not isinstance(transform, Affine2D): assert isinstance(transform, str) transform = Affine2D.fromstring(transform) version_major = int(_pop_flag(config, "version_major")) version_minor = int(_pop_flag(config, "version_minor")) reuse_tolerance = float(_pop_flag(config, "reuse_tolerance")) ignore_reuse_error = _pop_flag(config, "ignore_reuse_error") keep_glyph_names = _pop_flag(config, "keep_glyph_names") clip_to_viewbox = _pop_flag(config, "clip_to_viewbox") clipbox_quantization = _pop_flag(config, "clipbox_quantization") pretty_print = _pop_flag(config, "pretty_print") fea_file = _pop_flag(config, "fea_file") glyphmap_generator = _pop_flag(config, "glyphmap_generator") # restore legacy flag if relevant bitmap_resolutions = set() if FLAGS.bitmap_resolution is None: if "bitmap_resolution" in config: bitmap_resolutions.add(config.pop("bitmap_resolution")) if "bitmap_resolutions" in config: for bitmap_resolution in config.pop("bitmap_resolutions"): bitmap_resolutions.add(bitmap_resolution) bitmap_resolutions = tuple(sorted(bitmap_resolutions)) if not bitmap_resolutions: bitmap_resolutions = tuple( int(v) for v in _pop_flag(config, "bitmap_resolutions", "bitmap_resolution") ) use_zopflipng = _pop_flag(config, "use_zopflipng") use_pngquant = _pop_flag(config, "use_pngquant") pngquant_flags = _pop_flag(config, "pngquant_flags") axes = [] for axis_tag, axis_config in config.pop("axis").items(): axes.append( Axis( axis_tag, axis_config.pop("name"), axis_config.pop("default"), ) ) if axis_config: raise ValueError(f"Unexpected '{axis_tag}' config: {axis_config}") masters = [] source_names = set() for master_name, master_config in config.pop("master").items(): positions = tuple( sorted(AxisPosition(k, v) for k, v in master_config.pop("position").items()) ) srcs = set() if "srcs" in master_config: for src in master_config.pop("srcs"): srcs.update(_resolve_src(config_dir, src)) if additional_srcs is not None: srcs.update(additional_srcs) srcs = tuple(sorted(util.abspath(p) for p in srcs)) master = MasterConfig( master_name, master_config.pop("style_name"), ".".join( ( Path(output_file).stem, master_name, "ufo", ) ), positions, srcs, ) if master_config: raise ValueError(f"Unexpected '{master_name}' config: {master_config}") masters.append(master) master_source_names = {s.name for s in master.sources} if len(master_source_names) != len(master.sources): raise ValueError(f"Input svgs for {master_name} must have unique names") if not source_names: source_names = master_source_names elif source_names != master_source_names: raise ValueError(f"{fonts[i].name} srcs don't match {fonts[0].name}") if not masters: raise ValueError("Must have at least one master") if config: raise ValueError(f"Unexpected config: {config}") return FontConfig( family=family, output_file=output_file, color_format=color_format, upem=upem, width=width, ascender=ascender, descender=descender, linegap=linegap, transform=transform, version_major=version_major, version_minor=version_minor, reuse_tolerance=reuse_tolerance, ignore_reuse_error=ignore_reuse_error, keep_glyph_names=keep_glyph_names, clip_to_viewbox=clip_to_viewbox, clipbox_quantization=clipbox_quantization, pretty_print=pretty_print, fea_file=fea_file, glyphmap_generator=glyphmap_generator, bitmap_resolutions=bitmap_resolutions, use_zopflipng=use_zopflipng, use_pngquant=use_pngquant, pngquant_flags=pngquant_flags, axes=tuple(axes), masters=tuple(masters), source_names=tuple(sorted(source_names)), ).validate() def load_configs( config_files: Sequence[Path], additional_srcs: Optional[Tuple[Path]] = None ) -> Tuple[FontConfig]: configs = tuple(load(f, additional_srcs) for f in config_files) output_files = {c.output_file for c in configs} assert len(configs) == len( output_files ), f"{len(output_files)} for {len(configs)} configs. Outputs:\n" + "\n".join( sorted(output_files) ) return configs nanoemoji-0.16.0/src/nanoemoji/copy.py000066400000000000000000000017411523660054300176560ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from absl import app from pathlib import Path import shutil def main(argv): assert len(argv) == 3, "Expected 2 args, input font and output" input_file = Path(argv[1]) assert input_file.is_file(), f"No file {input_file}" output_file = Path(argv[2]) assert input_file.resolve() != output_file.resolve() shutil.copyfile(input_file, output_file) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/data/000077500000000000000000000000001523660054300172405ustar00rootroot00000000000000nanoemoji-0.16.0/src/nanoemoji/data/__init__.py000066400000000000000000000000001523660054300213370ustar00rootroot00000000000000nanoemoji-0.16.0/src/nanoemoji/data/_default.toml000066400000000000000000000002601523660054300217160ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "glyf_colr_1" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" [master.regular.position] wght = 400 nanoemoji-0.16.0/src/nanoemoji/disjoint_set.py000066400000000000000000000026411523660054300214020ustar00rootroot00000000000000# https://en.wikipedia.org/wiki/Disjoint-set_data_structure import collections from typing import FrozenSet, Generic, Generator, Tuple, TypeVar T = TypeVar("T") class DisjointSet(Generic[T]): def __init__(self): self.parent = {} self.rank = {} def make_set(self, e: T): if e in self.parent: return self.parent[e] = e self.rank[e] = 0 # find with path compression def find(self, e: T): self.make_set(e) prev = e while self.parent[e] != e: prev = e e = self.parent[e] self.parent[prev] = e return e # union by rank def union(self, x: T, y: T): x_root = self.find(x) y_root = self.find(y) if x_root == y_root: return # already in the same set if self.rank[x] < self.rank[y]: x_root, y_root = y_root, x_root self.parent[y_root] = x_root if self.rank[x_root] == self.rank[y_root]: self.rank[x_root] += 1 def sets(self) -> FrozenSet[FrozenSet[T]]: sets = collections.defaultdict(set) for e in self.parent: sets[self.find(e)].add(e) return frozenset(frozenset(s) for s in sets.values()) def sorted(self) -> Tuple[Tuple[T, ...], ...]: """Sorted tuple of sorted tuples edition of sets().""" return tuple(sorted(tuple(sorted(s)) for s in self.sets())) nanoemoji-0.16.0/src/nanoemoji/extract_svgs.py000066400000000000000000000032251523660054300214170ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helpers for extracting svg files from the SVG table.""" import copy from fontTools import ttLib from picosvg.svg import SVG from picosvg.svg_meta import strip_ns from typing import Iterable, Tuple def _remove_glyph_elements(svg: SVG, gids_to_remove: Iterable[int]) -> SVG: """Strip out unwanted glyph roots. We do NOT try to strip unused shared content; that is left for others, e.g. picosvg. """ svg = copy.deepcopy(svg) for gid in gids_to_remove: results = svg.xpath(f"svg:*[@id='glyph{gid}']") for result in results: parent = result.getparent() if parent is not None: parent.remove(result) return svg def svg_glyphs(font: ttLib.TTFont) -> Iterable[Tuple[int, SVG]]: for raw_svg, min_gid, max_gid in font["SVG "].docList: gids = set(range(min_gid, max_gid + 1)) svg = SVG.fromstring(raw_svg) for gid in gids: svg_for_gid = svg if len(gids) > 1: svg_for_gid = _remove_glyph_elements(svg_for_gid, gids - {gid}) yield (gid, svg_for_gid) nanoemoji-0.16.0/src/nanoemoji/extract_svgs_from_otsvg.py000066400000000000000000000056261523660054300236730ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generate SVG files from the SVG font table.""" from absl import app from absl import flags from absl import logging from fontTools import ttLib from lxml import etree from nanoemoji import codepoints from nanoemoji.color_glyph import map_viewbox_to_otsvg_space from nanoemoji.extract_svgs import svg_glyphs from nanoemoji import util import os import shutil import subprocess import sys from pathlib import Path from picosvg.svg import SVG from picosvg.svg_meta import strip_ns FLAGS = flags.FLAGS flags.DEFINE_string("output_dir", None, "Output dir. Files written to .svg.") flags.DEFINE_string( "log_level", "INFO", "The threshold for what messages will be logged. One of DEBUG, INFO, WARN, " "ERROR, or FATAL.", ) def main(argv): logging.set_verbosity(FLAGS.log_level) font_file = util.only(argv, lambda a: a.endswith(".ttf")) out_dir = Path(FLAGS.output_dir) assert out_dir.is_dir(), f"{FLAGS.output_dir} is not a directory" font = ttLib.TTFont(font_file) assert "SVG " in font, f"No SVG table in {font_file}" upem = font["head"].unitsPerEm ascender = font["OS/2"].sTypoAscender descender = font["OS/2"].sTypoDescender metrics = font["hmtx"].metrics logging.debug("Writing svgs from %s to %s", font_file, out_dir) logging.debug("upem %d ascender %d descender %d", upem, ascender, descender) # We want a subsequent nanoemoji scale to be 1:1 # So use the font height (global) and width (per glyph) as the svg viewbox height = ascender - descender for gid, svg in svg_glyphs(font): svg_defs = etree.Element("defs") svg_g = etree.Element("g") svg_g.attrib["transform"] = f"translate(0, {ascender})" for el in svg.svg_root: if strip_ns(el.tag) == "defs": svg_defs.append(el) else: svg_g.append(el) svg.svg_root.append(svg_defs) svg.svg_root.append(svg_g) glyph_name = font.getGlyphName(gid) width, _ = metrics[glyph_name] svg.svg_root.attrib["viewBox"] = f"0 0 {width} {height}" dest_file = out_dir / f"{gid:05d}.svg" with open(dest_file, "w") as f: f.write(svg.tostring(pretty_print=True)) logging.debug("Wrote %s", dest_file) if __name__ == "__main__": flags.mark_flag_as_required("output_dir") app.run(main) nanoemoji-0.16.0/src/nanoemoji/features.py000066400000000000000000000026311523660054300205210ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helps deal with emoji OpenType Layout features.""" # TODO if this is a qualified sequence create the unqualified version and vice versa from nanoemoji.glyph import glyph_name DEFAULT_GSUB_FEATURE_TAG = "ccmp" def generate_fea(rgi_sequences, feature_tag=DEFAULT_GSUB_FEATURE_TAG): # Generate feature with ligature lookup for multi-codepoint RGIs rules = [] rules.append("languagesystem DFLT dflt;") rules.append("languagesystem latn dflt;") rules.append("") rules.append(f"feature {feature_tag} {{") for rgi in sorted(rgi_sequences): if len(rgi) == 1: continue glyphs = [glyph_name(cp) for cp in rgi] target = glyph_name(rgi) rules.append(" sub %s by %s;" % (" ".join(glyphs), target)) rules.append(f"}} {feature_tag};") rules.append("") return "\n".join(rules) nanoemoji-0.16.0/src/nanoemoji/fixed.py000066400000000000000000000023641523660054300200050ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from picosvg.geometric_types import almost_equal MIN_INT16 = -(1 << 15) MAX_INT16 = (1 << 15) - 1 MIN_UINT16 = 0 MAX_UINT16 = (1 << 16) - 1 MIN_F2DOT14 = -2.0 MAX_F2DOT14 = MAX_INT16 / (1 << 14) MIN_FIXED = MIN_INT16 MAX_FIXED = ((1 << 31) - 1) / (1 << 16) def int16_safe(*values): return all(almost_equal(v, int(v)) and MIN_INT16 <= v <= MAX_INT16 for v in values) def f2dot14_safe(*values): return all(MIN_F2DOT14 <= value <= MAX_F2DOT14 for value in values) def fixed_safe(*values): return all(MIN_FIXED <= value <= MAX_FIXED for value in values) def f2dot14_rotation_safe(*values): return all(MIN_F2DOT14 <= (value / 180.0) <= MAX_F2DOT14 for value in values) nanoemoji-0.16.0/src/nanoemoji/generate_svgs_from_colr.py000066400000000000000000000040751523660054300236050ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Creates svg files from a COLR table.""" from absl import app from absl import flags from absl import logging from fontTools import ttLib from nanoemoji.colr_to_svg import colr_to_svg, glyph_region, map_font_space_to_viewbox from nanoemoji import util from pathlib import Path from picosvg.geometric_types import Rect FLAGS = flags.FLAGS flags.DEFINE_string("output_dir", None, "Output dir. Files written to .svg.") flags.DEFINE_string( "log_level", "INFO", "The threshold for what messages will be logged. One of DEBUG, INFO, WARN, " "ERROR, or FATAL.", ) def _view_box(font: ttLib.TTFont, glyph_name: str) -> Rect: # we want a viewbox that results in no scaling when translating from font-space return glyph_region(font, glyph_name) def main(argv): logging.set_verbosity(FLAGS.log_level) font_file = util.only(argv, lambda a: a.endswith(".ttf")) out_dir = Path(FLAGS.output_dir) assert out_dir.is_dir(), f"{FLAGS.output_dir} is not a directory" font = ttLib.TTFont(font_file) assert "COLR" in font, f"No COLR table in {font_file}" logging.debug("Writing svgs from %s to %s", font_file, out_dir) for glyph_name, svg in colr_to_svg(lambda gn: _view_box(font, gn), font).items(): gid = font.getGlyphID(glyph_name) dest_file = out_dir / f"{gid:05d}.svg" with open(dest_file, "w") as f: f.write(svg.tostring(pretty_print=True)) if __name__ == "__main__": flags.mark_flag_as_required("output_dir") app.run(main) nanoemoji-0.16.0/src/nanoemoji/glue_together.py000066400000000000000000000204601523660054300215400ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Copies color assets from one font to another. Both must use the same glyph names.""" from absl import app from absl import flags from absl import logging import copy from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables.C_B_D_T_ import cbdt_bitmap_format_17 as CbdtBitmapFormat17 from fontTools import ttLib from nanoemoji import bitmap_tables from nanoemoji.colr import paints_of_type from nanoemoji.reorder_glyphs import reorder_glyphs from nanoemoji.util import load_fully import os from pathlib import Path from typing import Iterable, List, Mapping, NamedTuple, Tuple FLAGS = flags.FLAGS flags.DEFINE_string("target_font", None, "Font assets are copied into.") flags.DEFINE_string("donor_font", None, "Font from which assets are copied.") flags.DEFINE_string("color_table", None, "The color table to copy.") class CbdtGlyphInfo(NamedTuple): data: CbdtBitmapFormat17 size: int def _copy_colr(target: ttLib.TTFont, donor: ttLib.TTFont): # Copy all glyphs used by COLR over colr_version = donor["COLR"].version if colr_version == 1: glyphs_to_copy = sorted( {p.Glyph for p in paints_of_type(donor, ot.PaintFormat.PaintGlyph)} ) elif colr_version == 0: glyphs_to_copy = sorted( { rec.name for layers in donor["COLR"].ColorLayers.values() for rec in layers } ) else: raise NotImplementedError(colr_version) # We avoid using the glyf table's `__setitem__` for it appends to the TTFont's # glyphOrder list without also invalidating the {glyphNames:glyphID} cache, # which means TTFont.getGlyphID could return incorrect result. # Instead we set the new glyphs directly inside the glyf's `glyphs` dict and # call TTFont.setGlyphOrder at the end, which automatically triggers a rebuild # of glyphID cache. # https://github.com/fonttools/fonttools/issues/2605 target_glyphs = target["glyf"].glyphs for glyph_name in glyphs_to_copy: target_glyphs[glyph_name] = donor["glyf"][glyph_name] if glyph_name in target["hmtx"].metrics: assert ( target["hmtx"][glyph_name] == donor["hmtx"][glyph_name] ), f"Unexpected change in metrics for {glyph_name}" else: # new glyph, new metrics target["hmtx"][glyph_name] = donor["hmtx"][glyph_name] target.setGlyphOrder(target.getGlyphOrder() + glyphs_to_copy) if "CPAL" not in target: target["CPAL"] = donor["CPAL"] else: # update the target's default palette (index=0) but leave others alone assert len(donor["CPAL"].palettes) == 1 target["CPAL"].palettes[0] = donor["CPAL"].palettes[0] target["COLR"] = donor["COLR"] def _svg_glyphs(font: ttLib.TTFont) -> Iterable[Tuple[int, str]]: for _, min_gid, max_gid in font["SVG "].docList: for gid in range(min_gid, max_gid + 1): yield gid, font.getGlyphName(gid) def _copy_svg(target: ttLib.TTFont, donor: ttLib.TTFont): # SVG is exciting because nanoemoji likes to restructure glyph order # To keep things simple, let's build a new glyph order that keeps all the svg font gids stable target_glyph_order = list(target.getGlyphOrder()) svg_glyphs = {gn for _, gn in _svg_glyphs(donor)} non_svg_target_glyphs = [gn for gn in target_glyph_order if gn not in svg_glyphs] new_glyph_order = [] for svg_gid, svg_glyph_name in _svg_glyphs(donor): # we want gid to remain stable so copy non-svg glyphs until that will be true while len(new_glyph_order) < svg_gid: new_glyph_order.append(non_svg_target_glyphs.pop(0)) new_glyph_order.append(svg_glyph_name) new_glyph_order.extend(non_svg_target_glyphs) # any leftovers? reorder_glyphs(target, new_glyph_order) target["SVG "] = donor["SVG "] def _cbdt_data_and_sizes(ttfont: ttLib.TTFont) -> Mapping[str, CbdtGlyphInfo]: data = {} for strike_data in ttfont["CBDT"].strikeData: data.update(strike_data) sizes = {} for strike in ttfont["CBLC"].strikes: for sub_table in strike.indexSubTables: for name, (start, end) in zip(sub_table.names, sub_table.locations): sizes[name] = end - start assert data.keys() == sizes.keys(), f"{data.keys()} != {sizes.keys()}" return { glyph_name: CbdtGlyphInfo(data[glyph_name], sizes[glyph_name]) for glyph_name in data } def _copy_cbdt(target: ttLib.TTFont, donor: ttLib.TTFont): cbdt_glyph_info = _cbdt_data_and_sizes(donor) # reorder the bitmap table to match the targets glyph order # we only support square bitmaps so the strikes are all the same # other than glyph names so we can just construct a new # order that matches that of target donor_order = list(cbdt_glyph_info.keys()) only_in_donor = set(donor_order) - set(target.getGlyphOrder()) # confirm our core assumption that we successfully held glyph names stable if only_in_donor: raise ValueError( f"Donor glyph names do not exist in target: {sorted(only_in_donor)}" ) new_order = sorted(donor_order, key=target.getGlyphID) # now we know the desired order, reshard into runs # TODO duplicative of make_cbdt_table # take the first strike as a template, then wipe out strikes and data # so we can build it up again in a potentially different glyph order strike_template = donor["CBLC"].strikes[0] clbc_index_template = strike_template.indexSubTables[0] strike_template.indexSubTables = [] cblc = donor["CBLC"] cblc.strikes = [] cbdt = donor["CBDT"] cbdt.strikeData = [] data_offset = bitmap_tables.CBDT_HEADER_SIZE while new_order: # grab the next run w/consecutive gids min_gid = target.getGlyphID(new_order[0]) end = 1 while ( len(new_order) > end and target.getGlyphID(new_order[end]) == target.getGlyphID(new_order[end - 1]) + 1 ): end += 1 glyph_run = new_order[:end] new_order = new_order[end:] max_gid = target.getGlyphID(glyph_run[-1]) strike = copy.deepcopy(strike_template) strike.bitmapSizeTable.min_gid = min_gid strike.bitmapSizeTable.max_gid = max_gid max_width = max(cbdt_glyph_info[gn].data.metrics.Advance for gn in glyph_run) strike.bitmapSizeTable.hori.widthMax = max_width strike.bitmapSizeTable.vert.widthMax = max_width clbc_index = copy.deepcopy(clbc_index_template) clbc_index.names = glyph_run clbc_index.locations = [] for glyph_name in glyph_run: clbc_index.locations.append( (data_offset, data_offset + cbdt_glyph_info[glyph_name].size) ) data_offset = clbc_index.locations[-1][-1] strike.indexSubTables = [clbc_index] cblc.strikes.append(strike) cbdt.strikeData.append({gn: cbdt_glyph_info[gn].data for gn in glyph_run}) target["CBDT"] = cbdt target["CBLC"] = cblc def main(argv): target = load_fully(Path(FLAGS.target_font)) donor = load_fully(Path(FLAGS.donor_font)) donation = FLAGS.color_table.lower().strip() if donation == "colr": _copy_colr(target, donor) elif donation == "svg": _copy_svg(target, donor) elif donation == "cbdt": _copy_cbdt(target, donor) else: raise ValueError(f"Unsupported color table '{FLAGS.color_table}'") target.save(FLAGS.output_file) logging.info("Wrote %s", FLAGS.output_file) if __name__ == "__main__": flags.mark_flag_as_required("target_font") flags.mark_flag_as_required("donor_font") flags.mark_flag_as_required("color_table") flags.mark_flag_as_required("output_file") app.run(main) nanoemoji-0.16.0/src/nanoemoji/glyph.py000066400000000000000000000027561523660054300200360ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. _MAX_NAME_LEN = 63 # fea for some reason insists on this # str.isascii was added with python 3.7 try: _isascii = str.isascii # type: ignore except AttributeError: def _isascii(s: str) -> bool: try: s.encode("ascii") except UnicodeEncodeError: return False else: return True def _name(cp): ch = chr(cp) if ch.isalpha() and _isascii(ch): return ch return "%x" % cp def glyph_name(codepoints): try: iter(codepoints) except TypeError: codepoints = [codepoints] name = "_".join((_name(c) for c in codepoints)) if len(name) > _MAX_NAME_LEN: import hashlib import base64 hash = hashlib.sha1() # don't care if secure hash.update(name.encode("utf-8")) name = base64.b32encode(hash.digest()).decode("utf-8") if not name[0].isalpha(): name = "g_" + name return name nanoemoji-0.16.0/src/nanoemoji/glyph_reuse.py000066400000000000000000000062651523660054300212400ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Helper for detecting glyph reuse from absl import logging from picosvg.svg_reuse import normalize, affine_between from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath from typing import ( NamedTuple, Optional, ) from .fixed import fixed_safe class ReuseResult(NamedTuple): glyph_name: str transform: Affine2D class GlyphReuseCache: def __init__(self, reuse_tolerance: float): self._reuse_tolerance = reuse_tolerance self._known_glyphs = set() self._reusable_paths = {} # normalize tries to remap first two significant vectors to [1 0], [0 1] # reuse tolerence is relative to viewbox, which is typically much larger # than the space normalize operates in. TODO: better default. self._normalize_tolerance = self._reuse_tolerance / 10 def try_reuse(self, path: str) -> Optional[ReuseResult]: """Try to reproduce path as the transformation of another glyph. Path is expected to be in font units. Returns (glyph name, transform) if possible, None if not. """ assert ( not path in self._known_glyphs ), f"{path} isn't a path, it's a glyph name we've seen before" assert path.startswith("M"), f"{path} doesn't look like a path" if self._reuse_tolerance == -1: return None norm_path = normalize(SVGPath(d=path), self._normalize_tolerance).d if norm_path not in self._reusable_paths: return None glyph_name, glyph_path = self._reusable_paths[norm_path] affine = affine_between( SVGPath(d=glyph_path), SVGPath(d=path), self._reuse_tolerance ) if affine is None: logging.warning("affine_between failed: %s %s ", glyph_path, path) return None # https://github.com/googlefonts/nanoemoji/issues/313 avoid out of bounds affines if not fixed_safe(*affine): logging.warning( "affine_between overflows Fixed: %s %s, %s", glyph_path, path, affine ) return None return ReuseResult(glyph_name, affine) def add_glyph(self, glyph_name, glyph_path): assert glyph_path.startswith("M"), f"{glyph_path} doesn't look like a path" if self._reuse_tolerance != -1: norm_path = normalize(SVGPath(d=glyph_path), self._normalize_tolerance).d else: norm_path = glyph_path self._reusable_paths[norm_path] = (glyph_name, glyph_path) self._known_glyphs.add(glyph_name) def is_known_glyph(self, glyph_name): return glyph_name in self._known_glyphs nanoemoji-0.16.0/src/nanoemoji/glyphmap.py000066400000000000000000000045101523660054300205220ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import csv from dataclasses import dataclass from io import StringIO from pathlib import Path from typing import NamedTuple, Optional, Tuple @dataclass(frozen=True) class GlyphMapping: # One of the two paths can be set to None, but at least one is required svg_file: Optional[Path] bitmap_file: Optional[Path] codepoints: Tuple[int, ...] glyph_name: str def __post_init__(self): if not any((self.svg_file, self.bitmap_file)): raise ValueError("At least one of svg or bitmap filename is required") def csv_line(self) -> str: row = [self.svg_file or "", self.bitmap_file or "", self.glyph_name] if self.codepoints: row.extend(f"{c:04x}" for c in self.codepoints) else: row.append("") # Use a csv.writer instead of ",".join() so we escape commas in file/glyph names f = StringIO() writer = csv.writer(f, lineterminator="") writer.writerow(row) return f.getvalue() def load_from(file) -> Tuple[GlyphMapping]: results = [] reader = csv.reader(file, skipinitialspace=True) for row in reader: try: svg_filename, bitmap_filename, glyph_name, *cps = row except ValueError as e: raise ValueError(f"Error parsing {row} from {file}") from e svg_file = None if not svg_filename else Path(svg_filename) bitmap_file = None if not bitmap_filename else Path(bitmap_filename) if cps and cps != [""]: cps = tuple(int(cp, 16) for cp in cps) else: cps = () results.append(GlyphMapping(svg_file, bitmap_file, cps, glyph_name)) return tuple(results) def parse_csv(filename) -> Tuple[GlyphMapping]: with open(filename) as f: return load_from(f) nanoemoji-0.16.0/src/nanoemoji/keep_glyph_names.py000066400000000000000000000032611523660054300222150ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Update post to keep glyph names.""" from absl import app from absl import flags from absl import logging from fontTools import ttLib from pathlib import Path FLAGS = flags.FLAGS flags.DEFINE_string( "log_level", "INFO", "The threshold for what messages will be logged. One of DEBUG, INFO, WARN, " "ERROR, or FATAL.", ) def keep_glyph_names(font: ttLib.TTFont): # ref https://github.com/googlefonts/ufo2ft/blob/ad28eea062e0dd48678309bd9ef86dfcc85fa85a/Lib/ufo2ft/postProcessor.py#L281-L285 if "post" not in font: raise ValueError(f"No post table") post = font["post"] post.formatType = 2.0 post.extraNames = [] post.mapping = {} def main(argv): logging.set_verbosity(FLAGS.log_level) assert len(argv) == 3, "Expected 2 args, input font and output font" input_file = Path(argv[1]) assert input_file.is_file(), f"No file {input_file}" output_file = Path(argv[2]) assert input_file.resolve() != output_file.resolve() font = ttLib.TTFont(input_file) keep_glyph_names(font) font.save(output_file) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/maximum_color.py000066400000000000000000000332561523660054300215650ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generates color tables to provide support for a wide range of browsers. Requires input font have vector color capability, that is either an SVG table or COLR/CPAL. https://www.youtube.com/watch?v=HLddvNiXym4 Use cbdt for bitmaps because sbix is less x-platform than you'd guess (https://github.com/harfbuzz/harfbuzz/issues/2679) Sample usage: maximum_color MySvgFont.ttf""" from absl import app from absl import flags from absl import logging from fontTools import ttLib from fontTools.ttLib.ttFont import newTable from nanoemoji import config from nanoemoji.colr_to_svg import colr_glyphs from nanoemoji.extract_svgs import svg_glyphs from nanoemoji.ninja import ( build_dir, gen_ninja, maybe_run_ninja, module_rule, rel_build, NinjaWriter, ) from nanoemoji.util import only from pathlib import Path from typing import List, NamedTuple, Optional, Tuple FLAGS = flags.FLAGS flags.DEFINE_bool( "destroy_non_color_glyphs", True, "If true feel free to obliterate any existing glyf/cff content, e.g. fallback glyphs", ) flags.DEFINE_bool( "bitmaps", False, "If true, generate a bitmap table (specificaly CBDT)", ) flags.DEFINE_integer( "colr_version", 1, "COLR table version to generate", lower_bound=0, upper_bound=1, ) # attribute names need to match inputs to write_font rule class WriteFontInputs(NamedTuple): glyphmap_file: Path config_file: Path part_file: Path @property def table_tag(self) -> str: basename = Path(self.glyphmap_file).stem table_tag, _, _ = basename.partition("_") return f"{table_tag:4}" @property def table_version(self) -> Optional[int]: basename = Path(self.glyphmap_file).stem _, _, version = basename.partition("_") return int(version) if version else None @property def color_format(self) -> str: identifier = self.table_tag.strip().lower() table_version = self.table_version if identifier == "svg": # for good woff2 performance, at cost of inflated size return "picosvg" elif identifier == "colr": # optimize for woff2 performance if table_version not in (0, 1): raise ValueError( f"Invalid COLR version, expected 0 or 1, got {table_version}" ) return f"glyf_colr_{table_version}" elif identifier == "cbdt": return "cbdt" else: raise ValueError(f"What is {identifier}?!") @classmethod def for_tag( cls, table_tag: str, table_version: Optional[int] = None ) -> "WriteFontInputs": basename = table_tag.strip() if table_version is not None: basename += f"_{table_version}" return cls( Path(basename + ".glyphmap"), Path(basename + ".toml"), master_part_file_dest(), ) def _vector_color_table(font: ttLib.TTFont) -> str: has_svg = "SVG " in font has_colr = "COLR" in font if has_svg == has_colr: raise ValueError("Must have exactly one of COLR, SVG") if has_svg: return "SVG " if has_colr: return "COLR" raise AssertionError("Impossible") def svg_extract_dir() -> Path: return build_dir() / "svg_dump" def svg_generate_dir() -> Path: return build_dir() / "svg_generate" def picosvg_dir() -> Path: return build_dir() / "picosvg" def picosvg_dest(input_svg: Path) -> Path: return picosvg_dir() / input_svg.name def part_file_dest(picosvg_file: Path) -> Path: return picosvg_file.with_suffix(".parts.json") def master_part_file_dest() -> Path: return Path("parts-merged.json") def bitmap_dir() -> Path: return build_dir() / "bitmap" def bitmap_dest(input_svg: Path) -> Path: return bitmap_dir() / input_svg.with_suffix(".png").name def _write_preamble(nw: NinjaWriter): module_rule( nw, "extract_svgs_from_otsvg", f"--output_dir {rel_build(svg_extract_dir())} $in $out", ) nw.newline() module_rule( nw, "generate_svgs_from_colr", f"--output_dir {rel_build(svg_generate_dir())} $in $out", ) nw.newline() module_rule( nw, "write_glyphmap_for_glyph_svgs", f"--output_file $out @$out.rsp", rspfile="$out.rsp", rspfile_content="$in", ) nw.newline() module_rule( nw, "write_config_for_mergeable", "--color_format $color_format $in $out", ) nw.newline() module_rule( nw, "write_font", f"--config_file $config_file --glyphmap_file $glyphmap_file --part_file $part_file --output_file $out", ) nw.newline() nw.rule( f"picosvg", f"picosvg --output_file $out $in", ) nw.newline() module_rule( nw, "write_part_file", f"--reuse_tolerance $reuse_tolerance --wh $wh --output_file $out $in", ) nw.newline() module_rule( nw, "write_combined_part_files", f"--output_file $out @$out.rsp", rspfile="$out.rsp", rspfile_content="$in", ) nw.newline() # set height only, let width scale proportionally resolutions = config.load().bitmap_resolutions if len(resolutions) != 1: raise ValueError(f"Must have exactly 1 bitmap resolution, not {resolutions}") res = resolutions[0] if res <= 0: raise ValueError(f"Producing bitmaps with resolution {res} doesn't make sense") nw.rule( "write_bitmap", f"resvg -h {res} $in $out", ) nw.newline() module_rule( nw, "glue_together", f"--color_table $color_table --target_font $target_font --donor_font $in --output_file $out", ) nw.newline() module_rule( nw, "keep_glyph_names", f"$in $out", ) nw.newline() module_rule( nw, "strip_glyph_names", f"$in $out", ) nw.newline() module_rule( nw, "copy", f"$in $out", ) nw.newline() def _write_font(nw: NinjaWriter, output_file: Path, inputs: WriteFontInputs): nw.build( output_file, "write_font", implicit=list(inputs), variables=inputs._asdict() ) nw.newline() def _write_config_for_mergeable( nw: NinjaWriter, config_file: Path, input_font: Path, color_format: str ): nw.build( config_file, "write_config_for_mergeable", input_font, variables={"color_format": color_format}, ) nw.newline() def _picosvgs(nw: NinjaWriter, svg_files: List[Path]) -> List[Path]: picosvgs = [rel_build(picosvg_dest(s)) for s in svg_files] for svg_file, picosvg in zip(svg_files, picosvgs): nw.build(picosvg, "picosvg", svg_file) nw.newline() return picosvgs def _part_file( nw: NinjaWriter, font_config: config.FontConfig, picosvg_files: List[Path] ) -> Path: part_files = [part_file_dest(p) for p in picosvg_files] for picosvg_file, part_file in zip(picosvg_files, part_files): nw.build( part_file, "write_part_file", picosvg_file, variables={ "reuse_tolerance": font_config.reuse_tolerance, "wh": font_config.ascender - font_config.descender, }, ) nw.build( master_part_file_dest(), "write_combined_part_files", sorted(part_files), ) return master_part_file_dest() def _generate_additional_color_table( nw: NinjaWriter, input_font: Path, glyphmap_inputs: List[Path], table_tag: str, glue_target: Path, table_version: Optional[int] = None, ) -> Path: write_font_inputs = WriteFontInputs.for_tag(table_tag, table_version) identifier = write_font_inputs.color_format del table_tag # make a glyphmap nw.build( write_font_inputs.glyphmap_file, "write_glyphmap_for_glyph_svgs", glyphmap_inputs, ) nw.newline() # picosvg because we want good woff2 outcomes _write_config_for_mergeable( nw, write_font_inputs.config_file, input_font, write_font_inputs.color_format ) # generate a new font with SVG glyphs that use the same names as the original font_with_new_table = Path("MergeSource." + identifier + ".ttf") _write_font(nw, font_with_new_table, write_font_inputs) # stick our shiny new table onto the input font output_file = Path(input_font.stem + f".added_{identifier}.ttf") nw.build( output_file, "glue_together", font_with_new_table, implicit=list({input_font, glue_target}), variables={ "color_table": write_font_inputs.table_tag.strip(), "target_font": glue_target, }, ) nw.newline() return output_file def _generate_svg_from_colr( nw: NinjaWriter, font_config: config.FontConfig, input_font: Path, font: ttLib.TTFont, ) -> Tuple[Path, List[Path]]: # generate svgs svg_files = [ rel_build(svg_generate_dir() / f"{gid:05d}.svg") for gid in colr_glyphs(font) ] nw.build(svg_files, "generate_svgs_from_colr", input_font) nw.newline() # create and merge an SVG table picosvgs = _picosvgs(nw, svg_files) part_file = _part_file(nw, font_config, picosvgs) output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "SVG ", input_font ) return output_file, picosvgs def _generate_colr_from_svg( nw: NinjaWriter, font_config: config.FontConfig, input_font: Path, font: ttLib.TTFont, colr_version: int, ) -> Tuple[Path, List[Path]]: # extract the svgs svg_files = [ rel_build(svg_extract_dir() / f"{gid:05d}.svg") for gid, _ in svg_glyphs(font) ] nw.build(svg_files, "extract_svgs_from_otsvg", input_font) nw.newline() # create and merge a COLR table picosvgs = _picosvgs(nw, svg_files) part_file = _part_file(nw, font_config, picosvgs) output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "COLR", input_font, table_version=colr_version, ) return output_file, picosvgs def _generate_cbdt( nw: NinjaWriter, input_font: Path, font: ttLib.TTFont, color_font: Path, picosvg_files: List[Path], ): # generate bitmaps bitmap_files = [rel_build(bitmap_dest(s)) for s in picosvg_files] for picosvg, bitmap in zip(picosvg_files, bitmap_files): nw.build(bitmap, "write_bitmap", picosvg) nw.newline() # create and merge a CBDT table output_file = _generate_additional_color_table( nw, input_font, picosvg_files + bitmap_files + [input_font], "CBDT", color_font ) return output_file def _keep_glyph_names(nw: NinjaWriter, input_file: Path) -> ttLib.TTFont: # The whole concept is we keep glyph name stable until the end so # make sure we start with stable names. Doesn't matter what they are, # just that they don't change. output_file = Path(input_file.stem + ".keep_glyph_names.ttf") nw.build( output_file, "keep_glyph_names", input_file, ) nw.newline() return output_file def _strip_glyph_names(nw: NinjaWriter, input_file: Path, output_file: Path): nw.build( output_file, "strip_glyph_names", input_file, ) nw.newline() def _run(argv): if len(argv) != 2: raise ValueError("Must have one argument, a font file") if not FLAGS.destroy_non_color_glyphs: raise NotImplementedError("Retention of non-color glyphs not implemented yet") input_file = Path(argv[1]).resolve() # we need a non-relative path assert input_file.is_file() font = ttLib.TTFont(input_file) font_config = config.load() final_output = Path(font_config.output_file) assert ( input_file.resolve() != (build_dir() / final_output).resolve() ), "In == Out is bad" build_file = build_dir() / "build.ninja" build_dir().mkdir(parents=True, exist_ok=True) color_table = _vector_color_table(font) if gen_ninja(): logging.info(f"Generating {build_file.relative_to(build_dir())}") with open(build_file, "w") as f: nw = NinjaWriter(f) _write_preamble(nw) wip_file = _keep_glyph_names(nw, input_file) # generate the missing vector table if color_table == "COLR": wip_file, picosvg_files = _generate_svg_from_colr( nw, font_config, wip_file, font ) else: wip_file, picosvg_files = _generate_colr_from_svg( nw, font_config, wip_file, font, FLAGS.colr_version ) if FLAGS.bitmaps: wip_file = _generate_cbdt(nw, input_file, font, wip_file, picosvg_files) if config.load().keep_glyph_names: nw.build(final_output, "copy", wip_file) else: _strip_glyph_names(nw, wip_file, final_output) maybe_run_ninja(build_file) def main(): # We don't seem to be __main__ when run as cli tool installed by setuptools app.run(_run) if __name__ == "__main__": app.run(_run) nanoemoji-0.16.0/src/nanoemoji/nanoemoji.py000066400000000000000000000557141523660054300206740ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Create an emoji font from a set of SVGs. UFO handling informed by: Cosimo's https://gist.github.com/anthrotype/2acbc67c75d6fa5833789ec01366a517 Notes for https://github.com/googlefonts/ufo2ft/pull/359 For COLR: Each SVG file represent one base glyph in the COLR font. For each glyph, we get a sequence of PaintedLayer. To convert to font format we use the UFO Glyph pen. Sample usage: nanoemoji -v 1 $(find ~/oss/noto-emoji/svg -name '*.svg') nanoemoji $(find ~/oss/twemoji/assets/svg -name '*.svg') """ from absl import app from absl import flags from absl import logging from collections.abc import Iterable import functools import glob from nanoemoji import codepoints, config, write_font from nanoemoji.config import AxisPosition, FontConfig, MasterConfig from nanoemoji.ninja import ( build_dir, gen_ninja, maybe_run_ninja, module_rule, rel_build, NinjaWriter, ) from nanoemoji.util import fs_root, rel, only, abspath, shell_quote import os from pathlib import Path import platform import re import shlex import shutil import subprocess import sys from typing import ( Any, Callable, List, Mapping, MutableMapping, NamedTuple, Optional, Tuple, Set, Sequence, ) FLAGS = flags.FLAGS flags.DEFINE_bool( "gen_svg_font_diffs", False, "Whether to generate svg vs font render diffs." ) flags.DEFINE_integer("svg_font_diff_resolution", 256, "Render diffs resolution") def self_dir() -> Path: return Path(__file__).parent.resolve() def rel_self(path: Path) -> Path: return rel(self_dir(), path) def _get_bool_flag(name: str): return getattr(FLAGS, name) def _bool_flag(name: str, value: bool): flag = " --" if not value: flag += "no" flag += name return flag def _per_config_file(font_config: FontConfig, suffix: str) -> Path: return build_dir() / Path(font_config.output_file).with_suffix(suffix).name def _config_file(font_config: FontConfig) -> Path: return _per_config_file(font_config, ".toml") def _fea_file(font_config: FontConfig) -> Path: return _per_config_file(font_config, ".fea") def _ufo_config(font_config: FontConfig, master: MasterConfig) -> Path: return _per_config_file(font_config, "." + master.output_ufo + ".toml") def _glyphmap_rule(font_config: FontConfig) -> str: return font_config.glyphmap_generator def _glyphmap_file(font_config: FontConfig, master: MasterConfig) -> Path: master_part = "" if font_config.is_vf: master_part = "." + master.output_ufo return _per_config_file(font_config, master_part + ".glyphmap") def _glyphmap_file(font_config: FontConfig, master: MasterConfig) -> Path: master_part = "" if font_config.is_vf: master_part = "." + master.output_ufo return _per_config_file(font_config, master_part + ".glyphmap") def write_glyphmap_rule(nw, glyphmap_generator): module_rule( nw, glyphmap_generator, f"--output_file $out @$out.rsp", rspfile="$out.rsp", rspfile_content="$in", allow_external=True, ) nw.newline() @functools.lru_cache() def _chrome_command() -> str: cmd, validator = { "Linux": ("google-chrome", shutil.which), "Darwin": ( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", lambda s: Path(s).is_file(), ), "Windows": ("chrome", shutil.which), }[platform.system()] if not validator(cmd): raise ValueError(f"Chrome ({cmd}) not found") return shell_quote(cmd) def write_preamble(nw): nw.rule( f"picosvg_unclipped", f"picosvg " + _bool_flag("clip_to_viewbox", False) + " --output_file $out" + " $in", ) nw.newline() nw.rule( f"picosvg_clipped", f"picosvg " + _bool_flag("clip_to_viewbox", True) + " --output_file $out" + " $in", ) nw.newline() module_rule(nw, "write_fea", "--output_file $out $in") nw.newline() # set height only, let width scale proportionally nw.rule( "write_bitmap", f"resvg -h $res $in $out", ) nw.newline() # -y is to always overwrite existing output without Y/N interactive prompt zopfli_verbose = "" if FLAGS.verbosity: zopfli_verbose = "-v" nw.rule( "zopflipng", f"{sys.executable} -m zopfli.png {zopfli_verbose} -y $in $out", ) nw.newline() # always overwrite using --force pngquant_verbose = "" if FLAGS.verbosity: pngquant_verbose = "-v" module_rule( nw, "pngquant", f"-i $in -o $out -- -f {pngquant_verbose} $pngquant_flags" ) nw.newline() module_rule( nw, "write_combined_part_files", f"--output_file $out @$out.rsp", rspfile="$out.rsp", rspfile_content="$in", ) nw.newline() module_rule( nw, "write_font", f"--config_file $config_file --fea_file $fea_file --glyphmap_file $glyphmap_file --part_file $part_file $in", ) module_rule( nw, "write_variable_font", f"--config_file $config_file $in", ) if FLAGS.gen_svg_font_diffs: res = FLAGS.svg_font_diff_resolution chrome_screenshot = " ".join( ( _chrome_command(), "--headless", f"--window-size=$res,$res", "--force-device-scale-factor=1", "--virtual-time-budget=1000", "--enable-features=COLRV1Fonts", # unnecessary for 98+ "--screenshot=$out", "$in", ) ) nw.rule("screenshot", chrome_screenshot) nw.rule("copy_font_to_screenshot_dir", "cp $in $out") module_rule( nw, "write_font2png_html", f"--resolution $res --output_file $out $in" ) module_rule(nw, "write_pngdiff", f"--output_file $out $in") module_rule( nw, "write_diffreport", f"--lhs_dir {rel_build(svg2png_dir())} " f"--rhs_dir {rel_build(font2png_dir())} " "--output_file $out @$out.rsp", rspfile="$out.rsp", rspfile_content="$in", ) nw.newline() module_rule( nw, "write_part_file", f"--reuse_tolerance $reuse_tolerance --wh $wh --output_file $out $in", ) nw.newline() nw.newline() def picosvg_dir() -> Path: return build_dir() / "picosvg" def bitmap_dir() -> Path: return build_dir() / "bitmap" def zopflipng_dir() -> Path: return build_dir() / "zopflipng" def pngquant_dir() -> Path: return build_dir() / "pngquant" def svg2png_dir() -> Path: return build_dir() / "imagediff" / "svg2png" def font2png_dir() -> Path: return build_dir() / "imagediff" / "font2png" def diff_bitmap_dir() -> Path: return build_dir() / "imagediff" / "diff" def _dest_for_src(scope_fn, out_dir: Path, input_svg: Path, suffix: str) -> Path: if not hasattr(scope_fn, "names_seen"): scope_fn.names_seen = {} names_seen = scope_fn.names_seen # If many different inputs have the same name disambiguate 1..N # by including N in picosvg path input_svg = abspath(input_svg) nth_of_name = 0 while names_seen.get((nth_of_name, input_svg.name), input_svg) != input_svg: nth_of_name += 1 names_seen[(nth_of_name, input_svg.name)] = input_svg if nth_of_name > 0: out_dir = out_dir / str(nth_of_name) return rel_build(out_dir / input_svg.name).with_suffix(suffix) def picosvg_dest(clipped: bool, input_svg: Path) -> Path: out_dir = picosvg_dir() if clipped: out_dir = out_dir / "clipped" return _dest_for_src(picosvg_dest, out_dir, input_svg, ".svg") def part_file_dest(picosvg_file: Path) -> Path: return picosvg_file.with_suffix(".parts.json") def bitmap_dest(input_svg: Path, resolution: int) -> Path: return _dest_for_src(bitmap_dest, bitmap_dir(), input_svg, f".{resolution}.png") def zopflipng_dest(input_svg: Path, resolution: int) -> Path: return _dest_for_src( zopflipng_dest, zopflipng_dir(), input_svg, f".{resolution}.png" ) def pngquant_dest(input_svg: Path, resolution: int) -> Path: return _dest_for_src(pngquant_dest, pngquant_dir(), input_svg, f".{resolution}.png") def svg2png_dest(input_svg: Path) -> Path: return _dest_for_src(svg2png_dest, svg2png_dir(), input_svg, ".png") def font2png_html_dest(input_svg: Path) -> Path: return _dest_for_src(font2png_html_dest, font2png_dir(), input_svg, ".html") def font2png_dest(input_svg: Path) -> Path: return _dest_for_src(font2png_dest, font2png_dir(), input_svg, ".png") def diff_png_dest(input_svg: Path) -> Path: return _dest_for_src(diff_png_dest, diff_bitmap_dir(), input_svg, ".png") def master_part_file_dest() -> Path: return Path("parts-merged.json") def write_picosvg_builds( picosvg_builds: Set[Path], nw: NinjaWriter, font_config: FontConfig, master: MasterConfig, ) -> Tuple[Set[Path], Set[Path]]: rule_name = "picosvg_unclipped" if font_config.clip_to_viewbox: rule_name = "picosvg_clipped" picosvgs = set() part_files = set() for svg_file in master.sources: svg_file = abspath(svg_file) dest = picosvg_dest(font_config.clip_to_viewbox, svg_file) if svg_file in picosvg_builds: continue picosvg_builds.add(svg_file) nw.build(dest, rule_name, rel_build(svg_file)) part_dest = part_file_dest(dest) nw.build( part_dest, "write_part_file", dest, variables={ "reuse_tolerance": font_config.reuse_tolerance, "wh": font_config.ascender - font_config.descender, }, ) picosvgs.add(dest) part_files.add(part_dest) return (picosvgs, part_files) def write_bitmap_builds( bitmap_builds: Set[Path], nw: NinjaWriter, clipped: bool, resolutions: Set[int], master: MasterConfig, ): os.makedirs(str(bitmap_dir()), exist_ok=True) for svg_file in master.sources: for resolution in resolutions: dest = bitmap_dest(svg_file, resolution) if dest in bitmap_builds: continue bitmap_builds.add(dest) nw.build( dest, "write_bitmap", rel_build(svg_file), variables={"res": resolution} ) def write_compressed_bitmap_builds( builds: Set[Path], resolutions: Set[int], nw: NinjaWriter, master: MasterConfig, rule_name: str, dest_dir: Path, infile_fn: Callable[[Path, int], Path], outfile_fn: Callable[[Path, int], Path], variables: Optional[Mapping[str, Any]] = None, ): if variables is None: variables = {} os.makedirs(str(dest_dir), exist_ok=True) for svg_file in master.sources: for resolution in resolutions: dest = outfile_fn(svg_file, resolution) if dest in builds: continue builds.add(dest) nw.build( dest, rule_name, infile_fn(svg_file, resolution), variables=variables ) def write_fea_build(nw: NinjaWriter, font_config: FontConfig): nw.build( rel_build(_fea_file(font_config)), "write_fea", rel_build(_glyphmap_file(font_config, font_config.default())), ) nw.newline() def write_svg_font_diff_build( nw: NinjaWriter, font_dest: str, svg_files: Sequence[Path], resolutions: Set[int] ): # render each svg => png for svg_file in svg_files: for resolution in sorted(resolutions): nw.build( svg2png_dest(svg_file), "screenshot", rel_build(svg_file), variables={"res": resolution}, ) nw.newline() # copy the output font to the screenshot directory font_for_screenshots = font2png_dir() / "Font.ttf" nw.build(font_for_screenshots, "copy_font_to_screenshot_dir", font_dest) # make an html container for each input in the font for svg_file in svg_files: inputs = [ font_for_screenshots, rel_build(svg_file), ] nw.build( font2png_html_dest(svg_file), "write_font2png_html", inputs, variables={"res": resolution}, ) nw.newline() # render the html container => png for svg_file in svg_files: nw.build(font2png_dest(svg_file), "screenshot", font2png_html_dest(svg_file)) nw.newline() # create comparison images for svg_file in svg_files: inputs = [ svg2png_dest(svg_file), font2png_dest(svg_file), ] nw.build(diff_png_dest(svg_file), "write_pngdiff", inputs) nw.newline() # write report and kerplode if there are bad diffs nw.build("diffs.html", "write_diffreport", [diff_png_dest(f) for f in svg_files]) def _input_files(font_config: FontConfig, master: MasterConfig) -> List[Path]: input_files = [] if font_config.has_picosvgs: input_files.extend( picosvg_dest(font_config.clip_to_viewbox, f) for f in master.sources ) if font_config.has_untouchedsvgs: input_files.extend(rel_build(f) for f in master.sources) if font_config.has_bitmaps: dest_func = bitmap_dest if font_config.use_zopflipng: dest_func = zopflipng_dest elif font_config.use_pngquant: dest_func = pngquant_dest # zopflipng always happens after pngquant, so when both are true # the final desired file is zopflipng_dest for resolution in set(font_config.bitmap_resolutions): input_files.extend(dest_func(f, resolution) for f in master.sources) return input_files def _update_sources(font_config: FontConfig) -> FontConfig: if not font_config.has_picosvgs: return font_config return font_config._replace( masters=tuple( master._replace( sources=tuple( Path(picosvg_dest(font_config.clip_to_viewbox, s)) for s in master.sources ) ) for master in font_config.masters ) ) def write_glyphmap_build( nw: NinjaWriter, font_config: FontConfig, master: MasterConfig, ): nw.build( rel_build(_glyphmap_file(font_config, master)), _glyphmap_rule(font_config), _input_files(font_config, master), ) nw.newline() def _variables_for_font_build( font_config: FontConfig, master: MasterConfig, config_file: Path ) -> MutableMapping[str, Any]: return { "config_file": rel_build(config_file), "fea_file": rel_build(_fea_file(font_config)), "glyphmap_file": rel_build(_glyphmap_file(font_config, master)), "part_file": master_part_file_dest(), } def write_ufo_build(nw: NinjaWriter, font_config: FontConfig, master: MasterConfig): ufo_config = font_config._replace(output_file=master.output_ufo, masters=(master,)) ufo_config = _update_sources(ufo_config) ufo_config_file = _ufo_config(font_config, master) config.write(build_dir() / ufo_config_file, ufo_config) variables = _variables_for_font_build(font_config, master, ufo_config_file) variables["config_file"] = rel_build(ufo_config_file) nw.build( master.output_ufo, "write_font", implicit=list(variables.values()), variables=variables, ) nw.newline() def write_static_font_build(nw: NinjaWriter, font_config: FontConfig): assert len(font_config.masters) == 1 variables = _variables_for_font_build( font_config, font_config.default(), _config_file(font_config) ) nw.build( font_config.output_file, "write_font", implicit=list(variables.values()), variables=variables, ) nw.newline() def write_variable_font_build(nw: NinjaWriter, font_config: FontConfig): nw.build( font_config.output_file, "write_variable_font", implicit=[rel_build(_fea_file(font_config))] + [m.output_ufo for m in font_config.masters], variables={"config_file": rel_build(_config_file(font_config))}, ) nw.newline() def _write_config_for_build(font_config: FontConfig): # Dump config with defaults, CLI args, etc resolved to build # and sources updated to point to build picosvgs font_config = _update_sources(font_config) config_file = _config_file(font_config) config.write(config_file, font_config) logging.info(f"Wrote {config_file.relative_to(build_dir().parent)}") def _run(argv): additional_srcs = tuple(Path(f) for f in argv if f.endswith(".svg")) font_configs = config.load_configs( tuple(Path(f) for f in argv if f.endswith(".toml")), additional_srcs=additional_srcs, ) if not font_configs: font_configs = (config.load(additional_srcs=additional_srcs),) if any(fc.has_bitmaps for fc in font_configs) and not shutil.which("resvg"): raise RuntimeError( "'resvg' command-line tool not found on $PATH. " "Try `pip install resvg-cli` or visit https://github.com/RazrFalcon/resvg." ) required_dirs = [build_dir()] if FLAGS.gen_svg_font_diffs: required_dirs += [ svg2png_dir(), font2png_dir(), diff_bitmap_dir(), picosvg_dir(), ] for required_dir in required_dirs: required_dir.mkdir(parents=True, exist_ok=True) build_file = build_dir() / "build.ninja" assert not FLAGS.gen_svg_font_diffs or ( len(font_configs) == 1 ), "Can only generate diffs for one font at a time" if len(font_configs) > 1: assert all(not c.is_vf for c in font_configs) logging.info(f"Proceeding with {len(font_configs)} config(s)") for font_config in font_configs: _write_config_for_build(font_config) if gen_ninja(): logging.info(f"Generating {build_file.relative_to(build_dir())}") with open(build_file, "w") as f: nw = NinjaWriter(f) write_preamble(nw) for glyphmap_generator in sorted( {fc.glyphmap_generator for fc in font_configs} ): write_glyphmap_rule(nw, glyphmap_generator) # After rules, builds for font_config in font_configs: write_fea_build(nw, font_config) for font_config in font_configs: for master in font_config.masters: write_glyphmap_build(nw, font_config, master) picosvg_builds = set() # svgs for which we already made a picosvg part_files = set() for font_config in font_configs: for master in font_config.masters: if font_config.has_picosvgs: _, parts = write_picosvg_builds( picosvg_builds, nw, font_config, master, ) part_files |= parts nw.newline() # Write a combined part file (potentially empty) nw.build( master_part_file_dest(), "write_combined_part_files", sorted(part_files), ) bitmap_builds = set() # svgs for which we already made a bitmap for font_config in font_configs: if font_config.has_bitmaps: assert not font_config.is_vf write_bitmap_builds( bitmap_builds, nw, font_config.clip_to_viewbox, # currently unused set(font_config.bitmap_resolutions), font_config.masters[0], ) nw.newline() zopflipng_builds = set() # svgs for which we already made a zopflipng pngquant_builds = set() # svgs for which we already made a pngquant for font_config in font_configs: if not font_config.has_bitmaps or not ( font_config.use_zopflipng or font_config.use_pngquant ): continue assert not font_config.is_vf master = font_config.masters[0] if font_config.use_pngquant: write_compressed_bitmap_builds( pngquant_builds, set(font_config.bitmap_resolutions), nw, master, rule_name="pngquant", dest_dir=pngquant_dir(), infile_fn=bitmap_dest, outfile_fn=pngquant_dest, variables={"pngquant_flags": font_config.pngquant_flags}, ) if font_config.use_zopflipng: zopflipng_infile_fn = bitmap_dest if font_config.use_pngquant: zopflipng_infile_fn = pngquant_dest nw.newline() write_compressed_bitmap_builds( zopflipng_builds, set(font_config.bitmap_resolutions), nw, master, rule_name="zopflipng", dest_dir=zopflipng_dir(), infile_fn=zopflipng_infile_fn, outfile_fn=zopflipng_dest, ) nw.newline() for font_config in font_configs: if FLAGS.gen_svg_font_diffs: assert not font_config.is_vf write_svg_font_diff_build( nw, font_config.output_file, font_config.masters[0].sources, set(font_config.bitmap_resolutions), ) for master in font_config.masters: if font_config.is_vf: write_ufo_build(nw, font_config, master) for font_config in font_configs: if font_config.is_vf: write_variable_font_build(nw, font_config) else: write_static_font_build(nw, font_config) maybe_run_ninja(build_file) def main(): # We don't seem to be __main__ when run as cli tool installed by setuptools app.run(_run) if __name__ == "__main__": app.run(_run) nanoemoji-0.16.0/src/nanoemoji/ninja.py000066400000000000000000000071131523660054300200020ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helps work with ninja.""" from absl import flags from absl import logging from nanoemoji.util import rel, quote_if_path from ninja import ninja_syntax import os from pathlib import Path import subprocess import sys from typing import MutableSequence FLAGS = flags.FLAGS # internal flags, typically client wouldn't change flags.DEFINE_string("build_dir", "build/", "Where build runs.") flags.DEFINE_bool("gen_ninja", True, "Whether to regenerate build.ninja") flags.DEFINE_bool("exec_ninja", True, "Whether to run ninja.") def _str_path(arg): if isinstance(arg, Path): return str(arg) if isinstance(arg, MutableSequence): return _str_paths(arg) return arg def _str_paths(args): # Path in particular kerplodes nw.build if isinstance(args, dict): return {_str_path(k): _str_path(v) for k, v in args.items()} elif isinstance(args, list) or isinstance(args, tuple): return [_str_path(v) for v in args] else: raise ValueError(f"What to do with {type(args)}") class NinjaWriter: def __init__(self, output_f): self._nw = ninja_syntax.Writer(output_f) self.comment("Generated by nanoemoji") self.newline() def rule(self, *args, **kwargs): self._nw.rule(*args, **kwargs) def build(self, *args, **kwargs): # ninja already shell-quotes paths containing spaces/special characters when # when expanding $in or $out variables in rule commands, however other # user-defined variables are not quoted automatically. So when we pass a path # as a variable, we need to make sure it's correctly quoted. Other string # variables we leave unquoted as they may contain multiple arguments (e.g. # $pngquant_flags). variables = { k: quote_if_path(v) for k, v in kwargs.pop("variables", {}).items() } self._nw.build( *_str_paths(args), **{k: _str_paths(v) for k, v in kwargs.items()}, variables=variables, ) def newline(self): self._nw.newline() def comment(self, comment): self._nw.comment(comment) def build_dir() -> Path: return Path(FLAGS.build_dir).resolve() def rel_build(path: Path) -> Path: return rel(build_dir(), path) def gen_ninja() -> bool: return FLAGS.gen_ninja def module_rule( nw, mod_name, arg_pattern, rspfile=None, rspfile_content=None, rule_name=None, allow_external=False, ): if not rule_name: rule_name = mod_name if not allow_external: mod_name = "nanoemoji." + mod_name nw.rule( rule_name, f"{sys.executable} -m {mod_name} -v {FLAGS.verbosity} {arg_pattern}", rspfile=rspfile, rspfile_content=rspfile_content, ) def maybe_run_ninja(build_file: Path): ninja_cmd = ["ninja", "-C", os.path.dirname(build_file)] if FLAGS.exec_ninja: logging.info(" ".join(ninja_cmd)) subprocess.run(ninja_cmd, check=True) else: logging.info("To run: " + " ".join(ninja_cmd)) nanoemoji-0.16.0/src/nanoemoji/paint.py000066400000000000000000000575151523660054300200310ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helpers for expressing paint. Based on https://github.com/googlefonts/colr-gradients-spec/blob/main/colr-gradients-spec.md#structure-of-gradient-colr-v1-extensions. """ import dataclasses from abc import ABC, abstractmethod from enum import Enum, IntEnum from absl import logging from fontTools.ttLib.tables import otTables as ot from math import copysign, radians from nanoemoji.colors import Color from nanoemoji.fixed import ( int16_safe, f2dot14_safe, MIN_INT16, MAX_INT16, MIN_UINT16, MAX_UINT16, ) from picosvg.geometric_types import Point, almost_equal from picosvg.svg_transform import Affine2D from typing import ( Any, ClassVar, Generator, Iterable, Mapping, Optional, Sequence, Tuple, ) class Extend(Enum): PAD = (0,) REPEAT = (1,) REFLECT = (2,) # Porter-Duff modes for COLRv1 PaintComposite: # https://github.com/googlefonts/colr-gradients-spec/tree/off_sub_1#compositemode-enumeration class CompositeMode(IntEnum): CLEAR = 0 SRC = 1 DEST = 2 SRC_OVER = 3 DEST_OVER = 4 SRC_IN = 5 DEST_IN = 6 SRC_OUT = 7 DEST_OUT = 8 SRC_ATOP = 9 DEST_ATOP = 10 XOR = 11 PLUS = 12 SCREEN = 13 OVERLAY = 14 DARKEN = 15 LIGHTEN = 16 COLOR_DODGE = 17 COLOR_BURN = 18 HARD_LIGHT = 19 SOFT_LIGHT = 20 DIFFERENCE = 21 EXCLUSION = 22 MULTIPLY = 23 HSL_HUE = 24 HSL_SATURATION = 25 HSL_COLOR = 26 HSL_LUMINOSITY = 27 def _identity(v): return v _PAINT_FIELD_TO_OT_FIELD = { "format": ("PaintFormat", _identity), "paint": ("Paint", _identity), "transform": ("Transform", lambda t: (t.xx, t.yx, t.xy, t.yy, t.dx, t.dy)), "center": (("centerX", "centerY"), _identity), } @dataclasses.dataclass(frozen=True) class PaintTraverseContext: path: Tuple["Paint", ...] paint: "Paint" transform: Affine2D @dataclasses.dataclass(frozen=True) class ColorStop: stopOffset: float = 0.0 color: Color = Color.fromstring("black") def round(self, ndigits: int) -> "ColorStop": return dataclasses.replace(self, stopOffset=round(self.stopOffset, ndigits)) class Paint(ABC): format: ClassVar[int] = -1 # so pytype knows all Paint have format @abstractmethod def colors(self) -> Generator[Color, None, None]: ... @abstractmethod def to_ufo_paint(self, colors: Sequence[Color]): ... def breadth_first(self) -> Generator[PaintTraverseContext, None, None]: frontier = [PaintTraverseContext((), self, Affine2D.identity())] while frontier: context = frontier.pop(0) yield context transform = context.transform paint_transform = context.paint.gettransform() transform = Affine2D.compose_ltr( ( transform, paint_transform, ) ) for paint in context.paint.children(): frontier.append( PaintTraverseContext( context.path + (context.paint,), paint, transform ) ) def children(self) -> Iterable["Paint"]: return () def gettransform(self) -> Affine2D: # Returns the transform caused by this Paint (not it's ancestors) return Affine2D.identity() @classmethod def from_ot(cls, ot_paint: ot.Paint) -> "Paint": paint_t = globals()[ot_paint.getFormatName()] paint_args = [] for f in dataclasses.fields(paint_t): ot_field, converter = _PAINT_FIELD_TO_OT_FIELD.get( f.name, (f.name, _identity) ) if isinstance(ot_field, tuple): arg = tuple(converter(getattr(ot_paint, f)) for f in ot_field) else: arg = converter(getattr(ot_paint, ot_field)) paint_args.append(arg) paint = paint_t(*paint_args) return paint @dataclasses.dataclass(frozen=True) class PaintColrLayers(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintColrLayers) layers: Tuple[Paint, ...] def colors(self): for p in self.layers: yield from p.colors() def to_ufo_paint(self, colors): return { "Format": self.format, "Layers": [p.to_ufo_paint(colors) for p in self.layers], } def children(self): return self.layers @dataclasses.dataclass(frozen=True) class PaintSolid(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintSolid) color: Color = Color.fromstring("black") def colors(self): yield self.color def to_ufo_paint(self, colors): return { "Format": self.format, "PaletteIndex": self.color.opaque().index_from(colors), "Alpha": self.color.alpha, } def _ufoColorLine(gradient, colors): return { "ColorStop": [ { "StopOffset": stop.stopOffset, "PaletteIndex": stop.color.opaque().index_from(colors), "Alpha": stop.color.alpha, } for stop in gradient.stops ], "Extend": gradient.extend.name.lower(), } @dataclasses.dataclass(frozen=True) class PaintLinearGradient(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintLinearGradient) extend: Extend = Extend.PAD stops: Tuple[ColorStop, ...] = tuple() p0: Point = Point() p1: Point = Point() p2: Point = None # if normal undefined, default to P1 rotated 90° cc'wise def __post_init__(self): if self.p2 is None: p0, p1 = Point(*self.p0), Point(*self.p1) # use object.__setattr__ as the dataclass is frozen object.__setattr__(self, "p2", p0 + (p1 - p0).perpendicular()) def colors(self): for stop in self.stops: yield stop.color def to_ufo_paint(self, colors): return { "Format": self.format, "ColorLine": _ufoColorLine(self, colors), "x0": self.p0[0], "y0": self.p0[1], "x1": self.p1[0], "y1": self.p1[1], "x2": self.p2[0], "y2": self.p2[1], } def check_overflows(self) -> "PaintLinearGradient": for i in range(3): attr_name = f"p{i}" value = getattr(self, attr_name) for j in range(2): if not (MIN_INT16 <= value[j] <= MAX_INT16): raise OverflowError( f"{self.__class__.__name__}.{attr_name}[{j}] ({value[j]}) is " f"out of bounds: [{MIN_INT16}...{MAX_INT16}]" ) return self def apply_transform(self, transform: Affine2D, check_overflows=True) -> Paint: gradient = dataclasses.replace( self, p0=transform.map_point(self.p0), p1=transform.map_point(self.p1), p2=transform.map_point(self.p2), ) if check_overflows: gradient.check_overflows() return gradient def round(self, ndigits: int) -> "PaintLinearGradient": return dataclasses.replace( self, stops=tuple(stop.round(ndigits) for stop in self.stops), p0=self.p0.round(ndigits), p1=self.p1.round(ndigits), p2=self.p2.round(ndigits), ) def _decompose_uniform_transform(transform: Affine2D) -> Tuple[Affine2D, Affine2D]: scale, remaining_transform = transform.decompose_scale() s = max(*scale.getscale()) # most transforms will contain a Y-flip component as result of mapping from SVG to # font coordinate space. Here we keep this negative Y sign as part of the uniform # transform since it does not affect the circle-ness, and also makes so that the # font-mapped gradient geometry is more likely to be in the +x,+y quadrant like # the path geometry it is applied to. uniform_scale = Affine2D(s, 0, 0, copysign(s, transform.d), 0, 0) remaining_transform = Affine2D.compose_ltr( (uniform_scale.inverse(), scale, remaining_transform) ) translate, remaining_transform = remaining_transform.decompose_translation() # round away very small float-math noise, so we get clean 0s and 1s for the special # case of identity matrix which implies no wrapping transform remaining_transform = remaining_transform.round(9) logging.debug( "Decomposing %r:\n\tscale: %r\n\ttranslate: %r\n\tremaining: %r", transform, uniform_scale, translate, remaining_transform, ) uniform_transform = Affine2D.compose_ltr((uniform_scale, translate)) return uniform_transform, remaining_transform @dataclasses.dataclass(frozen=True) class PaintRadialGradient(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintRadialGradient) extend: Extend = Extend.PAD stops: Tuple[ColorStop, ...] = tuple() c0: Point = Point() c1: Point = Point() r0: float = 0.0 r1: float = 0.0 def colors(self): for stop in self.stops: yield stop.color def to_ufo_paint(self, colors): paint = { "Format": self.format, "ColorLine": _ufoColorLine(self, colors), "x0": self.c0[0], "y0": self.c0[1], "r0": self.r0, "x1": self.c1[0], "y1": self.c1[1], "r1": self.r1, } return paint def check_overflows(self) -> "PaintRadialGradient": int_bounds = { "c": (MIN_INT16, MAX_INT16), "r": (MIN_UINT16, MAX_UINT16), } attrs = [] for prefix in ("c", "r"): for i in range(2): attr_name = f"{prefix}{i}" value = getattr(self, attr_name) if prefix == "c": attrs.extend((f"{attr_name}[{j}]", value[j]) for j in range(2)) else: attrs.append((f"{attr_name}", value)) for attr_name, value in attrs: min_value, max_value = int_bounds[attr_name[0]] if not (min_value <= value <= max_value): raise OverflowError( f"{self.__class__.__name__}.{attr_name} ({value}) is " f"out of bounds: [{min_value}...{max_value}]" ) return self def apply_transform(self, transform: Affine2D, check_overflows=True) -> Paint: # if gradientUnits="objectBoundingBox" and the bbox is not square, or there's some # gradientTransform, we may end up with a transformation that does not keep the # aspect ratio of the gradient circles and turns them into ellipses, but CORLv1 # PaintRadialGradient by itself can only define circles. Thus we only apply the # uniform scale and translate components of the original transform to the circles, # then encode any remaining non-uniform transformation as a COLRv1 transform # that wraps the PaintRadialGradient (see further below). uniform_transform, remaining_transform = _decompose_uniform_transform(transform) c0 = uniform_transform.map_point(self.c0) c1 = uniform_transform.map_point(self.c1) sx, _ = uniform_transform.getscale() r0 = self.r0 * sx r1 = self.r1 * sx # TODO handle degenerate cases, fallback to solid, w/e gradient = dataclasses.replace(self, c0=c0, c1=c1, r0=r0, r1=r1) if check_overflows: gradient.check_overflows() return transformed(remaining_transform, gradient) def round(self, ndigits: int) -> "PaintRadialGradient": return dataclasses.replace( self, stops=tuple(stop.round(ndigits) for stop in self.stops), c0=self.c0.round(ndigits), c1=self.c1.round(ndigits), r0=round(self.r0, ndigits), r1=round(self.r1, ndigits), ) # TODO PaintSweepGradient @dataclasses.dataclass(frozen=True) class PaintGlyph(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintGlyph) glyph: str paint: Paint def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Glyph": self.glyph, "Paint": self.paint.to_ufo_paint(colors), } return paint def children(self) -> Iterable[Paint]: return (self.paint,) @dataclasses.dataclass(frozen=True) class PaintColrGlyph(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintColrGlyph) glyph: str def to_ufo_paint(self, _): paint = {"Format": self.format, "Glyph": self.glyph} return paint class _BasePaintTransform(Paint): paint: Paint @abstractmethod def gettransform(self) -> Affine2D: ... @dataclasses.dataclass(frozen=True) class PaintTransform(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintTransform) transform: Tuple[float, float, float, float, float, float] paint: Paint def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Transform": self.transform, "Paint": self.paint.to_ufo_paint(colors), } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D(*self.transform) @dataclasses.dataclass(frozen=True) class PaintTranslate(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintTranslate) paint: Paint dx: int dy: int def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "dx": self.dx, "dy": self.dy, } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().translate(self.dx, self.dy) @dataclasses.dataclass(frozen=True) class PaintScale(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintScale) paint: Paint scaleX: float = 1.0 scaleY: float = 1.0 def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "scaleX": self.scaleX, "scaleY": self.scaleY, } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().scale(self.scaleX, self.scaleY) @dataclasses.dataclass(frozen=True) class PaintScaleAroundCenter(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintScaleAroundCenter) paint: Paint scaleX: float = 1.0 scaleY: float = 1.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "scaleX": self.scaleX, "scaleY": self.scaleY, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return ( Affine2D.identity() .translate(self.center[0], self.center[1]) .scale(self.scaleX, self.scaleY) .translate(-self.center[0], -self.center[1]) ) @dataclasses.dataclass(frozen=True) class PaintScaleUniform(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintScaleUniform) paint: Paint scale: float = 1.0 def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "scale": self.scale, } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().scale(self.scale) @dataclasses.dataclass(frozen=True) class PaintScaleUniformAroundCenter(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintScaleUniformAroundCenter) paint: Paint scale: float = 1.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "scale": self.scale, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return ( Affine2D.identity() .translate(self.center[0], self.center[1]) .scale(self.scale) .translate(-self.center[0], -self.center[1]) ) @dataclasses.dataclass(frozen=True) class PaintRotate(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintRotate) paint: Paint angle: float = 0.0 def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "angle": self.angle, } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().rotate(radians(self.angle)) @dataclasses.dataclass(frozen=True) class PaintRotateAroundCenter(_BasePaintTransform): format: ClassVar[int] = int(ot.PaintFormat.PaintRotateAroundCenter) paint: Paint angle: float = 0.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "angle": self.angle, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().rotate( radians(self.angle), self.center[0], self.center[1] ) @dataclasses.dataclass(frozen=True) class PaintSkew(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintSkew) paint: Paint xSkewAngle: float = 0.0 ySkewAngle: float = 0.0 def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "xSkewAngle": self.xSkewAngle, "ySkewAngle": self.ySkewAngle, } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().skew( -radians(self.xSkewAngle), radians(self.ySkewAngle) ) @dataclasses.dataclass(frozen=True) class PaintSkewAroundCenter(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintSkewAroundCenter) paint: Paint xSkewAngle: float = 0.0 ySkewAngle: float = 0.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "xSkewAngle": self.xSkewAngle, "ySkewAngle": self.ySkewAngle, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return ( Affine2D.identity() .translate(self.center[0], self.center[1]) .skew(-radians(self.xSkewAngle), radians(self.ySkewAngle)) .translate(-self.center[0], -self.center[1]) ) @dataclasses.dataclass(frozen=True) class PaintComposite(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintComposite) mode: CompositeMode source: Paint backdrop: Paint def colors(self): yield from self.source.colors() yield from self.backdrop.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "CompositeMode": self.mode.name.lower(), "SourcePaint": self.source.to_ufo_paint(colors), "BackdropPaint": self.backdrop.to_ufo_paint(colors), } return paint def children(self) -> Iterable[Paint]: return (self.source, self.backdrop) def is_transform(paint_or_format) -> bool: if isinstance(paint_or_format, Paint): paint_or_format = paint_or_format.format return ( ot.PaintFormat.PaintTransform <= paint_or_format <= ot.PaintFormat.PaintVarSkewAroundCenter ) def is_gradient(paint_or_format) -> bool: if isinstance(paint_or_format, Paint): paint_or_format = paint_or_format.format return ( ot.PaintFormat.PaintLinearGradient <= paint_or_format <= ot.PaintFormat.PaintVarSweepGradient ) def transformed(transform: Affine2D, target: Paint) -> Paint: if transform == Affine2D.identity(): return target sx, b, c, sy, dx, dy = transform # Int16 translation? if (dx, dy) != (0, 0) and Affine2D.identity().translate(dx, dy) == transform: if int16_safe(dx, dy): return PaintTranslate(paint=target, dx=dx, dy=dy) # Scale? # If all we have are scale and translation this is pure scaling # If b,c are present this is some sort of rotation or skew if (sx, sy) != (1, 1) and (b, c) == (0, 0) and f2dot14_safe(sx, sy): if (dx, dy) == (0, 0): if almost_equal(sx, sy): return PaintScaleUniform(paint=target, scale=sx) else: return PaintScale(paint=target, scaleX=sx, scaleY=sy) else: # translated scaling is the same as scale around a non-origin center # If you trace through translate, scale, translate you get: # dx = (1 - sx) * cx # cx = dx / (1 - sx) # undefined if sx == 1; if so dx should == 0 # so, as long as (1==sx) == (0 == dx) we're good if (1 == sx) == (0 == dx) and (1 == sy) == (0 == dy): cx = 0 if sx != 1: cx = dx / (1 - sx) cy = 0 if sy != 1: cy = dy / (1 - sy) if int16_safe(cx, cy): if almost_equal(sx, sy): return PaintScaleUniformAroundCenter( paint=target, scale=sx, center=Point(cx, cy) ) else: return PaintScaleAroundCenter( paint=target, scaleX=sx, scaleY=sy, center=Point(cx, cy) ) # TODO optimize rotations # TODO optimize, skew, rotate around center return PaintTransform(paint=target, transform=tuple(transform)) nanoemoji-0.16.0/src/nanoemoji/parts.py000066400000000000000000000253731523660054300200440ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """A cache of reusable parts, esp paths, for whatever purpose you see fit. Intended to be used as a building block for glyph reuse. We always apply nop transforms to ensure any command type flips, such as arcs to cubics, occur. This ensures that if we merge a part file with no transform with one that has transformation the command types still align. """ import dataclasses from functools import lru_cache, partial, reduce import json from nanoemoji.config import FontConfig from nanoemoji.color_glyph import scale_viewbox_to_font_metrics from pathlib import Path from picosvg.geometric_types import Rect from picosvg.svg import SVG from picosvg.svg_meta import cmd_coords from picosvg.svg_reuse import affine_between, normalize from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath, SVGShape from typing import ( Iterable, List, MutableMapping, NamedTuple, NewType, Optional, Set, Tuple, Union, ) PathSource = Union[SVG, "ReusableParts"] _DEFAULT_ROUND_NDIGITS = 3 @lru_cache(maxsize=1) def _default_tolerence() -> float: return FontConfig().reuse_tolerance def _is_iterable_of(thing, desired_type) -> bool: try: it = iter(thing) except TypeError: return False try: val = next(it) return isinstance(val, desired_type) except StopIteration: return True # an SVG style path, e.g. the d attribute of Shape = NewType("Shape", str) # A normalized SVG style path NormalizedShape = NewType("NormalizedShape", str) # A set of shapes that normalize to the same path ShapeSet = NewType("ShapeSet", Set[Shape]) class ReuseResult(NamedTuple): transform: Affine2D shape: Shape @lru_cache(maxsize=512) def _bbox_area(shape: Shape) -> float: bbox = SVGPath(d=shape).bounding_box() return bbox.w * bbox.h def _round(shape: SVGShape) -> SVGPath: return shape.as_path().round_floats(_DEFAULT_ROUND_NDIGITS) def as_shape(path: SVGPath) -> Shape: # apply a nop transform because some things still change, like arcs to cubics path = path.apply_transform(Affine2D.identity()) return Shape(_round(path).d) # TODO: create a parts builder and a frozen parts from compute_donors() to more explicitly model the add/use cycle @dataclasses.dataclass class ReusableParts: version: Tuple[int, int, int] = (1, 0, 0) view_box: Rect = Rect(0, 0, 1, 1) reuse_tolerance: float = dataclasses.field(default_factory=_default_tolerence) shape_sets: MutableMapping[NormalizedShape, ShapeSet] = dataclasses.field( default_factory=dict ) _donor_cache: MutableMapping[NormalizedShape, Optional[Shape]] = dataclasses.field( default_factory=dict ) def normalize(self, path: str) -> NormalizedShape: if self.reuse_tolerance != -1: # normalize handles it's own rounding # apply a nop transform because some things still change, like arcs to cubics norm = NormalizedShape( normalize( SVGPath(d=path).apply_transform(Affine2D.identity()), self.reuse_tolerance, ).d ) else: norm = NormalizedShape(path) return norm def _add_norm_path(self, norm: NormalizedShape, shape: Shape): if norm not in self.shape_sets: self.shape_sets[norm] = ShapeSet(set()) self.shape_sets[norm].add(shape) self._donor_cache.pop(norm, None) def _add(self, shape: Shape): norm = self.normalize(shape) self._add_norm_path(norm, shape) def add(self, source: PathSource): """Combine two sets of parts. Source shapes will be scaled to dest viewbox.""" if isinstance(source, ReusableParts): transform = Affine2D.rect_to_rect(source.view_box, self.view_box) shapes = tuple( reduce(lambda a, c: a | c, source.shape_sets.values(), set()) ) elif isinstance(source, SVG): source.checkpicosvg() source_box = source.view_box() transform = scale_viewbox_to_font_metrics( self.view_box, source_box.h, 0, source_box.w ) shapes = tuple(s.as_path() for s in source.shapes()) else: raise ValueError(f"Unknown part source: {type(source)}") for shape in shapes: if isinstance(shape, str): shape = SVGPath(d=shape) if transform != Affine2D.identity(): shape = shape.apply_transform(transform) self._add(as_shape(shape)) def _compute_donor(self, norm: NormalizedShape): self._donor_cache[norm] = None # no solution # try to select a donor that can fulfil every member of the set # the input shape is in the set so if found we can get from donor => input # shrinking a big thing is more likely to result in small #s that fit into # more compact PaintTransform variants so try biggest first # NOTE there are cases where this picks a suboptimal transform, e.g. a 2x3 # downscale be used when a scale uniform around center upscale might work # Ex SVGPath(d="M8,13 A3 3 0 1 1 2,13 A3 3 0 1 1 8,13 Z") # SVGPath(d="M11,5 A2 2 0 1 1 7,5 A2 2 0 1 1 11,5 Z") # A fancier implementation would factor in the # of occurences and the cost # based on which shape is selected as donor if there are many possibilities. svg_paths = sorted( self.shape_sets[norm], key=lambda s: (_bbox_area(s), s), reverse=True ) svg_paths = [SVGPath(d=s) for s in svg_paths] if self.reuse_tolerance == -1: # No reuse across different shapes; all paths in the set are identical # strings (since normalize() returns raw paths when tolerance is -1), # so any path is a valid donor. self._donor_cache[norm] = Shape(svg_paths[0].d) return for svg_path in svg_paths: if all( affine_between(svg_path, svg_path2, self.reuse_tolerance) is not None for svg_path2 in svg_paths ): # Do NOT use as_shape; these paths already passed through it self._donor_cache[norm] = Shape(svg_path.d) break def compute_donors(self): self._donor_cache.clear() for norm in self.shape_sets: self._compute_donor(norm) def is_reused(self, shape: SVGPath) -> bool: shape = as_shape(shape) norm = self.normalize(shape) if norm not in self.shape_sets: return False if len(self.shape_sets[norm]) < 2: return False if norm not in self._donor_cache: self._compute_donor(norm) return shape == self._donor_cache[norm] # this shape provides! def try_reuse(self, shape: SVGPath) -> Optional[ReuseResult]: """Returns the shape and transform to use to build the input shape.""" shape = as_shape(shape) if self.reuse_tolerance == -1: return ReuseResult(Affine2D.identity(), shape) norm = self.normalize(shape) # The whole point is to pre-add, doing it on the fly reduces reuse if norm not in self.shape_sets: print(self.to_json()) raise ValueError( f"You MUST pre-add your shapes. No set matches normalization {norm} for {shape}." ) if shape not in self.shape_sets[norm]: print(self.to_json()) raise ValueError(f"You MUST pre-add your shapes. {shape} is new to us.") if norm not in self._donor_cache: assert ( shape in self.shape_sets[norm] ), f"The input shape must be in the group" self._compute_donor(norm) donor = self._donor_cache[norm] if donor is None: # bail out, no solution! return None affine = affine_between( SVGPath(d=donor), SVGPath(d=shape), self.reuse_tolerance ) assert ( affine is not None ), f"Should only get here with a solution. Epic fail on {donor}, {shape.d}" return ReuseResult(affine, donor) def to_json(self): json_dict = { "version": ".".join(str(v) for v in self.version), "reuse_tolerance": self.reuse_tolerance, "view_box": " ".join(str(int(v)) for v in self.view_box), "shape_sets": [ { "normalized": n, "shapes": list(s), "donor": self._donor_cache.get(n, ""), } for n, s in self.shape_sets.items() ], } return json.dumps(json_dict, indent=2) @classmethod def from_json(cls, string: str) -> "ReusableParts": json_dict = json.loads(string) parts = ReusableParts() parts.version = tuple(int(v) for v in json_dict.pop("version").split(".")) assert parts.version == (1, 0, 0), f"Bad version {parts.version}" parts.view_box = Rect(*(int(v) for v in json_dict.pop("view_box").split(" "))) assert parts.view_box[:2] == ( 0, 0, ), f"Must be a viewbox from 0,0 {parts.view_box}" parts.reuse_tolerance = float(json_dict.pop("reuse_tolerance")) for shape_set_json in json_dict.pop("shape_sets"): norm = NormalizedShape(shape_set_json.pop("normalized")) shapes = ShapeSet({Shape(s) for s in shape_set_json.pop("shapes")}) donor = shape_set_json.pop("donor") if donor and donor not in shapes: raise ValueError("Donor must be in group") if shape_set_json: raise ValueError(f"Unconsumed input {shape_set_json}") parts.shape_sets[norm] = shapes if donor != "": parts._donor_cache[norm] = donor if json_dict: raise ValueError(f"Unconsumed input {json_dict}") return parts @classmethod def loadjson(cls, input_file: Path) -> "ReusableParts": ext = input_file.suffix.lower() if ext != ".json": raise ValueError(f"Unknown format {input_file}") return cls.from_json(input_file.read_text(encoding="utf-8")) nanoemoji-0.16.0/src/nanoemoji/png.py000066400000000000000000000027601523660054300174720ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from io import BytesIO import os from pathlib import Path from PIL import Image from typing import Tuple, Union class PNG(bytes): # The first eight bytes of a PNG file always contain the following (decimal) values: # 137 80 78 71 13 10 26 10 # https://www.w3.org/TR/PNG-Structure.html SIGNATURE = b"\x89PNG\r\n\x1a\n" def __new__(cls, *args, **kwargs): self = super().__new__(cls, *args, **kwargs) header = self[:8] if header != cls.SIGNATURE: raise ValueError("Invalid PNG file: bad signature {header!r}") self._size = None return self @property def size(self) -> Tuple[int, int]: if self._size is None: with Image.open(BytesIO(self)) as image: self._size = image.size return self._size @classmethod def read_from(cls, path: Union[str, os.PathLike]) -> "PNG": return cls(Path(path).read_bytes()) nanoemoji-0.16.0/src/nanoemoji/pngquant.py000066400000000000000000000040021523660054300205320ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Wrapper around pngquant command. This runs the pnquant command as a subprocess, forwarding all the unparsed options after '--', while also handling two special error codes 98 or 99 by simply copying the input file to the output file. Pngquant exits with 98 when the conversion results in a larger file than the original; and with 99 when the conversion results in quality below the requested minimum. Usage: $ python -m nanoemoji.pngquant -i INPUT -o OUTPUT -- [PNGQUANTFLAGS] """ from absl import app from absl import flags import shutil import subprocess FLAGS = flags.FLAGS flags.DEFINE_string("input_file", None, "Input filename", short_name="i") flags.DEFINE_string("output_file", None, "Output filename", short_name="o") def main(argv): pngquant = shutil.which("pngquant") if pngquant is None: raise RuntimeError( "'pngquant' command-line tool not found on $PATH. " "Try `pip install pngquant-cli` or visit https://github.com/kornelski/pngquant." ) pngquant_args = argv[1:] infile = FLAGS.input_file outfile = FLAGS.output_file p = subprocess.run([pngquant, *pngquant_args, "-o", outfile, infile]) err = p.returncode if err in (98, 99): print(f"Reuse {infile}") shutil.copyfile(infile, outfile) err = 0 return err if __name__ == "__main__": flags.mark_flag_as_required("input_file") flags.mark_flag_as_required("output_file") app.run(main) nanoemoji-0.16.0/src/nanoemoji/reorder_glyphs.py000066400000000000000000000224071523660054300217360ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helps with glyph reordering. See https://docs.google.com/document/d/1h9O-C_ndods87uY0QeIIcgAMiX2gDTpvO_IhMJsKAqs for context. """ from fontTools import ttLib from fontTools.ttLib.tables import otBase from fontTools.ttLib.tables import otTables as ot from nanoemoji.util import bfs_base_table, require_fully_loaded, SubTablePath from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Callable, List, Optional _COVERAGE_ATTR = "Coverage" # tables that have one coverage use this name def _sort_by_gid( get_glyph_id: Callable[[str], int], glyphs: List[str], parallel_list: Optional[List[Any]], ): if parallel_list: reordered = sorted( ((g, e) for g, e in zip(glyphs, parallel_list)), key=lambda t: get_glyph_id(t[0]), ) sorted_glyphs, sorted_parallel_list = map(list, zip(*reordered)) parallel_list[:] = sorted_parallel_list else: sorted_glyphs = sorted(glyphs, key=get_glyph_id) glyphs[:] = sorted_glyphs def _get_dotted_attr(value: Any, dotted_attr: str) -> Any: attr_names = dotted_attr.split(".") assert attr_names while attr_names: attr_name = attr_names.pop(0) value = getattr(value, attr_name) return value class ReorderRule(ABC): """A rule to reorder something in a font to match the fonts glyph order.""" @abstractmethod def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: ... @dataclass(frozen=True) class ReorderCoverage(ReorderRule): """Reorder a Coverage table, and optionally a list that is sorted parallel to it.""" # A list that is parallel to Coverage parallel_list_attr: Optional[str] = None coverage_attr: str = _COVERAGE_ATTR def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: coverage = _get_dotted_attr(value, self.coverage_attr) if type(coverage) is not list: # Normal path, process one coverage that might have a parallel list parallel_list = None if self.parallel_list_attr: parallel_list = _get_dotted_attr(value, self.parallel_list_attr) assert ( type(parallel_list) is list ), f"{self.parallel_list_attr} should be a list" assert len(parallel_list) == len(coverage.glyphs), "Nothing makes sense" _sort_by_gid(font.getGlyphID, coverage.glyphs, parallel_list) else: # A few tables have a list of coverage. No parallel list can exist. assert ( not self.parallel_list_attr ), f"Can't have multiple coverage AND a parallel list; {self}" for coverage_entry in coverage: _sort_by_gid(font.getGlyphID, coverage_entry.glyphs, None) @dataclass(frozen=True) class ReorderList(ReorderRule): """Reorder the items within a list to match the updated glyph order. Useful when a list ordered by coverage itself contains something ordered by a gid. For example, the PairSet table of https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-2-pair-adjustment-positioning-subtable. """ list_attr: str key: str def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: lst = _get_dotted_attr(value, self.list_attr) assert isinstance(lst, list), f"{self.list_attr} should be a list" lst.sort(key=lambda v: font.getGlyphID(getattr(v, self.key))) # (Type, Optional Format) => List[ReorderRule] # Encodes the relationships Cosimo identified _REORDER_RULES = { # GPOS (ot.SinglePos, 1): [ReorderCoverage()], (ot.SinglePos, 2): [ReorderCoverage(parallel_list_attr="Value")], (ot.PairPos, 1): [ReorderCoverage(parallel_list_attr="PairSet")], (ot.PairSet, None): [ReorderList("PairValueRecord", key="SecondGlyph")], (ot.PairPos, 2): [ReorderCoverage()], (ot.CursivePos, 1): [ReorderCoverage(parallel_list_attr="EntryExitRecord")], (ot.MarkBasePos, 1): [ ReorderCoverage( coverage_attr="MarkCoverage", parallel_list_attr="MarkArray.MarkRecord" ), ReorderCoverage( coverage_attr="BaseCoverage", parallel_list_attr="BaseArray.BaseRecord" ), ], (ot.MarkLigPos, 1): [ ReorderCoverage( coverage_attr="MarkCoverage", parallel_list_attr="MarkArray.MarkRecord" ), ReorderCoverage( coverage_attr="LigatureCoverage", parallel_list_attr="LigatureArray.LigatureAttach", ), ], (ot.MarkMarkPos, 1): [ ReorderCoverage( coverage_attr="Mark1Coverage", parallel_list_attr="Mark1Array.MarkRecord" ), ReorderCoverage( coverage_attr="Mark2Coverage", parallel_list_attr="Mark2Array.Mark2Record" ), ], (ot.ContextPos, 1): [ReorderCoverage(parallel_list_attr="PosRuleSet")], (ot.ContextPos, 2): [ReorderCoverage()], (ot.ContextPos, 3): [ReorderCoverage()], (ot.ChainContextPos, 1): [ReorderCoverage(parallel_list_attr="ChainPosRuleSet")], (ot.ChainContextPos, 2): [ReorderCoverage()], (ot.ChainContextPos, 3): [ ReorderCoverage(coverage_attr="BacktrackCoverage"), ReorderCoverage(coverage_attr="InputCoverage"), ReorderCoverage(coverage_attr="LookAheadCoverage"), ], # GSUB (ot.ContextSubst, 1): [ReorderCoverage(parallel_list_attr="SubRuleSet")], (ot.ContextSubst, 2): [ReorderCoverage()], (ot.ContextSubst, 3): [ReorderCoverage()], (ot.ChainContextSubst, 1): [ReorderCoverage(parallel_list_attr="ChainSubRuleSet")], (ot.ChainContextSubst, 2): [ReorderCoverage()], (ot.ChainContextSubst, 3): [ ReorderCoverage(coverage_attr="BacktrackCoverage"), ReorderCoverage(coverage_attr="InputCoverage"), ReorderCoverage(coverage_attr="LookAheadCoverage"), ], (ot.ReverseChainSingleSubst, 1): [ ReorderCoverage(parallel_list_attr="Substitute"), ReorderCoverage(coverage_attr="BacktrackCoverage"), ReorderCoverage(coverage_attr="LookAheadCoverage"), ], # GDEF (ot.AttachList, None): [ReorderCoverage(parallel_list_attr="AttachPoint")], (ot.LigCaretList, None): [ReorderCoverage(parallel_list_attr="LigGlyph")], (ot.MarkGlyphSetsDef, None): [ReorderCoverage()], # MATH (ot.MathGlyphInfo, None): [ReorderCoverage(coverage_attr="ExtendedShapeCoverage")], (ot.MathItalicsCorrectionInfo, None): [ ReorderCoverage(parallel_list_attr="ItalicsCorrection") ], (ot.MathTopAccentAttachment, None): [ ReorderCoverage( coverage_attr="TopAccentCoverage", parallel_list_attr="TopAccentAttachment" ) ], (ot.MathKernInfo, None): [ ReorderCoverage( coverage_attr="MathKernCoverage", parallel_list_attr="MathKernInfoRecords" ) ], (ot.MathVariants, None): [ ReorderCoverage( coverage_attr="VertGlyphCoverage", parallel_list_attr="VertGlyphConstruction", ), ReorderCoverage( coverage_attr="HorizGlyphCoverage", parallel_list_attr="HorizGlyphConstruction", ), ], } def _access_path(path: SubTablePath): path_parts = [] for entry in path: path_part = entry.name if entry.index is not None: path_part += f"[{entry.index}]" path_parts.append(path_part) return ".".join(path_parts) def reorder_glyphs(font: ttLib.TTFont, new_glyph_order: List[str]): old_glyph_order = font.getGlyphOrder() if len(new_glyph_order) != len(old_glyph_order): raise ValueError( f"New glyph order contains {len(new_glyph_order)} glyphs, " f"but font has {len(old_glyph_order)} glyphs" ) if set(old_glyph_order) != set(new_glyph_order): raise ValueError( "New glyph order does not contain the same set of glyphs as the font:\n" f"* only in new: {set(new_glyph_order) - set(old_glyph_order)}\n" f"* only in old: {set(old_glyph_order) - set(new_glyph_order)}" ) # Changing the order of glyphs in a TTFont requires that all tables that use # glyph indexes have been fully. # Cf. https://github.com/fonttools/fonttools/issues/2060 require_fully_loaded(font) font.setGlyphOrder(new_glyph_order) coverage_containers = {"GDEF", "GPOS", "GSUB", "MATH"} for tag in coverage_containers: if tag in font.keys(): for path in bfs_base_table(font[tag].table, f'font["{tag}"]'): # print(_access_path(path)) value = path[-1].value reorder_key = (type(value), getattr(value, "Format", None)) for reorder in _REORDER_RULES.get(reorder_key, []): reorder.apply(font, value) nanoemoji-0.16.0/src/nanoemoji/strip_glyph_names.py000066400000000000000000000027701523660054300224360ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Update post to drop glyph names.""" from absl import app from absl import flags from absl import logging from fontTools import ttLib from pathlib import Path FLAGS = flags.FLAGS flags.DEFINE_string( "log_level", "INFO", "The threshold for what messages will be logged. One of DEBUG, INFO, WARN, " "ERROR, or FATAL.", ) def main(argv): logging.set_verbosity(FLAGS.log_level) assert len(argv) == 3, "Expected 2 args, input font and output font" input_file = Path(argv[1]) assert input_file.is_file(), f"No file {input_file}" output_file = Path(argv[2]) assert input_file.resolve() != output_file.resolve() font = ttLib.TTFont(input_file) post = font["post"] post.formatType = 3.0 for attr in ("extraNames", "mapping"): if hasattr(post, attr): delattr(post, attr) post.glyphOrder = None font.save(output_file) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/svg.py000066400000000000000000000731441523660054300175110ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Helps nanoemoji build svg fonts.""" import dataclasses from io import BytesIO from itertools import groupby from fontTools import ttLib from functools import reduce from lxml import etree # pytype: disable=import-error from nanoemoji.colors import Color from nanoemoji.color_glyph import ColorGlyph from nanoemoji.config import FontConfig from nanoemoji.disjoint_set import DisjointSet from nanoemoji.glyph_reuse import GlyphReuseCache, ReuseResult from nanoemoji.paint import ( _BasePaintTransform, CompositeMode, Extend, Paint, PaintSolid, PaintLinearGradient, PaintRadialGradient, PaintGlyph, PaintColrGlyph, PaintComposite, PaintColrLayers, is_transform, ) from picosvg.geometric_types import Rect from nanoemoji.reorder_glyphs import reorder_glyphs from picosvg.svg import to_element, SVG, SVGTraverseContext from picosvg import svg_meta from picosvg.svg_reuse import normalize, affine_between from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath from typing import ( cast, ClassVar, Mapping, MutableMapping, NamedTuple, Optional, Set, Sequence, Tuple, Union, ) # topicosvg()'s default _DEFAULT_ROUND_NDIGITS = 3 class GradientReuseKey(NamedTuple): paint: Paint transform: Affine2D = Affine2D.identity() _GradientPaint = Union[PaintLinearGradient, PaintRadialGradient] @dataclasses.dataclass class ReuseCache: reuse_tolerance: float glyph_cache: GlyphReuseCache glyph_elements: MutableMapping[str, etree.Element] = dataclasses.field( default_factory=dict ) reuse_results: MutableMapping[str, ReuseResult] = dataclasses.field( default_factory=dict ) gradient_ids: MutableMapping[GradientReuseKey, str] = dataclasses.field( default_factory=dict ) # sentinel value to forcibly skip reuse of a glyph (e.g. .notdef), distinct # from None, which means "reuse not found". SKIP_REUSE: ClassVar[ReuseResult] = ReuseResult("", Affine2D.identity()) def add_glyph( self, glyph_name: str, context: SVGTraverseContext, reuse_result: Optional[ReuseResult] = SKIP_REUSE, ): assert glyph_name not in self.glyph_elements, f"Second addition of {glyph_name}" if not isinstance(context.paint, PaintGlyph): raise ValueError(f"Not a PaintGlyph {context}") if reuse_result is not self.SKIP_REUSE: if reuse_result is None: self.glyph_cache.add_glyph(glyph_name, context.paint.glyph) else: self.reuse_results[glyph_name] = reuse_result self.glyph_elements[glyph_name] = to_element(SVGPath(d=context.paint.glyph)) def _ensure_has_id(el: etree.Element): if "id" in el.attrib: return nth_child = 0 prev = el.getprevious() while prev is not None: nth_child += 1 prev = prev.getprevious() el.attrib["id"] = f'{el.getparent().attrib["id"]}::{nth_child}' def _paint_glyph_name(color_glyph: ColorGlyph, nth: int) -> str: return f"{color_glyph.ufo_glyph_name}.{nth}" def _color_glyph_name(glyph_name: str) -> str: return glyph_name[: glyph_name.rindex(".")] def _glyph_groups( config: FontConfig, color_glyphs: Sequence[ColorGlyph], reuse_cache: ReuseCache ) -> Tuple[Tuple[str, ...], ...]: """Find glyphs that need to be kept together by union find.""" # If the color glyphs contain '.notdef', we want to keep it the first glyph, so # we must avoid grouping it with the rest, as they get reshuffled later on. initial_glyphs = () for color_glyph in color_glyphs: if color_glyph.ufo_glyph_name == ".notdef": assert color_glyph.glyph_id == 0 initial_glyphs = ((".notdef",),) break # ensure glyphs sharing shapes are in the same doc reuse_groups = DisjointSet[str]() for color_glyph in color_glyphs: if color_glyph.ufo_glyph_name != ".notdef": reuse_groups.make_set(color_glyph.ufo_glyph_name) nth_paint_glyph = 0 for root in color_glyph.painted_layers: for context in root.breadth_first(): # Group glyphs based on common shapes if not isinstance(context.paint, PaintGlyph): continue glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) # we still need to add the .notdef layers to reuse_cache even if not reused, # as later code expects all glyph elements to be there. if color_glyph.ufo_glyph_name == ".notdef": reuse_cache.add_glyph(glyph_name, context) else: reuse_result = reuse_cache.glyph_cache.try_reuse( context.paint.glyph # pytype: disable=attribute-error ) reuse_cache.add_glyph(glyph_name, context, reuse_result) if reuse_result: # This entire color glyph and the one we share a shape with go in one svg doc reuse_groups.union( color_glyph.ufo_glyph_name, _color_glyph_name(reuse_result.glyph_name), ) nth_paint_glyph += 1 return initial_glyphs + reuse_groups.sorted() def _ntos(n: float) -> str: return svg_meta.ntos(round(n, _DEFAULT_ROUND_NDIGITS)) # https://docs.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics def _svg_matrix(transform: Affine2D) -> str: return transform.round(_DEFAULT_ROUND_NDIGITS).tostring() def _apply_solid_paint(el: etree.Element, paint: PaintSolid): if etree.QName(el.tag).localname == "g": assert paint.color.opaque() == Color.fromstring( "black" ), "Unexpected color choice" # Always set fill explicitly, even for black (the SVG default). # This works around a Firefox rendering bug: # https://github.com/googlefonts/noto-emoji/issues/480 # https://bugzilla.mozilla.org/show_bug.cgi?id=1908302 el.attrib["fill"] = paint.color.opaque().to_string() if paint.color.alpha != 1.0: el.attrib["opacity"] = _ntos(paint.color.alpha) def _apply_gradient_paint( svg_defs: etree.Element, svg_path: etree.Element, paint: _GradientPaint, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): if reuse_cache is None: grad_id = _define_gradient(svg_defs, paint, transform) else: # Gradients can be reused by multiple glyphs in the same OT-SVG document, # provided paints are the same and have the same transform. # Pre-apply transform to gradient's geometry and round both to # normalize them before adding to the cache, thus increasing the chance # of a reuse. if not transform.almost_equals(Affine2D.identity()): paint = paint.apply_transform(transform, check_overflows=False) transform = Affine2D.identity() if is_transform(paint): paint = cast(_BasePaintTransform, paint) transform, paint = paint.gettransform(), paint.paint paint = cast(_GradientPaint, paint) paint = paint.round(_DEFAULT_ROUND_NDIGITS) transform = transform.round(_DEFAULT_ROUND_NDIGITS) reuse_key = GradientReuseKey(paint, transform) grad_id = reuse_cache.gradient_ids.get(reuse_key) if grad_id is None: grad_id = _define_gradient(svg_defs, paint, transform) reuse_cache.gradient_ids[reuse_key] = grad_id svg_path.attrib["fill"] = f"url(#{grad_id})" def _define_gradient( svg_defs: etree.Element, paint: _GradientPaint, transform: Affine2D = Affine2D.identity(), ) -> str: if isinstance(paint, PaintLinearGradient): return _define_linear_gradient(svg_defs, paint, transform) elif isinstance(paint, PaintRadialGradient): return _define_radial_gradient(svg_defs, paint, transform) else: raise TypeError(type(paint)) def _apply_gradient_common_parts( gradient: etree.Element, paint: _GradientPaint, transform: Affine2D = Affine2D.identity(), ): gradient.attrib["gradientUnits"] = "userSpaceOnUse" for stop in paint.stops: stop_el = etree.SubElement(gradient, "stop") stop_el.attrib["offset"] = _ntos(stop.stopOffset) stop_el.attrib["stop-color"] = stop.color.opaque().to_string() if stop.color.alpha != 1.0: stop_el.attrib["stop-opacity"] = _ntos(stop.color.alpha) if paint.extend != Extend.PAD: gradient.attrib["spreadMethod"] = paint.extend.name.lower() transform = transform.round(_DEFAULT_ROUND_NDIGITS) if transform != Affine2D.identity(): # Safari has a bug which makes it reject a gradient if gradientTransform # contains an 'involutory matrix' (i.e. matrix whose inverse equals itself, # such that M @ M == Identity, e.g. reflection), hence the following hack: # https://github.com/googlefonts/nanoemoji/issues/268 # https://en.wikipedia.org/wiki/Involutory_matrix # TODO: Remove once the bug gets fixed if transform @ transform == Affine2D.identity(): transform = transform._replace(a=transform.a + 0.00001) assert transform.inverse() != transform gradient.attrib["gradientTransform"] = transform.tostring() def _define_linear_gradient( svg_defs: etree.Element, paint: PaintLinearGradient, transform: Affine2D = Affine2D.identity(), ) -> str: gradient = etree.SubElement(svg_defs, "linearGradient") gradient_id = gradient.attrib["id"] = f"g{len(svg_defs)}" p0, p1, p2 = paint.p0, paint.p1, paint.p2 # P2 allows to rotate the linear gradient independently of the end points P0 and P1. # Below we compute P3 which is the orthogonal projection of P1 onto a line passing # through P0 and perpendicular to the "normal" or "rotation vector" from P0 and P2. # The vector P3-P0 is the "effective" linear gradient vector after this rotation. # When vector P2-P0 is perpendicular to the gradient vector P1-P0, then P3 # (projection of P1 onto perpendicular to normal) is == P1 itself thus no rotation. # When P2 is collinear to the P1-P0 gradient vector, then this projected P3 == P0 # and the gradient degenerates to a solid paint (the last color stop). p3 = p0 + (p1 - p0).projection((p2 - p0).perpendicular()) x1, y1 = p0 x2, y2 = p3 gradient.attrib["x1"] = _ntos(x1) gradient.attrib["y1"] = _ntos(y1) gradient.attrib["x2"] = _ntos(x2) gradient.attrib["y2"] = _ntos(y2) _apply_gradient_common_parts(gradient, paint, transform) return gradient_id def _define_radial_gradient( svg_defs: etree.Element, paint: PaintRadialGradient, transform: Affine2D = Affine2D.identity(), ) -> str: gradient = etree.SubElement(svg_defs, "radialGradient") gradient_id = gradient.attrib["id"] = f"g{len(svg_defs)}" if paint.c0 != paint.c1: fx, fy = paint.c0 gradient.attrib["fx"] = _ntos(fx) gradient.attrib["fy"] = _ntos(fy) if paint.r0 != 0: gradient.attrib["fr"] = _ntos(paint.r0) cx, cy = paint.c1 gradient.attrib["cx"] = _ntos(cx) gradient.attrib["cy"] = _ntos(cy) gradient.attrib["r"] = _ntos(paint.r1) _apply_gradient_common_parts(gradient, paint, transform) return gradient_id def _map_gradient_coordinates( paint: _GradientPaint, affine: Affine2D ) -> _GradientPaint: if isinstance(paint, PaintLinearGradient): return dataclasses.replace( paint, p0=affine.map_point(paint.p0), p1=affine.map_point(paint.p1), p2=affine.map_point(paint.p2), ) elif isinstance(paint, PaintRadialGradient): scalex, scaley = affine.getscale() if not scalex or abs(scalex) != abs(scaley): raise ValueError( f"Expected uniform scale and/or translate, found: {affine}" ) return dataclasses.replace( paint, c0=affine.map_point(paint.c0), c1=affine.map_point(paint.c1), r0=affine.map_vector((paint.r0, 0)).x, r1=affine.map_vector((paint.r1, 0)).x, ) raise TypeError(type(paint)) def _is_svg_supported_composite(paint: Paint) -> bool: # Only simple group opacity for now because that's all we produce in color_glyph.py return ( isinstance(paint, PaintComposite) and paint.mode == CompositeMode.SRC_IN and isinstance(paint.backdrop, PaintSolid) ) _XLINK_HREF_ATTR_NAME = f"{{{svg_meta.xlinkns()}}}href" _PAINT_ATTRIB_APPLY_PAINT_MAY_SET = {"fill", "opacity"} def _apply_paint( svg_defs: etree.Element, el: etree.Element, paint: Paint, upem_to_vbox: Affine2D, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): # If you modify the attributes _apply_paint can set also modify _PAINT_ATTRIB_APPLY_PAINT_MAY_SET if isinstance(paint, PaintSolid): _apply_solid_paint(el, paint) elif isinstance(paint, (PaintLinearGradient, PaintRadialGradient)): # Gradient paint coordinates are in UPEM space, we want them in SVG viewBox # so that they match the SVGPath.d coordinates (that we copy unmodified). paint = _map_gradient_coordinates(paint, upem_to_vbox) # Likewise transforms refer to UPEM so they must be adjusted for SVG if transform != Affine2D.identity(): transform = Affine2D.compose_ltr( (upem_to_vbox.inverse(), transform, upem_to_vbox) ) _apply_gradient_paint(svg_defs, el, paint, reuse_cache, transform) elif is_transform(paint): transform @= paint.gettransform() child = paint.paint # pytype: disable=attribute-error _apply_paint(svg_defs, el, child, upem_to_vbox, reuse_cache, transform) else: raise NotImplementedError(type(paint)) def _attrib_apply_paint_uses(el: etree.Element) -> Set[str]: # Per https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use # only a few attributes of target can be overriden by . However, we need # only concern ourselves with the subset of those that _apply_paint might set. attrs = set(el.attrib) & _PAINT_ATTRIB_APPLY_PAINT_MAY_SET # Don't treat fill="black" as needing override since that's the SVG default if attrs == {"fill"} and el.attrib.get("fill") == "black": attrs = set() return attrs def _migrate_to_defs( svg: SVG, reused_el: etree.Element, reuse_cache: ReuseCache, reuse_result: ReuseResult, ): svg_defs = svg.xpath_one("//svg:defs") if reused_el in svg_defs: return # nop tag = etree.QName(reused_el.tag).localname assert tag == "path", f"expected 'path', found '{tag}'" svg_use = etree.Element("use", nsmap=svg.svg_root.nsmap) svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{reuse_result.glyph_name}" # if reused_el hasn't been given a parent yet just let the replace it # otherwise move it from current to new parent if reused_el.getparent() is None: reuse_cache.glyph_elements[reuse_result.glyph_name] = svg_use else: reused_el.addnext(svg_use) svg_defs.append(reused_el) # append moves # sorted for diff stability for attr_name in sorted(_attrib_apply_paint_uses(reused_el)): svg_use.attrib[attr_name] = reused_el.attrib.pop(attr_name) return svg_use def _create_use_element( svg: SVG, parent_el: etree.Element, reuse_result: ReuseResult ) -> etree.Element: svg_use = etree.SubElement(parent_el, "use", nsmap=svg.svg_root.nsmap) svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{reuse_result.glyph_name}" transform = reuse_result.transform tx, ty = transform.gettranslate() if tx: svg_use.attrib["x"] = _ntos(tx) if ty: svg_use.attrib["y"] = _ntos(ty) transform = transform.translate(-tx, -ty) if transform != Affine2D.identity(): svg_use.attrib["transform"] = _svg_matrix(transform) return svg_use def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): svg_defs = svg.xpath_one("//svg:defs") # each glyph gets a group of its very own svg_g = svg.append_to("/svg:svg", etree.Element("g")) svg_g.attrib["id"] = f"glyph{color_glyph.glyph_id}" view_box = color_glyph.svg.view_box() if view_box is None: raise ValueError(f"{color_glyph.svg_filename} must declare view box") # https://github.com/googlefonts/nanoemoji/issues/58: group needs transform transform = color_glyph.transform_for_otsvg_space() if not transform.almost_equals(Affine2D.identity()): svg_g.attrib["transform"] = _svg_matrix(transform) vbox_to_upem = color_glyph.transform_for_font_space() upem_to_vbox = vbox_to_upem.inverse() # copy the shapes into our svg el_by_path = {(): svg_g} complete_paths = set() nth_paint_glyph = 0 for root in color_glyph.painted_layers: for context in root.breadth_first(): if any(c == context.path[: len(c)] for c in complete_paths): continue parent_el = svg_g path = context.path while path: if path in el_by_path: parent_el = el_by_path[path] break path = path[:-1] if isinstance(context.paint, PaintGlyph): glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) assert ( glyph_name in reuse_cache.glyph_elements ), f"Missing entry for {glyph_name}" reuse_result = reuse_cache.reuse_results.get(glyph_name, None) if reuse_result: reused_glyph_name = reuse_result.glyph_name reused_el = reuse_cache.glyph_elements[reused_glyph_name] reused_el_tag = etree.QName(reused_el.tag).localname if reused_el_tag == "use": # if reused_el is a it means _migrate_to_defs has already # replaced a parent-less with a pointing to it, and # has appended the reused path to . Assert that's the case assert _use_href(reused_el) == reused_glyph_name reused_el = svg.xpath_one( f'//svg:defs/svg:path[@id="{reused_glyph_name}"]', ) elif reused_el_tag == "path": # we need to refer to you, it's important you have identity reused_el.attrib["id"] = reused_glyph_name else: raise AssertionError(reused_el_tag) svg_use = _create_use_element(svg, parent_el, reuse_result) # We must apply the inverse of the reuse transform to the children # paints to discount its effect on them, since these refer to the # original pre-reuse paths. _apply_paint expects 'transform' to be # in UPEM space, whereas reuse_result.transform is in SVG space, so # we remap the (inverse of the) latter from SVG to UPEM. inverse_reuse_transform = Affine2D.compose_ltr( ( upem_to_vbox, reuse_result.transform.inverse(), upem_to_vbox.inverse(), ) ) _apply_paint( svg_defs, svg_use, context.paint.paint, # pytype: disable=attribute-error upem_to_vbox, reuse_cache, inverse_reuse_transform, ) # In two cases, we need to push the reused element to the outer # and replace its first occurence with a : # 1) If reuse spans multiple glyphs, as Adobe Illustrator # doesn't support direct references between glyphs: # https://github.com/googlefonts/nanoemoji/issues/264 # 2) If the reused_el has attributes cannot override # https://github.com/googlefonts/nanoemoji/issues/337 if color_glyph.ufo_glyph_name != _color_glyph_name( reused_glyph_name ) or _attrib_apply_paint_uses(reused_el): _migrate_to_defs(svg, reused_el, reuse_cache, reuse_result) else: el = reuse_cache.glyph_elements[glyph_name] _apply_paint( svg_defs, el, context.paint.paint, # pytype: disable=attribute-error upem_to_vbox, reuse_cache, ) parent_el.append(el) # pytype: disable=attribute-error # don't update el_by_path because we're declaring this path complete complete_paths.add(context.path + (context.paint,)) nth_paint_glyph += 1 elif isinstance(context.paint, PaintColrLayers): pass elif isinstance(context.paint, PaintSolid): _apply_solid_paint(parent_el, context.paint) elif _is_svg_supported_composite(context.paint): el = etree.SubElement(parent_el, f"{{{svg_meta.svgns()}}}g") el_by_path[context.path + (context.paint,)] = el # TODO: support transform types, either by introducing or by applying context.transform to Paint else: raise ValueError(f"What do we do with {context}") def _ensure_ttfont_fully_decompiled(ttfont: ttLib.TTFont): # A TTFont might be opened lazily and some tables only partially decompiled. # So for this to work on any TTFont, we first compile everything to a temporary # stream then reload with lazy=False. Input font is modified in-place. tmp = BytesIO() ttfont.save(tmp) tmp.seek(0) ttfont2 = ttLib.TTFont(tmp, lazy=False) for tag in ttfont2.keys(): table = ttfont2[tag] # cmap is exceptional in that it always loads subtables lazily upon getting # their attributes, no matter the value of TTFont.lazy option. # TODO: remove this hack once fixed in fonttools upstream if tag == "cmap": _ = [st.cmap for st in table.tables] ttfont[tag] = table def _ensure_groups_grouped_in_glyph_order( color_glyphs: MutableMapping[str, ColorGlyph], ttfont: ttLib.TTFont, reuse_groups: Tuple[Tuple[str, ...]], ): # svg requires glyphs in same doc have sequential gids; reshuffle to make this true. # We kept glyph names stable when saving a font for svg so it's safe to match on assert ttfont["post"].formatType == 2, "glyph names need to be stable" # .notdef must always be the first glyph in the font or Bad Things happen old_glyph_order = ttfont.getGlyphOrder() assert old_glyph_order[0] == ".notdef", f"1st glyph not named '.notdef'" if ".notdef" in color_glyphs: first_group, *reuse_groups = reuse_groups assert first_group == (".notdef",) assert color_glyphs[".notdef"].glyph_id == 0 # everything that *isn't* shuffling group_glyphs = reduce(lambda a, c: a | set(c), reuse_groups, set()) glyph_order = [g for g in old_glyph_order if g not in group_glyphs] # plus everything that is shuffling, in the order it needs to stay in # update color glyph gid as we go gid = len(glyph_order) for group in reuse_groups: for glyph_name in group: glyph_order.append(glyph_name) color_glyphs[glyph_name] = color_glyphs[glyph_name]._replace(glyph_id=gid) gid += 1 assert len(glyph_order) == len(set(glyph_order)), ", ".join(glyph_order) assert len(ttfont.getGlyphOrder()) == len(glyph_order) and set( ttfont.getGlyphOrder() ) == set( glyph_order ), f"lhs only {set(ttfont.getGlyphOrder()) - set(glyph_order)} rhs only {set(glyph_order) - set(ttfont.getGlyphOrder())}" reorder_glyphs(ttfont, glyph_order) def _use_href(use_el): ref = use_el.attrib[_XLINK_HREF_ATTR_NAME] assert ref.startswith("#"), f"Only use #fragment supported, reject {ref}" return ref[1:] def _tidy_use_elements(svg: SVG): use_els = sorted(svg.xpath("//use"), key=_use_href) targets = {} for use_el in use_els: ref = _use_href(use_el) reused_el = svg.xpath_one(f'//svg:*[@id="{ref}"]') targets[ref] = reused_el duplicate_attrs = { attr_name for attr_name, attr_value in use_el.attrib.items() if attr_name in reused_el.attrib and attr_value == reused_el.attrib.get(attr_name) } for duplicate_attr in duplicate_attrs: del use_el.attrib[duplicate_attr] # If all have the same paint attr migrate it from use to target for ref, uses in groupby(use_els, key=_use_href): uses = list(uses) target = targets[ref] for attr_name in sorted(_PAINT_ATTRIB_APPLY_PAINT_MAY_SET): values = [use.attrib[attr_name] for use in uses if attr_name in use.attrib] unique_values = set(values) if len(values) == len(uses) and len(unique_values) == 1: target.attrib[attr_name] = values[0] for use in uses: del use.attrib[attr_name] def _picosvg_docs( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph] ) -> Sequence[Tuple[str, int, int]]: reuse_cache = ReuseCache( config.reuse_tolerance, GlyphReuseCache(config.reuse_tolerance) ) reuse_groups = _glyph_groups(config, color_glyphs, reuse_cache) color_glyphs = {c.ufo_glyph_name: c for c in color_glyphs} _ensure_groups_grouped_in_glyph_order(color_glyphs, ttfont, reuse_groups) doc_list = [] for group in reuse_groups: reuse_cache.gradient_ids = {} # don't share gradients across groups # establish base svg, defs root = etree.Element( f"{{{svg_meta.svgns()}}}svg", {"version": "1.1"}, nsmap={None: svg_meta.svgns(), "xlink": svg_meta.xlinkns()}, ) defs = etree.SubElement(root, f"{{{svg_meta.svgns()}}}defs", nsmap=root.nsmap) svg = SVG(root) for color_glyph in (color_glyphs[g] for g in group): if color_glyph.painted_layers: _add_glyph(svg, color_glyph, reuse_cache) # tidy use elements, they may emerge from _add_glyph with unnecessary attributes _tidy_use_elements(svg) # sort by @id to increase diff stability defs[:] = sorted(defs, key=lambda e: e.attrib["id"]) # strip if empty if len(defs) == 0: root.remove(defs) if len(root) == 0: continue gids = tuple(color_glyphs[g].glyph_id for g in group) doc_list.append( (svg.tostring(pretty_print=config.pretty_print), min(gids), max(gids)) ) return doc_list def _rawsvg_docs( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph] ) -> Sequence[Tuple[str, int, int]]: doc_list = [] for color_glyph in color_glyphs: svg = ( # all the scaling and positioning happens in "transform" below color_glyph.svg.remove_attributes(("width", "height", "viewBox")) # Firefox likes to render blank if present .remove_attributes(("enable-background",), inplace=True) ) g = etree.Element( "g", { # Map gid => svg doc "id": f"glyph{color_glyph.glyph_id}", # map viewBox to OT-SVG space (+x,-y) "transform": _svg_matrix(color_glyph.transform_for_otsvg_space()), }, ) # move all the elements under the new group g.extend(svg.svg_root) svg.svg_root.append(g) doc_list.append( ( svg.tostring(pretty_print=config.pretty_print), color_glyph.glyph_id, color_glyph.glyph_id, ) ) return doc_list def make_svg_table( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph], picosvg: bool, compressed: bool = False, ): """Build an SVG table optimizing for reuse of shapes. Reuse here requires putting shapes into a single svg doc. Use of large svg docs will come at runtime cost. A better implementation would also consider usage frequency and avoid taking reuse opportunities in some cases. For example, even the most and least popular glyphs share shapes we might choose to not take advantage of it. """ if picosvg: doc_list = _picosvg_docs(config, ttfont, color_glyphs) else: doc_list = _rawsvg_docs(config, ttfont, color_glyphs) svg_table = ttLib.newTable("SVG ") svg_table.compressed = compressed svg_table.docList = doc_list ttfont[svg_table.tableTag] = svg_table nanoemoji-0.16.0/src/nanoemoji/svg_path.py000066400000000000000000000070521523660054300205200ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from typing import Any, Mapping, Optional import itertools from fontTools.pens.basePen import AbstractPen, DecomposingPen from fontTools.pens.transformPen import TransformPen import pathops from picosvg.svg_types import SVGPath from picosvg.svg_transform import Affine2D _SVG_CMD_TO_PEN_METHOD = { "M": "moveTo", "L": "lineTo", "C": "curveTo", "Q": "qCurveTo", "Z": "closePath", } def draw_svg_path( path: SVGPath, pen: AbstractPen, transform: Optional[Affine2D] = None, close_subpaths: bool = False, ): """Draw SVGPath using a FontTools Segment Pen.""" if transform is not None: pen = TransformPen(pen, transform) # In SVG sub-paths are implicitly open when they don't end with "Z"; in FT pens # the end of each sub-path must be marked explicitly with either pen.endPath() # for open paths or closePath() for closed ones. closed = True for cmd, args in path.as_cmd_seq(): if cmd == "M": if not closed: if close_subpaths: pen.closePath() else: pen.endPath() closed = False # pens expect args as 2-tuples; we use the 'grouper' itertools recipe # https://docs.python.org/3.8/library/itertools.html#recipes assert len(args) % 2 == 0 points = itertools.zip_longest(*([iter(args)] * 2)) getattr(pen, _SVG_CMD_TO_PEN_METHOD[cmd])(*points) if cmd == "Z": closed = True if not closed: if close_subpaths: pen.closePath() else: pen.endPath() class SVGPathPen(DecomposingPen): """A FontTools Pen that draws onto a picosvg SVGPath. The pen automatically decomposes components using the provided `glyphSet` mapping. Args: glyphSet: a mapping of {glyph_name: glyph} to be used for resolving component references when the pen's `addComponent` method is called. path: an existing SVGPath to extend with drawing commands. If None, a new SVGPath is created by default, accessible with the `path` attribute. """ # addComponent will raise KeyError on missing reference, instead of just warning skipMissingComponents = False def __init__( self, glyphSet: Optional[Mapping[str, Any]] = None, path: Optional[SVGPath] = None, ): DecomposingPen.__init__(self, glyphSet or {}) self.path = path or SVGPath() def moveTo(self, pt): self.path.M(*pt) def lineTo(self, pt): self.path.L(*pt) def curveTo(self, *points): # flatten sequence of point tuples self.path.C(*(v for pt in points for v in pt)) def qCurveTo(self, *points): # handle TrueType quadratic splines with implicit on-curve mid-points for control_pt, end_pt in pathops.decompose_quadratic_segment(points): self.path.Q(*control_pt, *end_pt) def closePath(self): self.path.end() def endPath(self): pass nanoemoji-0.16.0/src/nanoemoji/util.py000066400000000000000000000157211523660054300176640ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Small helper functions.""" from collections import deque import contextlib from fontTools.ttLib.tables import otBase from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables import otConverters from fontTools import ttLib from functools import partial from io import BytesIO import os from pathlib import Path import shlex import subprocess import sys from typing import Any, Callable, Deque, Iterable, List, NamedTuple, Tuple, Union def only(iterable, filter_fn=lambda v: v): it = filter(filter_fn, iterable) result = next(it) assert next(it, None) is None return result def expand_ninja_response_files(argv: List[str]) -> List[str]: """ Extend argument list with MSVC-style '@'-prefixed response files. Ninja build rules support this mechanism to allow passing a very long list of inputs that may exceed the shell's maximum command-line length. References: https://ninja-build.org/manual.html ("Rule variables") https://docs.microsoft.com/en-us/cpp/build/reference/at-specify-a-compiler-response-file """ result = [] for arg in argv: if arg.startswith("@"): with open(arg[1:], "r") as rspfile: rspfile_content = rspfile.read() result.extend(shell_split(rspfile_content)) else: result.append(arg) return result def fs_root() -> Path: return Path("/").resolve() def rel(from_path: Path, to_path: Path) -> Path: # relative_to(A,B) doesn't like it if B doesn't start with A abs_from_path = abspath(from_path) abs_to_path = abspath(to_path) if abs_from_path.drive != abs_to_path.drive: # On Windows, we can't resolve relative paths across drive mount points. # We must return the absolute path in this case, or else we get: # ValueError: path is on mount 'D:', start on mount 'C:' return abs_to_path return Path(os.path.relpath(abs_to_path, abs_from_path)) def abspath(path: Path) -> Path: # pathlib.Path.absolute() doesn't do path normalization, whereas Path.resolve() # does normalization but also resolves symlinks which sometimes we don't want to # so here we use good ol' os.path.abspath. return Path(os.path.abspath(path)) @contextlib.contextmanager def file_printer(filename): if filename == "-": # conventionally means print to stdout yield print else: with open(filename, "w") as f: yield partial(print, file=f) def require_fully_loaded(font: ttLib.TTFont): not_loaded = sorted(t for t in font.keys() if not font.isLoaded(t)) if not_loaded: raise ValueError(f"Everything should be loaded, following aren't: {not_loaded}") def _reload(font: ttLib.TTFont, lazy: bool = True): # Stream font to memory and load it back again tmp = BytesIO() font.save(tmp) tmp.seek(0) return ttLib.TTFont(tmp, lazy=lazy) def load_fully(font: Union[Path, ttLib.TTFont]) -> ttLib.TTFont: if isinstance(font, Path): font = ttLib.TTFont(str(font), lazy=False) else: # A TTFont might be opened lazily and some tables only partially decompiled. # If so, reload it if font.lazy is not False: font = _reload(font, lazy=False) font.ensureDecompiled() # Do what you thought lazy=False meant require_fully_loaded(font) return font SubTablePath = Tuple[otBase.BaseTable.SubTableEntry, ...] # Given f(current frontier, new entries) add new entries to frontier AddToFrontierFn = Callable[[Deque[SubTablePath], List[SubTablePath]], None] def dfs_base_table( root: otBase.BaseTable, root_accessor: str ) -> Iterable[SubTablePath]: yield from _traverse_ot_data( root, root_accessor, lambda frontier, new: frontier.extendleft(reversed(new)) ) def bfs_base_table( root: otBase.BaseTable, root_accessor: str ) -> Iterable[SubTablePath]: yield from _traverse_ot_data( root, root_accessor, lambda frontier, new: frontier.extend(new) ) def _traverse_ot_data( root: otBase.BaseTable, root_accessor: str, add_to_frontier_fn: AddToFrontierFn ) -> Iterable[SubTablePath]: # no visited because general otData is forward-offset only and thus cannot cycle frontier: Deque[SubTablePath] = deque() frontier.append((otBase.BaseTable.SubTableEntry(root_accessor, root),)) while frontier: # path is (value, attr_name) tuples. attr_name is attr of parent to get value path = frontier.popleft() current = path[-1].value yield path new_entries = [] for subtable_entry in current.iterSubTables(): new_entries.append(path + (subtable_entry,)) add_to_frontier_fn(frontier, new_entries) def shell_quote(s: Union[str, Path]) -> str: """Quote a string or pathlib.Path for use in a shell command.""" s = str(s) # shlex.quote() is POSIX-only, for Windows we use subprocess.list2cmdline() # which converts a list of args to a command line string following the # the MS C runtime rules. if sys.platform.startswith("win"): return subprocess.list2cmdline([s]) else: return shlex.quote(s) # Python has no cmdline2list() equivalent to list2cmdline(), so we resort to # using the MS C runtime's CommandLineToArgvW() function via ctypes. if sys.platform.startswith("win"): from ctypes import POINTER, byref, c_int, windll # type: ignore from ctypes.wintypes import LPCWSTR, LPWSTR, HLOCAL # type: ignore CommandLineToArgvW = windll.shell32.CommandLineToArgvW CommandLineToArgvW.argtypes = [LPCWSTR, POINTER(c_int)] CommandLineToArgvW.restype = POINTER(LPWSTR) LocalFree = windll.kernel32.LocalFree LocalFree.argtypes = [HLOCAL] LocalFree.restype = HLOCAL def shell_split(s: str) -> List[str]: """Split a shell command line into a list of arguments.""" argc = c_int(0) # we don't care about argv[0] which is the program name cmdline = "foobar.exe " + s argv = CommandLineToArgvW(cmdline, byref(argc)) result = [argv[i] for i in range(1, argc.value)] LocalFree(argv) return result else: def shell_split(s: str) -> List[str]: """Split a shell command line into a list of arguments.""" return shlex.split(s, posix=os.name == "posix") def quote_if_path(s: Union[str, Path]) -> str: """Quote pathlib.Path for use in a shell command, keep str as-is.""" return shell_quote(s) if isinstance(s, Path) else s nanoemoji-0.16.0/src/nanoemoji/write_combined_part_files.py000066400000000000000000000030221523660054300241000ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Combines N part files to 1""" from absl import app from absl import flags from nanoemoji.parts import ReusableParts from nanoemoji import util from pathlib import Path FLAGS = flags.FLAGS def main(argv): input_files = util.expand_ninja_response_files(argv[1:]) combined_parts = ReusableParts() individual_parts = [ReusableParts.loadjson(Path(p)) for p in input_files] if individual_parts: combined_parts.version = util.only({p.version for p in individual_parts}) combined_parts.reuse_tolerance = util.only( {p.reuse_tolerance for p in individual_parts} ) combined_parts.view_box = util.only({p.view_box for p in individual_parts}) for parts in individual_parts: combined_parts.add(parts) combined_parts.compute_donors() # precompute for later use with util.file_printer(FLAGS.output_file) as print: print(combined_parts.to_json()) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_config_for_mergeable.py000066400000000000000000000033441523660054300242350ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generates a glyphmap for svgs named by glyph id.""" from absl import app from absl import flags from fontTools import ttLib from nanoemoji import config from nanoemoji import util from pathlib import Path import textwrap FLAGS = flags.FLAGS def main(argv): font_file = util.only(argv, lambda a: a.endswith(".ttf")) config_file = Path(util.only(argv, lambda a: a.endswith(".toml"))) font = ttLib.TTFont(font_file) upem = font["head"].unitsPerEm ascender = font["OS/2"].sTypoAscender descender = font["OS/2"].sTypoDescender with open(config_file, "w") as f: f.write(textwrap.dedent(f""" output_file = "COLR.ttf" color_format = "{FLAGS.color_format}" upem = {upem} width = 0 # from input width ascender = {ascender} descender = {descender} keep_glyph_names = true fea_file = "" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" [master.regular.position] wght = 400 """)) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_diffreport.py000066400000000000000000000065321523660054300222650ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Writes a simple html report on a set of image diffs.""" from absl import app from absl import flags from absl import logging import functools from PIL import Image, ImageChops, ImageStat from pathlib import Path from textwrap import dedent from nanoemoji import util FLAGS = flags.FLAGS flags.DEFINE_string("lhs_dir", None, "Directory with lhs images for diffs.") flags.DEFINE_string("rhs_dir", None, "Directory with rhs images for diffs.") flags.DEFINE_string("output_file", None, "Output filename.") flags.DEFINE_integer( "report_max_entries", 128, "Show the worst (biggest diff) N emoji." ) def _diff_value(diff_file): return sum(ImageStat.Stat(Image.open(diff_file)).sum2) def _lhs(diff_file): return Path(FLAGS.lhs_dir) / diff_file.name def _rhs(diff_file): return Path(FLAGS.rhs_dir) / diff_file.name def main(argv): diff_files = ( Path(diff_file) for diff_file in util.expand_ninja_response_files(argv[1:]) ) diff_files = sorted(diff_files, key=_diff_value, reverse=True) with open(FLAGS.output_file, "w") as f: f.write(dedent("""
SVG Raw Diff Pink Diff COLRv1
""")) for diff_file in diff_files[: FLAGS.report_max_entries]: logging.info("%s %s", diff_file.name, _diff_value(diff_file)) pink_diff = diff_file.parent / (diff_file.stem + ".pink" + diff_file.suffix) vars = { "lhs_file": str(_lhs(diff_file)), "rhs_file": str(_rhs(diff_file)), "diff_file1": str(diff_file), "diff_file2": str(pink_diff), "filename": diff_file.name, } f.write(dedent("""
{filename}
""".format(**vars))) f.write(dedent(""" """)) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_fea.py000066400000000000000000000024571523660054300206560ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Writes a fea file for ligature lookup for multi-codepoint RGIs""" # TODO if this is a qualified sequence create the unqualified version and vice versa from absl import app from absl import flags from nanoemoji import glyphmap from nanoemoji import features from nanoemoji import util FLAGS = flags.FLAGS flags.DEFINE_string("output_file", "-", "Output filename ('-' means stdout)") def main(argv): with util.file_printer(FLAGS.output_file) as print: sequences = sorted( { gm.codepoints for gm in glyphmap.parse_csv(argv[1]) if len(gm.codepoints) > 1 } ) print(features.generate_fea(sequences)) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_font.py000066400000000000000000000700661523660054300210720ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Writes UFO and/or font files.""" from absl import app from absl import flags from absl import logging from collections import Counter import csv import dataclasses import enum import math from fontTools import ttLib from fontTools.misc.arrayTools import rectArea, normRect, unionRect from fontTools.misc.roundTools import otRound from fontTools.ttLib.tables import otTables as ot from fontTools.pens.boundsPen import ControlBoundsPen from fontTools.pens.transformPen import TransformPen from itertools import chain from lxml import etree # pytype: disable=import-error from nanoemoji.bitmap_tables import make_cbdt_table, make_sbix_table from nanoemoji import codepoints, config, glyphmap from nanoemoji.colors import Color, uniq_sort_cpal_colors from nanoemoji.config import FontConfig from nanoemoji.color_glyph import ColorGlyph from nanoemoji.fixed import fixed_safe from nanoemoji.glyph import glyph_name from nanoemoji.glyphmap import GlyphMapping from nanoemoji.glyph_reuse import GlyphReuseCache from nanoemoji.paint import ( is_gradient, is_transform, transformed, CompositeMode, Paint, PaintComposite, PaintColrGlyph, PaintGlyph, PaintSolid, ) from nanoemoji.parts import ReusableParts from nanoemoji.png import PNG from nanoemoji.svg import make_svg_table from nanoemoji.svg_path import draw_svg_path from nanoemoji import util import os from pathlib import Path import ufoLib2 from ufo2ft.outlineCompiler import StubGlyph from picosvg.svg import SVG from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath import regex import sys from typing import ( cast, Any, Callable, Generator, Iterable, Mapping, MutableSequence, NamedTuple, Optional, Sequence, Tuple, ) from ufoLib2.objects import Component, Glyph import ufo2ft FLAGS = flags.FLAGS flags.DEFINE_string("config_file", None, "Config filename.") flags.DEFINE_string("glyphmap_file", None, "Glyphmap filename.") flags.DEFINE_string("part_file", None, "Reusable parts filename.") # A GlyphMapping plus an SVG, typically a picosvg, and/or a PNG class InputGlyph(NamedTuple): svg_file: Optional[Path] # either filenames can be omitted, mostly for debugging bitmap_file: Optional[Path] codepoints: Tuple[int, ...] glyph_name: str svg: Optional[SVG] # None for bitmap formats bitmap: Optional[PNG] # None for vector formats # A color font generator. # apply_ufo(ufo, color_glyphs) is called first, to update a generated UFO # apply_ttfont(ufo, color_glyphs, ttfont) is called second, to allow fixups after ufo2ft # Ideally we delete the ttfont stp in future. Blocking issues: # https://github.com/unified-font-object/ufo-spec/issues/104 # If the output file is .ufo then apply_ttfont is not called. # Where possible code to the ufo and let apply_ttfont be a nop. class ColorGenerator(NamedTuple): apply_ufo: Callable[[FontConfig, ufoLib2.Font, Tuple[ColorGlyph, ...]], None] apply_ttfont: Callable[ [FontConfig, ufoLib2.Font, Tuple[ColorGlyph, ...], ttLib.TTFont], None ] font_ext: str # extension for font binary, .ttf or .otf _COLOR_FORMAT_GENERATORS = { "glyf": ColorGenerator(lambda *args: _glyf_ufo(*args), lambda *_: None, ".ttf"), "glyf_colr_0": ColorGenerator( lambda *args: _colr_ufo(0, *args), lambda *_: None, ".ttf" ), "glyf_colr_1": ColorGenerator( lambda *args: _colr_ufo(1, *args), lambda *_: None, ".ttf" ), "cff_colr_0": ColorGenerator( lambda *args: _colr_ufo(0, *args), lambda *_: None, ".otf" ), "cff_colr_1": ColorGenerator( lambda *args: _colr_ufo(1, *args), lambda *_: None, ".otf" ), "cff2_colr_0": ColorGenerator( lambda *args: _colr_ufo(0, *args), lambda *_: None, ".otf" ), "cff2_colr_1": ColorGenerator( lambda *args: _colr_ufo(1, *args), lambda *_: None, ".otf" ), "picosvg": ColorGenerator( lambda *_: None, lambda *args: _svg_ttfont(*args, picosvg=True, compressed=False), ".ttf", ), "picosvgz": ColorGenerator( lambda *_: None, lambda *args: _svg_ttfont(*args, picosvg=True, compressed=True), ".ttf", ), "untouchedsvg": ColorGenerator( lambda *_: None, lambda *args: _svg_ttfont(*args, picosvg=False, compressed=False), ".ttf", ), "untouchedsvgz": ColorGenerator( lambda *_: None, lambda *args: _svg_ttfont(*args, picosvg=False, compressed=True), ".ttf", ), "cbdt": ColorGenerator( lambda *args: None, lambda *args: _cbdt_ttfont(*args), ".ttf", ), "sbix": ColorGenerator( lambda *args: None, lambda *args: _sbix_ttfont(*args), ".ttf", ), } assert _COLOR_FORMAT_GENERATORS.keys() == set(config._COLOR_FORMATS) def _ufo(config: FontConfig) -> ufoLib2.Font: ufo = ufoLib2.Font() ufo.info.familyName = config.family # set various font metadata; see the full list of fontinfo attributes at # https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/#generic-dimension-information ufo.info.unitsPerEm = config.upem # we just use a simple scheme that makes all sets of vertical metrics the same; # if one needs more fine-grained control they can fix up post build ufo.info.ascender = ufo.info.openTypeHheaAscender = ( ufo.info.openTypeOS2TypoAscender ) = config.ascender ufo.info.descender = ufo.info.openTypeHheaDescender = ( ufo.info.openTypeOS2TypoDescender ) = config.descender ufo.info.openTypeHheaLineGap = ufo.info.openTypeOS2TypoLineGap = config.linegap # set USE_TYPO_METRICS flag (OS/2.fsSelection bit 7) to make sure OS/2 Typo* metrics # are preferred to define Windows line spacing over legacy WinAscent/WinDescent: # https://docs.microsoft.com/en-us/typography/opentype/spec/os2#fsselection ufo.info.openTypeOS2Selection = [7] # version ufo.info.versionMajor = config.version_major ufo.info.versionMinor = config.version_minor # Must have .notdef and Win 10 Chrome likes a blank gid1 so make gid1 space ufo.newGlyph(".notdef") space = ufo.newGlyph(".space") space.unicodes = [0x0020] space.width = config.width ufo.glyphOrder = [".notdef", ".space"] # Always the .notdef outline, even for things like a pure SVG font # This decreases the odds of triggering https://github.com/khaledhosny/ots/issues/52 _draw_notdef(config, ufo) # use 'post' format 3.0 for TTFs, shaving a kew KBs of unneeded glyph names # if we're building svgs keep glyph names to simplify operation on the resulting binary keep = config.keep_glyph_names if config.has_svgs and config.has_picosvgs: keep = True ufo.lib[ufo2ft.constants.KEEP_GLYPH_NAMES] = keep return ufo def _make_ttfont( config: FontConfig, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...] ): if config.output_format == ".ufo": return None # Use skia-pathops to remove overlaps (i.e. simplify self-overlapping # paths) because the default ("booleanOperations") does not support # quadratic bezier curves (qcurve), which may appear # when we pass through picosvg (e.g. arcs or stroked paths). ttfont = None if config.output_format == ".ttf": ttfont = ufo2ft.compileTTF(ufo, overlapsBackend="pathops") if config.output_format == ".otf": cff_version = 1 if config.color_format.startswith("cff2_"): cff_version = 2 ttfont = ufo2ft.compileOTF( ufo, cffVersion=cff_version, overlapsBackend="pathops" ) if not ttfont: raise ValueError( f"Unable to generate {config.color_format} {config.output_format}" ) return ttfont def _write(ufo, ttfont, output_file): logging.info("Writing %s", output_file) if os.path.splitext(output_file)[1] == ".ufo": ufo.save(output_file, overwrite=True) else: ttfont.save(output_file) def _not_impl(func_name, color_format, *_): raise NotImplementedError(f"{func_name} for {color_format} not implemented") def _next_name(ufo: ufoLib2.Font, name_fn) -> str: i = 0 while name_fn(i) in ufo: i += 1 return name_fn(i) def _create_glyph( color_glyph: ColorGlyph, paint: PaintGlyph, path_in_font_space: str ) -> Glyph: glyph = _init_glyph(color_glyph) ufo = color_glyph.ufo draw_svg_path(SVGPath(d=path_in_font_space), glyph.getPen()) ufo.glyphOrder += [glyph.name] return glyph def _migrate_paths_to_ufo_glyphs( color_glyph: ColorGlyph, glyph_cache: GlyphReuseCache ) -> ColorGlyph: svg_units_to_font_units = color_glyph.transform_for_font_space() # Walk through the color glyph, where we see a PaintGlyph take the path out of it, # move the path into font coordinates, generate a ufo glyph, and push the name of # the ufo glyph into the PaintGlyph def _update_paint_glyph(paint): if paint.format != PaintGlyph.format: return paint if glyph_cache.is_known_glyph(paint.glyph): return paint assert paint.glyph.startswith("M"), f"{paint.glyph} doesn't look like a path" path_in_font_space = ( SVGPath(d=paint.glyph).apply_transform(svg_units_to_font_units).d ) reuse_result = glyph_cache.try_reuse(path_in_font_space) if reuse_result is not None: # TODO: when is it more compact to use a new transforming glyph? child_transform = Affine2D.identity() child_paint = paint.paint if is_transform(child_paint): child_transform = child_paint.gettransform() child_paint = child_paint.paint # sanity check: GlyphReuseCache.try_reuse would return None if overflowed assert fixed_safe(*reuse_result.transform) overflows = False # TODO: handle gradient anywhere in subtree, not only as direct child of # PaintGlyph or PaintTransform if is_gradient(child_paint): # We have a gradient so we need to reverse the effect of the # reuse_result.transform. First we try to apply the combined transform # to the gradient's geometry; but this may overflow OT integer bounds, # in which case we pass through gradient unscaled transform = Affine2D.compose_ltr( (child_transform, reuse_result.transform.inverse()) ) # skip reuse if combined transform overflows OT int bounds overflows = not fixed_safe(*transform) if not overflows: try: child_paint = child_paint.apply_transform(transform) except OverflowError: child_paint = transformed(transform, child_paint) if not overflows: return transformed( reuse_result.transform, PaintGlyph( glyph=reuse_result.glyph_name, paint=child_paint, ), ) glyph = _create_glyph(color_glyph, paint, path_in_font_space) glyph_cache.add_glyph(glyph.name, path_in_font_space) return dataclasses.replace(paint, glyph=glyph.name) return color_glyph.mutating_traverse(_update_paint_glyph) def _draw_glyph_extents( ufo: ufoLib2.Font, glyph: Glyph, bounds: Tuple[float, float, float, float] ): # apparently on Mac (but not Linux) Chrome and Firefox end up relying on the # extents of the base layer to determine where the glyph might paint. If you # leave the base blank the COLR glyph never renders. if rectArea(bounds) == 0: return start, end = bounds[:2], bounds[2:] pen = glyph.getPen() pen.moveTo(start) pen.lineTo(end) pen.endPath() return glyph def _draw_notdef(config: FontConfig, ufo: ufoLib2.Font): # A StubGlyph named .notdef provides a nice drawing of a notdef notdefArtist = StubGlyph( ".notdef", config.width, config.upem, config.ascender, config.descender, ) # UFO doesn't like just sticking StubGlyph directly in place glyph = ufo[".notdef"] glyph.width = notdefArtist.width notdefArtist.draw(glyph.getPen()) def _glyf_ufo( config: FontConfig, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...] ): # We want to mutate our view of color_glyphs color_glyphs = list(color_glyphs) # glyphs by reuse_key glyph_cache = GlyphReuseCache(config.reuse_tolerance) glyph_uses = Counter() for i, color_glyph in enumerate(color_glyphs): logging.debug( "%s %s %s", ufo.info.familyName, color_glyph.ufo_glyph_name, color_glyph.transform_for_font_space(), ) parent_glyph = color_glyph.ufo_glyph # generate glyphs for PaintGlyph's and assign glyph names color_glyphs[i] = color_glyph = _migrate_paths_to_ufo_glyphs( color_glyph, glyph_cache ) for root in color_glyph.painted_layers: for context in root.breadth_first(): # For 'glyf' just dump anything that isn't a PaintGlyph if not isinstance(context.paint, PaintGlyph): continue paint_glyph = cast(PaintGlyph, context.paint) glyph = ufo.get(paint_glyph.glyph) parent_glyph.components.append( Component(baseGlyph=glyph.name, transformation=context.transform) ) glyph_uses[glyph.name] += 1 # No great reason to keep single-component glyphs around (unless reused) for color_glyph in color_glyphs: parent_glyph = color_glyph.ufo_glyph if ( len(parent_glyph.components) == 1 and glyph_uses[parent_glyph.components[0].baseGlyph] == 1 ): component = ufo[parent_glyph.components[0].baseGlyph] del ufo[component.name] component.unicode = parent_glyph.unicode ufo[color_glyph.ufo_glyph_name] = component assert component.name == color_glyph.ufo_glyph_name def _name_prefix(color_glyph: ColorGlyph) -> Glyph: return f"{color_glyph.ufo_glyph_name}." def _init_glyph(color_glyph: ColorGlyph) -> Glyph: ufo = color_glyph.ufo glyph = ufo.newGlyph(_next_name(ufo, lambda i: f"{_name_prefix(color_glyph)}{i}")) glyph.width = ufo.get(color_glyph.glyph_name).width return glyph def _init_glyph(color_glyph: ColorGlyph) -> Glyph: ufo = color_glyph.ufo glyph = ufo.newGlyph(_next_name(ufo, lambda i: f"{_name_prefix(color_glyph)}{i}")) glyph.width = color_glyph.ufo_glyph.width return glyph def _create_transformed_glyph( color_glyph: ColorGlyph, paint: PaintGlyph, transform: Affine2D ) -> Glyph: glyph = _init_glyph(color_glyph) glyph.components.append(Component(baseGlyph=paint.glyph, transformation=transform)) color_glyph.ufo.glyphOrder += [glyph.name] return glyph def _colr0_layers(color_glyph: ColorGlyph, root: Paint, palette: Sequence[Color]): # COLRv0: write out each PaintGlyph we see in it's first color # If we see a transformed glyph generate a component # Results for complex structures will be suboptimal :) ufo = color_glyph.ufo layers = [] for context in root.breadth_first(): if context.paint.format != PaintGlyph.format: # pytype: disable=attribute-error continue paint_glyph: PaintGlyph = ( context.paint ) # pytype: disable=annotation-type-mismatch color = next(paint_glyph.colors()) glyph_name = paint_glyph.glyph if context.transform != Affine2D.identity(): glyph_name = _create_transformed_glyph( color_glyph, paint_glyph, context.transform ).name layers.append((glyph_name, color.index_from(palette))) return layers def _quantize_bounding_rect( xMin: float, yMin: float, xMax: float, yMax: float, factor: int = 1, ) -> Tuple[int, int, int, int]: """ >>> bounds = (72.3, -218.4, 1201.3, 919.1) >>> _quantize_bounding_rect(*bounds) (72, -219, 1202, 920) >>> _quantize_bounding_rect(*bounds, factor=10) (70, -220, 1210, 920) >>> _quantize_bounding_rect(*bounds, factor=100) (0, -300, 1300, 1000) """ assert factor >= 1 return ( int(math.floor(xMin / factor) * factor), int(math.floor(yMin / factor) * factor), int(math.ceil(xMax / factor) * factor), int(math.ceil(yMax / factor) * factor), ) def _transformed_glyph_bounds( ufo: ufoLib2.Font, glyph_name: str, transform: Affine2D ) -> Optional[Tuple[float, float, float, float]]: glyph = ufo[glyph_name] pen = bounds_pen = ControlBoundsPen(ufo) if not transform.almost_equals(Affine2D.identity()): pen = TransformPen(bounds_pen, transform) glyph.draw(pen) return bounds_pen.bounds def _bounds( color_glyph: ColorGlyph, quantize_factor: int = 1 ) -> Optional[Tuple[int, int, int, int]]: bounds = None for root in color_glyph.painted_layers: for context in root.breadth_first(): if not isinstance(context.paint, PaintGlyph): continue paint_glyph: PaintGlyph = cast(PaintGlyph, context.paint) glyph_bbox = _transformed_glyph_bounds( color_glyph.ufo, paint_glyph.glyph, context.transform ) if glyph_bbox is None: continue if bounds is None: bounds = glyph_bbox else: bounds = unionRect(bounds, glyph_bbox) if bounds is None: return # before quantizing to integer values > 1, we must first round floats to # int using the same rounding function (i.e. otRound) that fontTools # glyf table's compile method will use to round any float coordinates. bounds = tuple(otRound(v) for v in bounds) if quantize_factor > 1: return _quantize_bounding_rect(*bounds, factor=quantize_factor) return bounds def _ufo_colr_layers( colr_version: int, colors: Sequence[Color], color_glyph: ColorGlyph ): # The value for a COLOR_LAYERS_KEY entry per # https://github.com/googlefonts/ufo2ft/pull/359 colr_layers = [] # accumulate layers in z-order for paint in color_glyph.painted_layers: if colr_version == 0: colr_layers.extend(_colr0_layers(color_glyph, paint, colors)) elif colr_version == 1: colr_layers.append(paint.to_ufo_paint(colors)) else: raise ValueError(f"Invalid color version {colr_version}") if colr_version > 0: colr_layers = { "Format": int(ot.PaintFormat.PaintColrLayers), "Layers": colr_layers, } return colr_layers def _colr_ufo( colr_version: int, config: FontConfig, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ): black = Color(0, 0, 0, 1.0) # We want to mutate our view of color glyphs color_glyphs = list(color_glyphs) # We only store opaque colors in CPAL for COLRv1, as 'alpha' is # encoded separately. colors = uniq_sort_cpal_colors( ( c if colr_version == 0 else c.opaque() for c in chain.from_iterable(g.colors() for g in color_glyphs) if not c.is_current_color() ) ) logging.debug("colors %s", colors) # KISS; use a single global palette ufo.lib[ufo2ft.constants.COLOR_PALETTES_KEY] = [[c.to_ufo_color() for c in colors]] # each base glyph maps to a list of (glyph name, paint info) in z-order ufo_color_layers = {} # potentially reusable glyphs glyph_cache = GlyphReuseCache(config.reuse_tolerance) clipBoxes = {} quantization = config.clipbox_quantization if quantization is None: # by default, quantize clip boxes to an integer value 2% of the UPEM quantization = round(config.upem * 0.02) for i, color_glyph in enumerate(color_glyphs): logging.debug( "%s %s %s", ufo.info.familyName, color_glyph.ufo_glyph_name, color_glyph.transform_for_font_space(), ) # generate glyphs for PaintGlyph's and assign glyph names color_glyphs[i] = color_glyph = _migrate_paths_to_ufo_glyphs( color_glyph, glyph_cache ) if color_glyph.painted_layers: # write out the ufo structures for COLR ufo_color_layers[color_glyph.ufo_glyph_name] = _ufo_colr_layers( colr_version, colors, color_glyph ) bounds = _bounds(color_glyph, quantization) if bounds is not None: clipBoxes.setdefault(bounds, []).append(color_glyph.ufo_glyph_name) ufo.lib[ufo2ft.constants.COLOR_LAYERS_KEY] = ufo_color_layers if clipBoxes: if colr_version == 0: # COLRv0 doesn't define its own bounding boxes, but some implementations # rely on the extents of the base glyph so we must add those for bounds, glyphs in clipBoxes.items(): for glyph_name in glyphs: _draw_glyph_extents(ufo, ufo[glyph_name], bounds) else: # COLRv1 clip boxes are stored in UFO lib.plist as an array of 2-tuples, # each containing firstly the glyph names (array of strings), and secondly # the clip box values (array of 4 integers for a non-variable box) shared # by all those glyphs. ufo.lib[ufo2ft.constants.COLR_CLIP_BOXES_KEY] = [ (glyphs, box) for box, glyphs in clipBoxes.items() ] def _sbix_ttfont( config: FontConfig, _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): make_sbix_table(config, ttfont, color_glyphs) def _cbdt_ttfont( config: FontConfig, _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): make_cbdt_table(config, ttfont, color_glyphs) def _svg_ttfont( config: FontConfig, _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, picosvg: bool = True, compressed: bool = False, ): make_svg_table(config, ttfont, color_glyphs, picosvg, compressed) def _picosvg_and_cbdt( config: FontConfig, _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): picosvg = True compressed = False # make the svg table first because it changes glyph order and cbdt cares make_svg_table(config, ttfont, color_glyphs, picosvg, compressed) make_cbdt_table(config, ttfont, color_glyphs) def _ensure_codepoints_will_have_glyphs(ufo, glyph_inputs): """Ensure all codepoints we use will have a glyph. Single codepoint sequences will directly mapped to their glyphs. We need to add a glyph for any codepoint that is only used in a multi-codepoint sequence. """ all_codepoints = set() direct_mapped_codepoints = set() for glyph_input in glyph_inputs: if not glyph_input.codepoints: continue if len(glyph_input.codepoints) == 1: direct_mapped_codepoints.update(glyph_input.codepoints) all_codepoints.update(glyph_input.codepoints) need_blanks = all_codepoints - direct_mapped_codepoints logging.debug("%d codepoints require blanks", len(need_blanks)) glyph_names = [] for codepoint in need_blanks: # Any layer is fine; we aren't going to draw glyph = ufo.newGlyph(glyph_name(codepoint)) glyph.unicode = codepoint glyph_names.append(glyph.name) ufo.glyphOrder = ufo.glyphOrder + sorted(glyph_names) def _generate_color_font(config: FontConfig, inputs: Iterable[InputGlyph]): """Make a UFO and optionally a TTFont from svgs.""" ufo = _ufo(config) _ensure_codepoints_will_have_glyphs(ufo, inputs) color_glyphs = [] glyph_order = list(ufo.glyphOrder) assert glyph_order[0] == ".notdef" for glyph_input in inputs: if glyph_input.glyph_name in glyph_order: gid = glyph_order.index(glyph_input.glyph_name) else: gid = len(glyph_order) glyph_order.append(glyph_input.glyph_name) color_glyphs.append( ColorGlyph.create( config, ufo, str(glyph_input.svg_file) if glyph_input.svg_file else "", gid, glyph_input.glyph_name, glyph_input.codepoints, glyph_input.svg, str(glyph_input.bitmap_file) if glyph_input.bitmap_file else "", glyph_input.bitmap, ) ) color_glyphs = tuple(color_glyphs) # TODO: Optimize glyphOrder so that color glyphs sharing the same clip box # values are placed next to one another in continuous ranges, to minimize number # of COLRv1 ClipRecords ufo.glyphOrder = glyph_order for g in color_glyphs: ufo_gid = ufo.glyphOrder.index(g.ufo_glyph_name) assert ( g.glyph_id == ufo_gid ), f"{g.ufo_glyph_name} is {ufo_gid} in ufo, {g.glyph_id} in ColorGlyph" _COLOR_FORMAT_GENERATORS[config.color_format].apply_ufo(config, ufo, color_glyphs) if config.fea_file: with open(config.fea_file) as f: ufo.features.text = f.read() logging.debug("fea:\n%s\n" % ufo.features.text) else: logging.debug("No fea") ttfont = _make_ttfont(config, ufo, color_glyphs) if ttfont is not None: # apply_ttfont may wish to do things like reorder tables that require full load ttfont = util.load_fully(ttfont) # Permit fixups where we can't express something adequately in UFO _COLOR_FORMAT_GENERATORS[config.color_format].apply_ttfont( config, ufo, color_glyphs, ttfont ) # some formats keep glyph order through to here if not config.keep_glyph_names: ttfont["post"].formatType = 3 # no glyph names return ufo, ttfont def _inputs( font_config: FontConfig, glyph_mappings: Sequence[GlyphMapping], ) -> Generator[InputGlyph, None, None]: for g in glyph_mappings: picosvg = None if font_config.has_svgs: if not g.svg_file: raise ValueError(f"No svg file for glyph {g.glyph_name}") try: picosvg = SVG.parse(g.svg_file) except etree.ParseError as e: raise IOError(f"Unable to parse {g.svg_file}") from e bitmap = None if font_config.has_bitmaps: if not g.bitmap_file: raise ValueError(f"No bitmap file for glyph {g.glyph_name}") bitmap = PNG.read_from(g.bitmap_file) yield InputGlyph( g.svg_file, g.bitmap_file, g.codepoints, g.glyph_name, picosvg, bitmap, ) def main(argv): config_file = None if FLAGS.config_file: config_file = Path(FLAGS.config_file) font_config = config.load(config_file) if len(font_config.masters) != 1: raise ValueError( f"write_font expects only one master, {config_file} has {len(font_config.masters)}" ) inputs = list(_inputs(font_config, glyphmap.parse_csv(FLAGS.glyphmap_file))) reusable_parts = ReusableParts() if FLAGS.part_file: reusable_parts = ReusableParts.loadjson(Path(FLAGS.part_file)) if not inputs: sys.exit("Please provide at least one svg filename") ufo, ttfont = _generate_color_font(font_config, inputs) _write(ufo, ttfont, font_config.output_file) logging.info("Wrote %s" % font_config.output_file) if __name__ == "__main__": flags.mark_flag_as_required("glyphmap_file") app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_font2png.py000066400000000000000000000037171523660054300216600ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generate a png using Skia.""" from absl import app from absl import flags from absl import logging from nanoemoji import codepoints from nanoemoji import util import os import shutil import subprocess import sys FLAGS = flags.FLAGS flags.DEFINE_integer("height", None, "png height, pixels.") flags.DEFINE_integer("width", None, "png width, pixels.") flags.DEFINE_string("output_file", None, "Output filename.") def main(argv): src_svg = util.only(argv, lambda a: a.endswith(".svg")) font_file = util.only(argv, lambda a: a.endswith(".ttf")) text = "".join( chr(cp) for cp in codepoints.from_filename(os.path.basename(src_svg)) ) logging.info("%s %s", src_svg, text) colr_test_cmd = [ "colr_test", "--font", font_file, "--output", FLAGS.output_file, "--text", text, ] if FLAGS.height is not None: colr_test_cmd.extend(("--height", str(FLAGS.height))) if FLAGS.width is not None: colr_test_cmd.extend(("--width", str(FLAGS.width))) if not shutil.which(colr_test_cmd[0]): sys.exit( f"{colr_test_cmd[0]} binary (https://github.com/rsheeter/skia_colr/tree/colr_test) must be on PATH" ) logging.info(" ".join(colr_test_cmd)) subprocess.run(colr_test_cmd, check=True) logging.info("Wrote %s", FLAGS.output_file) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_font2png_html.py000066400000000000000000000057431523660054300227050ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Writes an html file suitable to screenshot for a single font glyph""" from absl import app from absl import flags from nanoemoji import codepoints, util from pathlib import Path import textwrap FLAGS = flags.FLAGS flags.DEFINE_string("output_file", "-", "Output filename ('-' means stdout)") flags.DEFINE_integer("resolution", None, "Resolution in px") def main(argv): assert len(argv) == 3 font_file = Path(argv[1]).name activation = "".join( f"&#x{cp:04x};" for cp in codepoints.from_filename(Path(argv[2]).name) ) with util.file_printer(FLAGS.output_file) as print: # replace rather than f-string because the template contains js-interpolations # and double-interpolated strings make my head hurt print( textwrap.dedent( """ ACTIVATION """.replace("FONT_LOCATION", font_file) .replace("RESOLUTION", str(FLAGS.resolution)) .replace("ACTIVATION", activation) ) ) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_glyphgraph.py000066400000000000000000000214351523660054300222650ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Writes a graphviz of a COLRv1 font.""" # TODO update to latest format #s; use consts to avoid this in future from absl import app from absl import flags from absl import logging from collections import Counter from fontTools import ttLib from fontTools.ttLib.tables import otTables as ot from graphviz import Digraph # pytype: disable=import-error from lxml import etree # pytype: disable=import-error from nanoemoji.colors import Color from typing import Mapping, NamedTuple, Set, Tuple FLAGS = flags.FLAGS class Node(NamedTuple): node_id: str node_label: str = None def label(self): if self.node_label: return self.node_label return self.node_id class DAG: graph: Digraph edges: Set[Tuple[str, str]] nth_of_type: Mapping[str, int] count_of_type: Counter def __init__(self): self.graph = Digraph( "unix", directory="build", format="svg", graph_attr={"rankdir": "LR"} ) self.edges = set() self.nth_of_type = {} self.count_of_type = Counter() def visited(self, node_id): return node_id in self.nth_of_type def edge(self, src, dest): if not self.visited(dest): dest_type = dest if "_" in dest: dest_type = dest[: dest.index("_")] self.count_of_type[dest_type] += 1 self.nth_of_type[dest] = self.count_of_type[dest_type] # if len(dest) > 32: # self.graph.node(dest, f"{dest_type}.{self.nth_of_type[dest]}") new_edge = (src, dest) not in self.edges if src is not None and new_edge: self.graph.edge(src, dest) self.edges.add((src, dest)) return new_edge def _base_glyphs(font, filter_fn): for base_glyph in font["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord: if filter_fn(base_glyph): yield base_glyph def _only(seq): seq = tuple(seq) if len(seq) != 1: raise ValueError(f"Need 1 entry, got {len(seq)}") return seq[0] def _indent(depth): return depth * " " def _color_index_node(palette, color_index): color = Color.fromstring(palette[color_index.PaletteIndex].hex()) ci_alpha = color_index.Alpha.value node_id = f"{ci_alpha:.2f}.{color.opaque().to_string()}.{color.alpha:.2f}" return Node( node_id=node_id, node_label=color._replace(alpha=color.alpha * ci_alpha).to_string(), ) def _color_line_node(palette, color_line): id_parts = ["ColorLine", color_line.Extend.name] name_parts = id_parts[:] for stop in color_line.ColorStop: color_node = _color_index_node(palette, stop.Color) id_parts.append(f"{color_node.node_id}@{stop.StopOffset.value:.1f}") name_parts.append(f"{color_node.node_label}@{stop.StopOffset.value:.1f}") return Node(node_id="_".join(id_parts), node_label=" ".join(name_parts)) def _paint_node(glyph_order, palette, paint) -> Node: if paint.Format == ot.Paint.Format.PaintColrLayers: return Node( node_id=( f"Layers_[{paint.FirstLayerIndex}..{paint.FirstLayerIndex+paint.NumLayers}]" ) ) if paint.Format == ot.Paint.Format.PaintSolid: return _color_index_node(palette, paint.Color) if paint.Format == ot.Paint.Format.PaintLinearGradient: id_parts = ( "Linear", f"p0({paint.x0.value}, {paint.y0.value})", f"p1({paint.x1.value}, {paint.y1.value})", f"p2({paint.x2.value}, {paint.y2.value})", _color_line_node(palette, paint.ColorLine).node_id, ) return Node(node_id="_".join(id_parts), node_label=" ".join(id_parts[:-1])) if paint.Format == ot.Paint.Format.PaintRadialGradient: id_parts = ( "Radial", f"c0({paint.x0.value}, {paint.y0.value})", f"r0 {paint.r0.value}", f"c1({paint.x1.value}, {paint.y1.value})", f"r1 {paint.r1.value}", _color_line_node(palette, paint.ColorLine).node_id, ) return Node(node_id="_".join(id_parts), node_label=" ".join(id_parts[:-1])) if paint.Format == ot.Paint.Format.PaintGlyph: id_parts = ( "Glyph", paint.Glyph, _paint_node(glyph_order, palette, paint.Paint).node_id, ) return Node( node_id="_".join(id_parts), node_label=f"gid {glyph_order.index(paint.Glyph)}", ) if paint.Format == ot.Paint.Format.PaintColrGlyph: id_parts = ( "Base", paint.Glyph, ) return Node(node_id="_".join(id_parts)) if paint.Format == ot.Paint.Format.PaintTransform: id_parts = ( "Transform", f"î {paint.Transform.xx.value},{paint.Transform.xy.value}", f"ĵ {paint.Transform.yx.value},{paint.Transform.yy.value}", f"dx {paint.Transform.dx.value}", f"dy {paint.Transform.dy.value}", _paint_node(glyph_order, palette, paint.Paint).node_id, ) return Node(node_id="_".join(id_parts)) if paint.Format == ot.Paint.Format.PaintComposite: id_parts = ( "Composite", _paint_node(glyph_order, palette, paint.SourcePaint).node_id, paint.CompositeMode.name, _paint_node(glyph_order, palette, paint.BackdropPaint).node_id, ) return Node(node_id="_".join(id_parts), node_label=paint.CompositeMode.name) raise NotImplementedError(f"id for format {paint.Format} ({dir(paint)})") def _paint(dag, parent, font, paint, depth): if depth > 256: raise NotImplementedError("Too deep, something wrong?") palette = font["CPAL"].palettes[0] node = _paint_node(font.getGlyphOrder(), palette, paint) logging.info(_indent(depth) + node.label()) dag.graph.node(node.node_id, node.node_label) node_id = node.node_id new_edge = dag.edge(parent, node_id) # Descend if new_edge: if paint.Format == ot.Paint.Format.PaintColrLayers: child_paints = font["COLR"].table.LayerList.Paint[ paint.FirstLayerIndex : paint.FirstLayerIndex + paint.NumLayers ] for child_paint in child_paints: _paint(dag, node_id, font, child_paint, depth + 1) elif paint.Format == ot.Paint.Format.PaintSolid: pass elif paint.Format in ( ot.Paint.Format.PaintLinearGradient, ot.Paint.Format.PaintRadialGradient, ): color_node = _color_line_node(palette, paint.ColorLine) dag.graph.node(color_node.node_id, color_node.node_label) dag.edge(node_id, color_node.node_id) elif paint.Format == ot.Paint.Format.PaintGlyph: _paint(dag, node_id, font, paint.Paint, depth + 1) # adding format 5 edges makes the result a horrible mess # elif paint.Format == 5: # _glyph(dag, node_id, font, _only(_base_glyphs(font, lambda g: g.BaseGlyph == paint.Glyph)), depth + 1) elif paint.Format == ot.Paint.Format.PaintTransform: _paint(dag, node_id, font, paint.Paint, depth + 1) elif paint.Format == ot.Paint.Format.PaintComposite: _paint(dag, node_id, font, paint.BackdropPaint, depth + 1) _paint(dag, node_id, font, paint.SourcePaint, depth + 1) else: raise ValueError(f"Unsupported: {paint.Format}") def _glyph(dag, parent, font, base_glyph, depth=0): name = "Base_" + base_glyph.BaseGlyph dag.edge(parent, name) logging.info(_indent(depth) + name) _paint(dag, name, font, base_glyph.Paint, depth + 1) def main(argv): if len(argv) > 2: raise ValueError("Only expected non-flag is font file") font = ttLib.TTFont(argv[1]) dag = DAG() for base_glyph in _base_glyphs(font, lambda _: True): _glyph(dag, None, font, base_glyph) logging.info("Count by type") for node_type, count in sorted(dag.count_of_type.items()): logging.info(" %s %s", node_type, count) output_file = dag.graph.render() tree = etree.parse(output_file) del tree.getroot().attrib["width"] del tree.getroot().attrib["height"] tree.write(output_file, pretty_print=True) logging.info("wrote %s", output_file) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_glyphmap.py000066400000000000000000000047451523660054300217460ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Default glyphmap writer. Writes rows like: picosvg/clipped/emoji_u270d_1f3fb.svg, bitmap/emoji_u270d_1f3fb.png, g_270d_1f3fb, 270d, 1f3fb The first two columns represent respectively the SVG and bitmap filenames; either can be left empty if the font is vector- or bitmap-only. The third column is the UFO/PostScript glyph name, and it's required. The fourth and the remaining columns are optional, and contain the Unicode codepoints as hexadecimal digits; a single codepoint gets added to the cmap, more than one produce a GSUB ligature, no codepoint leaves the glyph unmapped. """ import enum from absl import app from absl import flags from nanoemoji.glyph import glyph_name from nanoemoji.glyphmap import GlyphMapping from nanoemoji import codepoints from nanoemoji import util from pathlib import Path from typing import Iterator, Sequence, Tuple FLAGS = flags.FLAGS flags.DEFINE_string("output_file", "-", "Output filename ('-' means stdout)") class InputFileSuffix(enum.Enum): SVG = ".svg" PNG = ".png" def _glyphmappings(input_files: Sequence[str]) -> Iterator[GlyphMapping]: # group .svg and/or .png files with the same filename stem sources_by_stem = {} suffix_index = {InputFileSuffix.SVG: 0, InputFileSuffix.PNG: 1} for filename in input_files: input_file = Path(filename) i = suffix_index[InputFileSuffix(input_file.suffix)] sources_by_stem.setdefault(input_file.stem, [None, None])[i] = input_file for source_stem, files in sources_by_stem.items(): cps = tuple(codepoints.from_filename(source_stem)) yield GlyphMapping(*files, cps, glyph_name(cps)) def main(argv): input_files = util.expand_ninja_response_files(argv[1:]) with util.file_printer(FLAGS.output_file) as print: for gm in _glyphmappings(input_files): # filename(s), glyph_name, codepoint(s) print(gm.csv_line()) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_glyphmap_for_glyph_svgs.py000066400000000000000000000043221523660054300250500ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generates a glyphmap for svgs named by glyph id.""" from absl import app from absl import flags from absl import logging from fontTools import ttLib from nanoemoji.glyphmap import GlyphMapping from nanoemoji import util from pathlib import Path FLAGS = flags.FLAGS flags.DEFINE_string("output_file", "-", "Output filename ('-' means stdout)") flags.DEFINE_bool("bitmaps", False, "True if bitmaps should be included in glyphmap") def main(argv): input_files = util.expand_ninja_response_files(argv[1:]) del argv source_font = util.only(input_files, lambda a: a.endswith(".ttf")) glyph_order = ttLib.TTFont(source_font).getGlyphOrder() input_files = sorted( (Path(f) for f in input_files if f != source_font), key=lambda f: f.stem, reverse=True, ) with util.file_printer(FLAGS.output_file) as print: while input_files: svg_file = input_files.pop() assert svg_file.suffix in {".png", ".svg"}, f"What is {svg_file}" bitmap_file = None if svg_file.suffix == ".png": bitmap_file = svg_file svg_file = input_files.pop() assert svg_file.suffix == ".svg" assert int(svg_file.stem) == int( bitmap_file.stem ), f"Mismatched {svg_file}, {bitmap_file}" print( GlyphMapping( svg_file=svg_file, bitmap_file=bitmap_file, codepoints=(), glyph_name=glyph_order[int(svg_file.stem)], ).csv_line() ) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_part_file.py000066400000000000000000000030511523660054300220570ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generates a part file from 1 source, typically an svg file.""" from absl import app from absl import flags from nanoemoji.parts import ReusableParts from nanoemoji import util from pathlib import Path from picosvg.geometric_types import Rect from picosvg.svg import SVG FLAGS = flags.FLAGS flags.DEFINE_integer("wh", None, "The width and height to use.") flags.DEFINE_bool("compute_donors", False, "Whether to compute donors.") def main(argv): if len(argv) != 2: raise ValueError("Specify exactly one input") view_box = Rect(0, 0, FLAGS.wh, FLAGS.wh) parts = ReusableParts(view_box=view_box, reuse_tolerance=FLAGS.reuse_tolerance) svg = SVG.parse(Path(argv[1])) parts.add(svg) if FLAGS.compute_donors: parts.compute_donors() with util.file_printer(FLAGS.output_file) as print: print(parts.to_json()) if __name__ == "__main__": flags.mark_flag_as_required("wh") flags.mark_flag_as_required("reuse_tolerance") app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_pngdiff.py000066400000000000000000000030431523660054300215300ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Diff two pngs and save the result. See: https://pillow.readthedocs.io/en/stable/reference/ImageChops.html https://pillow.readthedocs.io/en/stable/reference/ImageStat.html """ from absl import app from absl import flags from absl import logging from PIL import Image, ImageChops, ImageStat import os FLAGS = flags.FLAGS flags.DEFINE_string("output_file", None, "File contining the diff.") def _diff_pixel(p): if p != (0, 0, 0, 0): return (255, 0, 233, 255) return p def _pink_diff_file(): base, ext = os.path.splitext(FLAGS.output_file) return f"{base}.pink{ext}" def main(argv): lhs_file, rhs_file = argv[1:] lhs, rhs = Image.open(lhs_file), Image.open(rhs_file) diff = ImageChops.difference(lhs, rhs) diff.save(FLAGS.output_file) # The default diff is really hard; make it more obvoius diff.putdata([_diff_pixel(p) for p in diff.getdata()]) diff.save(_pink_diff_file()) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/src/nanoemoji/write_variable_font.py000066400000000000000000000051351523660054300227320ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Assembles UFOs into a variable font. Based on https://gist.github.com/anthrotype/c11515065a4aa6549f9d5f2dfdcf8f23""" from absl import app from absl import flags from absl import logging from fontTools import designspaceLib from nanoemoji import config from pathlib import Path import ufo2ft import ufoLib2 FLAGS = flags.FLAGS flags.DEFINE_string("config_file", None, "Config filename.") def main(argv): ufos = tuple(a for a in argv[1:] if a.endswith(".ufo")) config_file = None if FLAGS.config_file: config_file = Path(FLAGS.config_file) font_config = config.load(config_file) designspace = designspaceLib.DesignSpaceDocument() import pprint pp = pprint.PrettyPrinter() # define axes names, tags and min/default/max axis_defs = [ dict( tag=a.axisTag, name=a.name, minimum=min( p.position for m in font_config.masters for p in m.position if p.axisTag == a.axisTag ), default=a.default, maximum=max( p.position for m in font_config.masters for p in m.position if p.axisTag == a.axisTag ), ) for a in font_config.axes ] logging.info(pp.pformat(axis_defs)) for axis_def in axis_defs: designspace.addAxisDescriptor(**axis_def) axis_names = {a.axisTag: a.name for a in font_config.axes} for master in font_config.masters: ufo = ufoLib2.Font.open(master.output_ufo) ufo.info.styleName = master.style_name location = {axis_names[p.axisTag]: p.position for p in master.position} designspace.addSourceDescriptor( name=master.output_ufo, location=location, font=ufo ) # build a variable TTFont from the designspace document # TODO: Use ufo2ft.compileVariableCFF2 for CFF vf = ufo2ft.compileVariableTTF(designspace) vf.save(font_config.output_file) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/tests/000077500000000000000000000000001523660054300147235ustar00rootroot00000000000000nanoemoji-0.16.0/tests/bitmap_tables_test.py000066400000000000000000000026651523660054300211530ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from io import BytesIO from nanoemoji.color_glyph import ColorGlyph from nanoemoji import config from nanoemoji.bitmap_tables import _cbdt_bitmap_data, BitmapMetrics from nanoemoji.png import PNG from nanoemoji.util import only from PIL import Image from test_helper import * def test_bitmap_data_for_non_square_image(): font_config = config.load() image_bytes = BytesIO() Image.new("RGBA", (90, 120), color="red").save(image_bytes, format="png") image_bytes.seek(0) image_bytes = image_bytes.read() png = PNG(image_bytes) metrics = BitmapMetrics.create( font_config, only(font_config.bitmap_resolutions), png, 120, range(-128, 127 + 1), range(0, 255 + 1), ) cbdt_bitmap = _cbdt_bitmap_data(font_config, metrics, png) assert (cbdt_bitmap.metrics.width, cbdt_bitmap.metrics.height) == (90, 120) nanoemoji-0.16.0/tests/cbdt/000077500000000000000000000000001523660054300156375ustar00rootroot00000000000000nanoemoji-0.16.0/tests/cbdt/config.toml000066400000000000000000000004211523660054300177760ustar00rootroot00000000000000family = "Noto Color Emoji Compat Test" output_file = "Font.ttf" color_format = "cbdt" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = [ "emoji_u0023.svg", "emoji_u1f1e6_1f1e8.svg", ] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/cbdt/emoji_u0023.svg000066400000000000000000000005201523660054300203110ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/cbdt/emoji_u1f1e6_1f1e8.svg000066400000000000000000000005201523660054300214530ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/circle.svg000066400000000000000000000001451523660054300167050ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/circle_10x.svg000066400000000000000000000001521523660054300173730ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/clocks_colr_1.ttx000066400000000000000000000340411523660054300202030ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/clocks_colr_1_noreuse.ttx000066400000000000000000000613021523660054300217430ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/clocks_glyf.ttx000066400000000000000000000202241523660054300177630ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/clocks_picosvg.ttx000066400000000000000000000121041523660054300204720ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M124,64 A60 60 0 1 1 4,64 A60 60 0 1 1 124,64 Z" id="e000.0"/> <path d="M63.8,63.98 L64.2,63.98 C66.3,63.98 68,62.28 68,60.18 L68,27.78 C68,25.68 66.3,23.98 64.2,23.98 L63.8,23.98 C61.7,23.98 60,25.68 60,27.78 L60,60.18 C60,62.28 61.7,63.98 63.8,63.98 Z" id="e000.1"/> <path d="M64,8 C94.88,8 120,33.12 120,64 C120,94.88 94.88,120 64,120 C33.12,120 8,94.88 8,64 C8,33.12 33.12,8 64,8 M64,4 C30.86,4 4,30.86 4,64 C4,97.14 30.86,124 64,124 C97.14,124 124,97.14 124,64 C124,30.86 97.14,4 64,4 L64,4 Z" id="e000.9" fill="#424242" opacity="0.2"/> <linearGradient id="g1" x1="64" y1="19.751" x2="64" y2="111.99" gradientUnits="userSpaceOnUse"> <stop offset="0.321" stop-color="#ECEFF1"/> <stop offset="1" stop-color="#B0BEC5"/> </linearGradient> </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <use xlink:href="#e000.0" fill="url(#g1)"/> <use xlink:href="#e000.1" fill="blue"/> <path d="M63.7,63.88 L64.04,64.09 C65.83,65.19 68.17,64.64 69.27,62.85 L80.61,44.48 C81.71,42.69 81.16,40.35 79.37,39.25 L79.03,39.04 C77.24,37.94 74.9,38.49 73.8,40.28 L62.47,58.65 C61.37,60.44 61.92,62.78 63.7,63.88 Z" fill="green"/> <use xlink:href="#e000.0" x="56.533" y="56.533" transform="matrix(0.117 0 0 0.117 49.919 49.919)" fill="#424242"/> <use xlink:href="#e000.0" x="60.8" y="60.8" transform="matrix(0.05 0 0 0.05 57.76 57.76)" fill="white"/> <use xlink:href="#e000.0" x="11.623" y="59.863" transform="matrix(0.067 0 0 0.067 10.844 55.852)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="11.863" transform="matrix(0.067 0 0 0.067 55.628 11.068)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="107.863" transform="matrix(0.067 0 0 0.067 55.628 100.636)" fill="#757575"/> <use xlink:href="#e000.0" x="107.623" y="59.863" transform="matrix(0.067 0 0 0.067 100.412 55.852)" fill="#757575"/> <use xlink:href="#e000.9"/> </g> <g id="glyph3" transform="matrix(0.781 0 0 0.781 0 -100)"> <use xlink:href="#e000.0" fill="url(#g1)"/> <use xlink:href="#e000.1" fill="purple"/> <path d="M63.78,63.81 L63.97,64.16 C64.98,66 67.29,66.68 69.13,65.67 L88.07,55.31 C89.91,54.3 90.59,51.99 89.58,50.15 L89.39,49.8 C88.38,47.96 86.07,47.28 84.23,48.29 L65.29,58.65 C63.45,59.66 62.77,61.97 63.78,63.81 Z" fill="red"/> <use xlink:href="#e000.0" x="56.533" y="56.533" transform="matrix(0.117 0 0 0.117 49.919 49.919)" fill="#424242"/> <use xlink:href="#e000.0" x="60.8" y="60.8" transform="matrix(0.05 0 0 0.05 57.76 57.76)" fill="white"/> <use xlink:href="#e000.0" x="11.623" y="59.863" transform="matrix(0.067 0 0 0.067 10.844 55.852)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="11.863" transform="matrix(0.067 0 0 0.067 55.628 11.068)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="107.863" transform="matrix(0.067 0 0 0.067 55.628 100.636)" fill="#757575"/> <use xlink:href="#e000.0" x="107.623" y="59.863" transform="matrix(0.067 0 0 0.067 100.412 55.852)" fill="#757575"/> <use xlink:href="#e000.9"/> </g> </svg> nanoemoji-0.16.0/tests/clocks_rects_picosvg.ttx000066400000000000000000000142201523660054300216730ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M124,64 A60 60 0 1 1 4,64 A60 60 0 1 1 124,64 Z" id="e000.0"/> <path d="M63.8,63.98 L64.2,63.98 C66.3,63.98 68,62.28 68,60.18 L68,27.78 C68,25.68 66.3,23.98 64.2,23.98 L63.8,23.98 C61.7,23.98 60,25.68 60,27.78 L60,60.18 C60,62.28 61.7,63.98 63.8,63.98 Z" id="e000.1"/> <path d="M64,8 C94.88,8 120,33.12 120,64 C120,94.88 94.88,120 64,120 C33.12,120 8,94.88 8,64 C8,33.12 33.12,8 64,8 M64,4 C30.86,4 4,30.86 4,64 C4,97.14 30.86,124 64,124 C97.14,124 124,97.14 124,64 C124,30.86 97.14,4 64,4 L64,4 Z" id="e000.9" fill="#424242" opacity="0.2"/> <linearGradient id="g1" x1="64" y1="19.751" x2="64" y2="111.99" gradientUnits="userSpaceOnUse"> <stop offset="0.321" stop-color="#ECEFF1"/> <stop offset="1" stop-color="#B0BEC5"/> </linearGradient> </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <use xlink:href="#e000.0" fill="url(#g1)"/> <use xlink:href="#e000.1" fill="blue"/> <path d="M63.7,63.88 L64.04,64.09 C65.83,65.19 68.17,64.64 69.27,62.85 L80.61,44.48 C81.71,42.69 81.16,40.35 79.37,39.25 L79.03,39.04 C77.24,37.94 74.9,38.49 73.8,40.28 L62.47,58.65 C61.37,60.44 61.92,62.78 63.7,63.88 Z" fill="green"/> <use xlink:href="#e000.0" x="56.533" y="56.533" transform="matrix(0.117 0 0 0.117 49.919 49.919)" fill="#424242"/> <use xlink:href="#e000.0" x="60.8" y="60.8" transform="matrix(0.05 0 0 0.05 57.76 57.76)" fill="white"/> <use xlink:href="#e000.0" x="11.623" y="59.863" transform="matrix(0.067 0 0 0.067 10.844 55.852)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="11.863" transform="matrix(0.067 0 0 0.067 55.628 11.068)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="107.863" transform="matrix(0.067 0 0 0.067 55.628 100.636)" fill="#757575"/> <use xlink:href="#e000.0" x="107.623" y="59.863" transform="matrix(0.067 0 0 0.067 100.412 55.852)" fill="#757575"/> <use xlink:href="#e000.9"/> </g> <g id="glyph3" transform="matrix(0.781 0 0 0.781 0 -100)"> <use xlink:href="#e000.0" fill="url(#g1)"/> <use xlink:href="#e000.1" fill="purple"/> <path d="M63.78,63.81 L63.97,64.16 C64.98,66 67.29,66.68 69.13,65.67 L88.07,55.31 C89.91,54.3 90.59,51.99 89.58,50.15 L89.39,49.8 C88.38,47.96 86.07,47.28 84.23,48.29 L65.29,58.65 C63.45,59.66 62.77,61.97 63.78,63.81 Z" fill="red"/> <use xlink:href="#e000.0" x="56.533" y="56.533" transform="matrix(0.117 0 0 0.117 49.919 49.919)" fill="#424242"/> <use xlink:href="#e000.0" x="60.8" y="60.8" transform="matrix(0.05 0 0 0.05 57.76 57.76)" fill="white"/> <use xlink:href="#e000.0" x="11.623" y="59.863" transform="matrix(0.067 0 0 0.067 10.844 55.852)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="11.863" transform="matrix(0.067 0 0 0.067 55.628 11.068)" fill="#757575"/> <use xlink:href="#e000.0" x="59.623" y="107.863" transform="matrix(0.067 0 0 0.067 55.628 100.636)" fill="#757575"/> <use xlink:href="#e000.0" x="107.623" y="59.863" transform="matrix(0.067 0 0 0.067 100.412 55.852)" fill="#757575"/> <use xlink:href="#e000.9"/> </g> </svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M2,2 L8,2 L8,4 L2,4 L2,2 Z" id="e001.0"/> </defs> <g id="glyph4" transform="matrix(10 0 0 10 0 -100)"> <use xlink:href="#e001.0" fill="blue"/> <use xlink:href="#e001.0" x="2" y="2" fill="blue" opacity="0.8"/> </g> <g id="glyph5" transform="matrix(10 0 0 10 0 -100)"> <use xlink:href="#e001.0" fill="purple"/> <use xlink:href="#e001.0" x="2" y="2" fill="darkgreen" opacity="0.8"/> </g> </svg> nanoemoji-0.16.0/tests/clocks_rects_untouchedsvg.ttx000066400000000000000000000125401523660054300227420ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg"><g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"><linearGradient id="a" x1="64" x2="64" y1="19.751" y2="111.99" gradientUnits="userSpaceOnUse"><stop stop-color="#ECEFF1" offset=".3212"/><stop stop-color="#B0BEC5" offset="1"/></linearGradient><circle cx="64" cy="64" r="60" fill="url(#a)"/><path d="m63.8 63.98h0.4c2.1 0 3.8-1.7 3.8-3.8v-32.4c0-2.1-1.7-3.8-3.8-3.8h-0.4c-2.1 0-3.8 1.7-3.8 3.8v32.4c0 2.1 1.7 3.8 3.8 3.8z" fill="blue"/><path d="m63.7 63.88 0.34 0.21c1.79 1.1 4.13 0.55 5.23-1.24l11.34-18.37c1.1-1.79 0.55-4.13-1.24-5.23l-0.34-0.21c-1.79-1.1-4.13-0.55-5.23 1.24l-11.33 18.37c-1.1 1.79-0.55 4.13 1.23 5.23z" fill="green"/><circle cx="64" cy="64" r="7" fill="#424242"/><circle cx="64" cy="64" r="3" fill="#fff"/><g fill="#757575"><circle cx="15.89" cy="64.13" r="4"/><circle cx="63.89" cy="16.13" r="4"/><circle cx="63.89" cy="112.13" r="4"/><circle cx="111.89" cy="64.13" r="4"/></g><g opacity=".2"><path d="M64,8c30.88,0,56,25.12,56,56s-25.12,56-56,56S8,94.88,8,64S33.12,8,64,8 M64,4 C30.86,4,4,30.86,4,64s26.86,60,60,60s60-26.86,60-60S97.14,4,64,4L64,4z" fill="#424242"/></g></g></svg> <svg xmlns="http://www.w3.org/2000/svg"><g id="glyph3" transform="matrix(10 0 0 10 0 -100)"><rect x="2" y="2" width="6" height="2" fill="blue"/><rect x="4" y="4" width="6" height="2" fill="blue" opacity="0.8"/></g></svg> <svg xmlns="http://www.w3.org/2000/svg"><g id="glyph4" transform="matrix(0.781 0 0 0.781 0 -100)"><linearGradient id="a" x1="64" x2="64" y1="19.751" y2="111.99" gradientUnits="userSpaceOnUse"><stop stop-color="#ECEFF1" offset=".3212"/><stop stop-color="#B0BEC5" offset="1"/></linearGradient><circle cx="64" cy="64" r="60" fill="url(#a)"/><path d="m63.8 63.98h0.4c2.1 0 3.8-1.7 3.8-3.8v-32.4c0-2.1-1.7-3.8-3.8-3.8h-0.4c-2.1 0-3.8 1.7-3.8 3.8v32.4c0 2.1 1.7 3.8 3.8 3.8z" fill="purple"/><path d="m63.78 63.81 0.19 0.35c1.01 1.84 3.32 2.52 5.16 1.51l18.94-10.36c1.84-1.01 2.52-3.32 1.51-5.16l-0.19-0.35c-1.01-1.84-3.32-2.52-5.16-1.51l-18.94 10.36c-1.84 1.01-2.52 3.32-1.51 5.16z" fill="red"/><circle cx="64" cy="64" r="7" fill="#424242"/><circle cx="64" cy="64" r="3" fill="#fff"/><g fill="#757575"><circle cx="15.89" cy="64.13" r="4"/><circle cx="63.89" cy="16.13" r="4"/><circle cx="63.89" cy="112.13" r="4"/><circle cx="111.89" cy="64.13" r="4"/></g><g opacity=".2"><path d="M64,8c30.88,0,56,25.12,56,56s-25.12,56-56,56S8,94.88,8,64S33.12,8,64,8 M64,4 C30.86,4,4,30.86,4,64s26.86,60,60,60s60-26.86,60-60S97.14,4,64,4L64,4z" fill="#424242"/></g></g></svg> <svg xmlns="http://www.w3.org/2000/svg"><g id="glyph5" transform="matrix(10 0 0 10 0 -100)"><rect x="2" y="2" width="6" height="2" fill="purple"/><rect x="4" y="4" width="6" height="2" fill="darkgreen" opacity="0.8"/></g></svg> nanoemoji-0.16.0/tests/codepoints_test.py000066400000000000000000000026561523660054300205140ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji import codepoints import pytest @pytest.mark.parametrize( "filename, expected_codepoints", [ # Noto Emoji, single codepoint ("emoji_u1f378.svg", (0x1F378,)), # Noto Emoji, multiple codepoints ("emoji_u1f385_1f3fb.svg", (0x1F385, 0x1F3FB)), # Noto Emoji, lots of codepoints! ( "emoji_u1f469_1f3fd_200d_1f91d_200d_1f468_1f3ff.svg", (0x1F469, 0x1F3FD, 0x200D, 0x1F91D, 0x200D, 0x1F468, 0x1F3FF), ), # Twemoji, single codepoint ("2198.svg", (0x2198,)), # Twemoji, multiple codepoints ( "1f9d1-200d-1f91d-200d-1f9d1.svg", (0x1F9D1, 0x200D, 0x1F91D, 0x200D, 0x1F9D1), ), ], ) def test_codepoints_from_filename(filename, expected_codepoints): assert expected_codepoints == codepoints.from_filename(filename) nanoemoji-0.16.0/tests/color_glyph_test.py000066400000000000000000000456341523660054300206710ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji.colors import Color from nanoemoji.color_glyph import ColorGlyph from nanoemoji.config import FontConfig from nanoemoji.paint import * from picosvg.svg import SVG from picosvg.svg_transform import Affine2D import dataclasses import io import os import pprint import pytest import ufoLib2 # TODO test _glyph_name obeys codepoint order def _ufo(config): ufo = ufoLib2.Font() ufo.info.unitsPerEm = config.upem ufo.info.ascender = config.ascender ufo.info.descender = config.descender return ufo def _test_file(filename): return os.path.join(os.path.dirname(__file__), filename) def _nsvg(filename): return SVG.parse(_test_file(filename)).topicosvg() def _pprint(thing): stream = io.StringIO() pprint.pprint(thing, indent=2, stream=stream) return stream.getvalue() @pytest.mark.parametrize( "view_box, upem, width, ascender, descender, expected_transform, expected_width", [ # same upem, flip y ("0 0 1024 1024", 1024, 1024, 1024, 0, Affine2D(1, 0, 0, -1, 0, 1024), 1024), # noto emoji norm. scale, flip y ("0 0 128 128", 1024, 1024, 1024, 0, Affine2D(8, 0, 0, -8, 0, 1024), 1024), # noto emoji emoji_u26be.svg viewBox. Scale, flip y and translate ( "-151 297 128 128", 1024, 1024, 1024, 0, Affine2D(8, 0, 0, -8, 1208, 3400), 1024, ), # made up example. Scale, translate, flip y, center horizontally ( "10 11 20 21", 100, 100, 100, 0, Affine2D(a=4.761905, b=0, c=0, d=-4.761905, e=-45.238095, f=152.380952), 100, ), # noto emoji width, ascender, descender ( "0 0 1024 1024", 1024, 1275, 950, -250, Affine2D(1.171875, 0, 0, -1.171875, 37.5, 950), 1275, ), # wider than tall: uniformly scale by height and stretch advance width to fit ( "0 0 20 10", 100, 100, 100, 0, Affine2D(a=10, b=0, c=0, d=-10, e=0, f=100), 200, ), # taller than wide: uniformly scale by height, center within advance width ( "0 0 10 20", 100, 100, 100, 0, Affine2D(a=5, b=0, c=0, d=-5, e=25, f=100), 100, ), ], ) def test_transform_and_width( view_box, upem, width, ascender, descender, expected_transform, expected_width ): svg_str = ( '" ) config = FontConfig( upem=upem, width=width, ascender=ascender, descender=descender ).validate() ufo = _ufo(config) color_glyph = ColorGlyph.create( config, ufo, "duck", 1, "glyph_name", [0x0042], SVG.fromstring(svg_str) ) assert color_glyph.transform_for_font_space() == pytest.approx(expected_transform) assert ufo[color_glyph.ufo_glyph_name].width == expected_width def _round_coords(paint, prec=5): if isinstance(paint, PaintLinearGradient): return dataclasses.replace( paint, p0=Point(round(paint.p0.x, prec), round(paint.p0.y, prec)), p1=Point(round(paint.p1.x, prec), round(paint.p1.y, prec)), p2=Point(round(paint.p2.x, prec), round(paint.p2.y, prec)), ) if isinstance(paint, PaintRadialGradient): return dataclasses.replace( paint, c0=Point(round(paint.c0.x, prec), round(paint.c0.y, prec)), c1=Point(round(paint.c1.x, prec), round(paint.c1.y, prec)), r0=round(paint.r0, prec), r1=round(paint.r1, prec), ) if is_transform(paint): return transformed(paint.gettransform().round(prec), paint.paint) return paint @pytest.mark.parametrize( "svg_in, expected_paints", [ # solid ( "rect.svg", ( PaintGlyph( glyph="M2,2 L8,2 L8,4 L2,4 L2,2 Z", paint=PaintSolid(color=Color.fromstring("blue")), ), PaintGlyph( glyph="M4,4 L10,4 L10,6 L4,6 L4,4 Z", paint=PaintSolid(color=Color.fromstring("blue", alpha=0.8)), ), ), ), # linear ( "linear_gradient_rect.svg", ( PaintGlyph( glyph="M2,2 L8,2 L8,4 L2,4 L2,2 Z", paint=PaintLinearGradient( stops=( ColorStop(stopOffset=0.1, color=Color.fromstring("blue")), ColorStop( stopOffset=0.9, color=Color.fromstring("cyan", 0.8) ), ), p0=Point(200, 800), p1=Point(800, 800), p2=Point(200, 600), ), ), ), ), # radial on square using objectBoundingBox (no wrapping PaintTransform needed) ( "radial_gradient_square.svg", ( PaintGlyph( glyph="M0,0 L10,0 L10,10 L0,10 L0,0 Z", paint=PaintRadialGradient( extend=Extend.REPEAT, stops=( ColorStop( stopOffset=0.05, color=Color.fromstring("fuchsia") ), ColorStop( stopOffset=0.75, color=Color.fromstring("orange") ), ), c0=Point(500, 500), c1=Point(500, 500), r0=0, r1=500, ), ), ), ), # radial on non-square rect using objectBoundingBox ( "radial_gradient_rect.svg", ( PaintGlyph( glyph="M2,2 L8,2 L8,4 L2,4 L2,2 Z", paint=PaintScale( scaleX=1.0, scaleY=0.33333, paint=PaintRadialGradient( extend=Extend.REPEAT, stops=( ColorStop( stopOffset=0.05, color=Color.fromstring("fuchsia") ), ColorStop( stopOffset=0.75, color=Color.fromstring("orange") ), ), c0=Point(500, 2100), c1=Point(500, 2100), r0=0, r1=300, ), ), ), ), ), # radial with gradientTransform ( "radial_gradient_transform.svg", ( PaintGlyph( glyph="M0,0 L1000,0 L1000,1000 L0,1000 L0,0 Z", paint=PaintTransform( transform=(0.93969, 0.0, -0.34202, 0.93969, 0.0, 0.0), paint=PaintRadialGradient( stops=( ColorStop( stopOffset=0.0, color=Color.fromstring("darkblue") ), ColorStop( stopOffset=0.5, color=Color.fromstring("skyblue") ), ColorStop( stopOffset=1.0, color=Color.fromstring("darkblue") ), ), c0=Point(x=733.1865, y=532.08885), c1=Point(x=733.1865, y=532.08885), r0=0, r1=532.08885, ), ), ), ), ), # linear with gradientTransform ( "linear_gradient_transform.svg", ( PaintGlyph( glyph="M0,0 L1000,0 L1000,1000 L0,1000 L0,0 Z", paint=PaintLinearGradient( extend=Extend.REFLECT, stops=( ColorStop(stopOffset=0.0, color=Color.fromstring("green")), ColorStop(stopOffset=0.5, color=Color.fromstring("white")), ColorStop(stopOffset=1.0, color=Color.fromstring("red")), ), p0=Point(x=0, y=1000), p1=Point(x=1000, y=1000), p2=Point(x=-1000, y=0), ), ), ), ), # linear with both gradientTransform and objectBoundingBox ( "linear_gradient_transform_2.svg", ( PaintGlyph( glyph="M100,450 L900,450 L900,550 L100,550 L100,450 Z", paint=PaintLinearGradient( stops=( ColorStop(stopOffset=0.05, color=Color.fromstring("gold")), ColorStop(stopOffset=0.95, color=Color.fromstring("red")), ), p0=Point(x=100, y=550), p1=Point(x=900, y=550), p2=Point(x=100, y=450), ), ), PaintGlyph( glyph="M450,100 L550,100 L550,900 L450,900 L450,100 Z", paint=PaintLinearGradient( stops=( ColorStop(stopOffset=0.05, color=Color.fromstring("gold")), ColorStop(stopOffset=0.95, color=Color.fromstring("red")), ), p0=Point(x=450, y=900), p1=Point(x=450, y=100), p2=Point(x=350, y=900), ), ), ), ), # radial with gradientTransform with almost zero scale, non-zero skew ( "radial_gradient_transform_2.svg", ( PaintGlyph( glyph=( "M51.56,22.14 C51.56,16.32 47.74,6.55 36.02,6.55 C23.9,6.55 20.18,17.89" " 20.18,22.14 C20.18,34.96 21.33,41.31 22.6,43.93 C22.84,44.43 23.56,44.66" " 23.79,43.46 C23.79,43.46 22.89,35.69 22.79,30.15 C22.77,28.86 22.37,24.06" " 25.08,23.46 C35,21.23 40.61,15.97 40.61,15.97 C42.07,19.16 46.63,22.26" " 48.27,23.45 C49.62,24.42 49.43,28.42 49.4,30.12 L48.05,43.44 C48.05,43.44" " 48.13,46.61 49.44,43.94 C50.75,41.26 51.56,26.44 51.56,22.14 Z" ), paint=PaintTransform( transform=(0.0, -1.0, 0.9288, 0.0, 0.0, 0.0), paint=PaintRadialGradient( stops=( ColorStop( stopOffset=0.0, color=Color.fromstring("white") ), ColorStop( stopOffset=1.0, color=Color.fromstring("black") ), ), c0=Point(x=-973.125, y=301.77018), c1=Point(x=-973.125, y=301.77018), r0=0.0, r1=129.01562, ), ), ), ), ), # Shape with opacity, should apply to gradient colors # See https://github.com/googlefonts/picosvg/issues/76 ( "gradient_opacity.svg", ( PaintGlyph( glyph="M2,2 L8,2 L8,4 L2,4 L2,2 Z", paint=PaintLinearGradient( stops=( ColorStop( stopOffset=0.1, color=Color.fromstring("blue", alpha=0.4), ), ColorStop( stopOffset=0.9, color=Color.fromstring("cyan", alpha=0.4 * 0.8), ), ), p0=Point(200.0, 800.0), p1=Point(800.0, 800.0), p2=Point(200.0, 600.0), ), ), PaintGlyph( glyph="M2,5 L8,5 L8,7 L2,7 L2,5 Z", paint=PaintScale( scaleX=1.0, scaleY=0.33333, paint=PaintRadialGradient( stops=( ColorStop( stopOffset=0.1, color=Color.fromstring("red", alpha=0.5), ), ColorStop( stopOffset=0.9, color=Color.fromstring("yellow", alpha=0.5 * 0.8), ), ), c0=Point(x=500.0, y=1200.0), c1=Point(x=500.0, y=1200.0), r0=0.0, r1=300.0, ), ), ), ), ), ( # viewBox="0 0 10 8" (w > h), with a linearGradient from (1, 1) to (9, 1). # The default advance width gets scaled by aspect ratio 1000 * 10/8 == 1250. # Test that linearGradient p0 and p1 are centered horizontally relative to # the scaled advance width (and not relative to the default advance width). "gradient_non_square_viewbox.svg", ( PaintGlyph( glyph="M1,1 L9,1 L9,7 L1,7 L1,1 Z", paint=PaintLinearGradient( stops=( ColorStop(stopOffset=0.1, color=Color.fromstring("blue")), ColorStop(stopOffset=0.9, color=Color.fromstring("cyan")), ), p0=Point(125.0, 875.0), p1=Point(1125.0, 875.0), p2=Point(125.0, 125.0), ), ), ), ), # Gradient with opacity resolves to composition of a solid color with alpha # and the layer(s) in question ( "group_opacity.svg", ( PaintGlyph( glyph="M19,11 L61,11 L61,121 L19,121 Z M29,21 L29,111 L51,111 L51,21 Z", paint=PaintSolid(color=Color(red=0, green=0, blue=0, alpha=0.8)), ), PaintComposite( mode=CompositeMode.SRC_IN, source=PaintColrLayers( layers=( PaintGlyph( glyph="M10,30 L100,30 L100,120 L10,120 L10,30 Z", paint=PaintSolid( color=Color(red=255, green=0, blue=0, alpha=1.0) ), ), PaintGlyph( glyph="M5,25 L105,25 L105,125 L5,125 Z M15,35 L15,115 L95,115 L95,35 Z", paint=PaintSolid( color=Color(red=0, green=0, blue=255, alpha=1.0) ), ), ), ), backdrop=PaintSolid(color=Color(red=0, green=0, blue=0, alpha=0.6)), ), PaintGlyph( glyph=( "M105,50 Q105,67.475 100.288,80.04 Q94.678,95 85,95 Q75.322,95 69.712,80.04 Q65,67.475" " 65,50 Q65,32.525 69.712,19.96 Q75.322,5 85,5 Q94.678,5 100.288,19.96 Q105,32.525" " 105,50 Z M95,50 Q95,34.338 90.925,23.471 Q87.748,15 85,15 Q82.252,15 79.075,23.471" " Q75,34.338 75,50 Q75,65.662 79.075,76.529 Q82.252,85 85,85 Q87.748,85 90.925,76.529" " Q95,65.662 95,50 Z" ), paint=PaintSolid(color=Color(red=0, green=0, blue=0, alpha=1.0)), ), ), ), ], ) def test_color_glyph_layers(svg_in, expected_paints): config = FontConfig(upem=1000, ascender=1000, descender=0, width=1000) color_glyph = ColorGlyph.create( config, _ufo(config), "duck", 1, "g_name", [0x0042], _nsvg(svg_in) ).mutating_traverse(_round_coords) actual_paints = color_glyph.painted_layers if actual_paints != expected_paints: print("A:") print(_pprint(actual_paints)) print("E:") print(_pprint(expected_paints)) assert actual_paints == expected_paints def test_rgb_rgba_svg(): config = FontConfig(upem=1000, ascender=1000, descender=0, width=1000) color_glyph = ColorGlyph.create( config, _ufo(config), "duck", 1, "g_name", [0x0042], _nsvg("rgb_rgba.svg") ).mutating_traverse(_round_coords) assert len(color_glyph.painted_layers) == 1 paint_glyph = color_glyph.painted_layers[0] assert isinstance(paint_glyph, PaintGlyph) assert isinstance(paint_glyph.paint, PaintLinearGradient) gradient = paint_glyph.paint assert len(gradient.stops) == 3 assert gradient.stops[0] == ColorStop( stopOffset=0.0, color=Color(255, 95, 109, 1.0) ) assert gradient.stops[1] == ColorStop(stopOffset=0.5, color=Color(255, 140, 0, 1.0)) assert gradient.stops[2] == ColorStop(stopOffset=1.0, color=Color(255, 13, 29, 0.9)) # TODO test that a composite is NOT formed where paint changes nanoemoji-0.16.0/tests/colored_notdef.svg000066400000000000000000000016261523660054300204370ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/colors_test.py000066400000000000000000000200061523660054300176330ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji.colors import Color, uniq_sort_cpal_colors import pytest @pytest.mark.parametrize( "color_string, expected_color", [ # 3-hex digits ("#BCD", Color(0xBB, 0xCC, 0xDD, 1.0)), # 4-hex digits ("#BCD3", Color(0xBB, 0xCC, 0xDD, 0.2)), # 6-hex digits ("#F1E2D3", Color(0xF1, 0xE2, 0xD3, 1.0)), # 8-hex digits ("#F1E2D366", Color(0xF1, 0xE2, 0xD3, 0.4)), # CSS named color ("wheat", Color(0xF5, 0xDE, 0xB3, 1.0)), # rgb(r,g,b) ("rgb(0, 256, -1)", Color(0, 255, 0, 1.0)), ("rgb(255, 140.5, 0)", Color(255, 140, 0, 1.0)), ("rgb(100%, 0%, 0%)", Color(255, 0, 0, 1.0)), ("rgb(0%, 100%, 0%)", Color(0, 255, 0, 1.0)), ("rgb(0%, 0%, 100%)", Color(0, 0, 255, 1.0)), ("rgb(50%, 50%, 50%)", Color(128, 128, 128, 1.0)), ("RGB(255, 255, 255)", Color(255, 255, 255, 1.0)), ("rgb( 10 , 20 , 30 )", Color(10, 20, 30, 1.0)), # rgb(r g b) ("rgb(42 101 43)", Color(42, 101, 43, 1.0)), ("rgb(42 101 43 / 0.5)", Color(42, 101, 43, 0.5)), ("rgb(42 101 43 / 50%)", Color(42, 101, 43, 0.5)), # rgba(r,g,b,a) ("rgba(100%, 5%, 11.5%, 0.9)", Color(255, 13, 29, 0.9)), ("rgba(100%, 35%, 11.5%, 0.9)", Color(255, 89, 29, 0.9)), ("rgba(255, 0, 128, 0.5)", Color(255, 0, 128, 0.5)), ("rgba(255, 0, 128, 50%)", Color(255, 0, 128, 0.5)), ("rgba(0, 0, 0, 0)", Color(0, 0, 0, 0.0)), ("rgba(0, 0, 0, 1)", Color(0, 0, 0, 1.0)), ("RGBA(255, 255, 255, 0.5)", Color(255, 255, 255, 0.5)), ("rgba( -10% , 120% , 0% , 1.5 )", Color(0, 255, 0, 1.0)), ("rgba(0, 0, 0, -0.5)", Color(0, 0, 0, 0.0)), # rgba(r g b / a) ("rgba(42 101 43 / 0.75)", Color(42, 101, 43, 0.75)), ("rgba(100% 50% 25% / 80%)", Color(255, 128, 64, 0.8)), # extra whitespace as found in the noto-emoji Luxembourg flag ("#00A1DE\n", Color(0, 161, 222, 1.0)), # 'currentColor' is a special keyword ("currentColor", Color(-1, -1, -1, 1.0)), # CSS variables for CPAL palette entry indices ("var(--color0, red)", Color(255, 0, 0, 1.0, palette_index=0)), ("var(--color123, #ABCDEF)", Color(0xAB, 0xCD, 0xEF, 1.0, palette_index=123)), # CSS variables with funky whitespace (" var\t ( --color1 , yellow ) ", Color(255, 255, 0, 1.0, palette_index=1)), ], ) def test_color_fromstring(color_string, expected_color): assert expected_color == Color.fromstring(color_string) @pytest.mark.parametrize( "color, expected_string", [ # 3-hex digits (Color(0xBB, 0xCC, 0xDD, 1.0), "#BBCCDD"), # 6-hex digits (Color(0xF1, 0xE2, 0xD3, 1.0), "#F1E2D3"), # 8-hex digits (Color(0xF1, 0xE2, 0xD3, 0.4), "#F1E2D366"), # CSS named color (Color(0xF5, 0xDE, 0xB3, 1.0), "wheat"), # CSS named color skipped for alpha != 1 (Color(0xF5, 0xDE, 0xB3, 0.4), "#F5DEB366"), # special sentinel value that stands for 'currentColor' keyword (Color(-1, -1, -1, 1.0), "currentColor"), # CSS var(--color{palette_index]) when palette_index is not None (Color(255, 0, 0, 1.0, palette_index=0), "var(--color0, red)"), (Color(0xAB, 0xCD, 0xEF, 1.0, palette_index=123), "var(--color123, #ABCDEF)"), ], ) def test_color_to_string(color, expected_string): assert expected_string == color.to_string() def test_color_like_namedtuple(): color = Color(0x00, 0x11, 0x22, 1.0, 4) assert color.red == color[0] == 0x00 assert color.green == color[1] == 0x11 assert color.blue == color[2] == 0x22 assert color.alpha == color[3] == 1.0 assert color.palette_index == color[4] == 4 assert len(color) == 5 assert tuple(color) == (0x00, 0x11, 0x22, 1.0, 4) assert color[:3] == (0x00, 0x11, 0x22) assert color._replace(palette_index=5) == Color(0x00, 0x11, 0x22, 1.0, 5) @pytest.mark.parametrize( "colors, expected", [ # empty CPAL is filled with no-op color, or else Chrome rejects it pytest.param([], [Color(0, 0, 0, 1.0)], id="empty"), # colors with explicit palette_index are sorted accordingly pytest.param( [ Color(255, 0, 0, 1.0, palette_index=1), Color(0, 255, 0, 1.0, palette_index=0), ], [ Color(0, 255, 0, 1.0, palette_index=0), Color(255, 0, 0, 1.0, palette_index=1), ], id="keep-orig-index", ), # same color can appear with different indices (not viceversa) pytest.param( [ Color(255, 0, 0, 1.0, palette_index=0), Color(255, 0, 0, 1.0, palette_index=1), ], [ Color(255, 0, 0, 1.0, palette_index=0), Color(255, 0, 0, 1.0, palette_index=1), ], id="no-dedup-indexed", ), # duplicate unpalette_indexed colors are made unique pytest.param( [Color(255, 0, 0, 1.0), Color(255, 0, 0, 1.0)], [Color(255, 0, 0, 1.0)], id="dedup-unindexed", ), # unindexed colors are placed in empty slots, sorted by > RGBA pytest.param( [ Color(0, 0, 0, 1.0, palette_index=1), Color(0xFF, 0xFF, 0xFF, 1.0, palette_index=3), Color(2, 2, 2, 1.0), Color(1, 1, 1, 1.0), Color(3, 3, 3, 1.0), ], [ Color(1, 1, 1, 1.0), Color(0, 0, 0, 1.0, palette_index=1), Color(2, 2, 2, 1.0), Color(0xFF, 0xFF, 0xFF, 1.0, palette_index=3), Color(3, 3, 3, 1.0), ], id="fill-empty-slots", ), # unindexed color are never conflated with those with an explicit index # even if == RGBA value, for selecting a different palette should not # inadvertently affect colors that weren't explicitly assigned an index pytest.param( [ Color(0xFF, 0, 0, 1.0), Color(0xFF, 0, 0, 1.0, palette_index=0), ], [ Color(0xFF, 0, 0, 1.0, palette_index=0), Color(0xFF, 0, 0, 1.0), ], id="keep-unindexed-distinct", ), ], ) def test_uniq_sort_cpal_colors(colors, expected): assert uniq_sort_cpal_colors(colors) == expected def test_uniq_sort_cpal_colors_ambiguous_indices(): with pytest.raises(ValueError, match="Palette entry 1 already maps to"): uniq_sort_cpal_colors( [ Color(0, 0, 0, 1.0, palette_index=0), Color(0x80, 0, 0, 1.0, palette_index=1), Color(0, 0, 0, 1.0, palette_index=0), Color(0, 0x80, 0, 1.0, palette_index=1), ] ) def test_color_fromstring_rgb_with_alpha_arg(): assert Color(255, 0, 0, 0.5) == Color.fromstring("rgb(255, 0, 0)", alpha=0.5) @pytest.mark.parametrize( "invalid_color", [ "rgb(1, 2)", "rgba(1, 2)", "rgb(1, 2, 3, 4, 5)", "rgba(1, 2, 3, 4, 5)", "rgb(1, 2 3)", "rgb(1 2, 3)", "rgb(abc, 0, 0)", "rgba(100%, 50%)", "rgba()", "rgb()", ], ) def test_color_fromstring_invalid(invalid_color): with pytest.raises(ValueError, match="invalid or unsupported color string"): Color.fromstring(invalid_color) nanoemoji-0.16.0/tests/colr_to_svg_test.py000066400000000000000000000204421523660054300206560ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from fontTools.colorLib.builder import buildCPAL, buildColrV1, LayerListBuilder from fontTools import ttLib from fontTools.ttLib.tables import _g_l_y_f from fontTools.ttLib.ttFont import newTable from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib.tables import otTables as ot from lxml import etree from nanoemoji.colr_to_svg import _colr_v1_paint_to_svg, _new_reuse_cache from nanoemoji.util import only from picosvg.svg import SVG from picosvg.svg_transform import Affine2D import pytest from test_helper import svg_diff import textwrap def _draw_box(pen): x1, y1 = (10, 10) x2, y2 = (90, 90) pen.moveTo((x1, y1)) pen.lineTo((x2, y1)) pen.lineTo((x2, y2)) pen.lineTo((x1, y2)) pen.closePath() def _build(cls, source) -> ot.Paint: return LayerListBuilder().tableBuilder.build(cls, source) def _buildPaint(source) -> ot.Paint: return LayerListBuilder().buildPaint(source) # palette 0: black, blue SIMPLE_CPAL = [[(0, 0, 0, 1.0), (0, 0, 1, 1.0)]] # palette 1: red, green MULTI_CPAL = [SIMPLE_CPAL[0], [(1, 0, 0, 1), (0, 1, 0, 1)]] @pytest.mark.parametrize( "glyph_to_convert, color_glyphs, monochrome_glyphs, palettes, expected_svg", [ # Solid filled box ( "color_box", { "color_box": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "box", ), }, {"box": _draw_box}, SIMPLE_CPAL, """ """, ), # Paint colr glyph ( "paint_colr_glyph", { "paint_colr_glyph": (ot.PaintFormat.PaintColrGlyph, "color_box"), "color_box": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "box", ), }, {"box": _draw_box}, SIMPLE_CPAL, """ """, ), # PaintComposite as nanoemoji uses it for group opacity ( "composite", { "composite": { "Format": int(ot.PaintFormat.PaintComposite), "CompositeMode": "src_in", "SourcePaint": ( ot.PaintFormat.PaintColrLayers, [ ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0, 0.8), "box", ), ( ot.PaintFormat.PaintTranslate, ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "box", ), 10, 10, ), ], ), "BackdropPaint": { "Format": ot.PaintFormat.PaintSolid, "PaletteIndex": 0, "Alpha": 0.5, }, }, }, {"box": _draw_box}, SIMPLE_CPAL, """ """, ), # other kinds of PaintComposite are dropped for now, only BackdropPaint is kept # https://github.com/googlefonts/nanoemoji/issues/409 ( "composite", { "composite": { "Format": ot.PaintFormat.PaintComposite, "CompositeMode": "soft_light", "SourcePaint": { "Format": ot.PaintFormat.PaintLinearGradient, "ColorLine": { "Extend": "pad", "ColorStop": [(0.0, 1, 0.5), (0.5, 2, 0.5), (1.0, 3, 0.5)], }, "x0": 47, "y0": 790, "x1": 890, "y1": -342, "x2": -1085, "y2": -53, }, "BackdropPaint": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "box", ), }, }, {"box": _draw_box}, SIMPLE_CPAL, """ """, ), # len(CPAL.palettes) > 1, using var(--color{index}) ( "color_box", { "color_box": ( ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "box", ), }, {"box": _draw_box}, MULTI_CPAL, """ """, ), ], ) def test_colr_v1_paint_to_svg( glyph_to_convert, color_glyphs, monochrome_glyphs, palettes, expected_svg ): actual_svg = SVG.fromstring('') expected_svg = SVG.fromstring(textwrap.dedent(expected_svg)) # create a minimal font to play with font = ttLib.TTFont() glyf_table = font["glyf"] = newTable("glyf") glyf_table.glyphs = {".notdef": _g_l_y_f.Glyph()} hmtx_table = font["hmtx"] = newTable("hmtx") hmtx_table.metrics = {} head_table = font["head"] = newTable("head") head_table.unitsPerEm = 100 maxp_table = font["maxp"] = newTable("maxp") maxp_table.numGlyphs = 1 colr_table = font["COLR"] = newTable("COLR") colr_table.table = ot.COLR() # provide some simple shapes to play with for glyph_name, draw_fn in monochrome_glyphs.items(): font.setGlyphOrder(font.getGlyphOrder() + [glyph_name]) pen = TTGlyphPen(None) draw_fn(pen) glyph = pen.glyph() # Add to glyf glyf_table.glyphs[glyph_name] = glyph # setup hmtx glyph.recalcBounds(glyf_table) hmtx_table.metrics[glyph_name] = (head_table.unitsPerEm, glyph.xMin) print(hmtx_table.metrics) font["CPAL"] = buildCPAL(palettes) layers, base_glyphs = buildColrV1(color_glyphs) colr_table.table.LayerList = layers colr_table.table.BaseGlyphList = base_glyphs paint = only( g.Paint for g in base_glyphs.BaseGlyphPaintRecord if g.BaseGlyph == glyph_to_convert ) _colr_v1_paint_to_svg( font, font.getGlyphSet(), actual_svg.svg_root, actual_svg.xpath_one("//svg:defs"), Affine2D.identity(), paint, _new_reuse_cache(), ) svg_diff(actual_svg, expected_svg) nanoemoji-0.16.0/tests/config_test.py000066400000000000000000000051611523660054300176040ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji import config from pathlib import Path import pytest from test_helper import TEST_DATA_DIR, locate_test_file import shutil import tempfile from typing import Iterable @pytest.mark.parametrize( "config_file", [ None, # the default config file "minimal_vf/config.toml", "minimal_static/config.toml", ], ) def test_read_write_config(config_file): tmp_dir = Path(tempfile.mkdtemp()) if config_file: config_file = locate_test_file(config_file) tmp_config = tmp_dir / config_file.name else: tmp_config = tmp_dir / "the_default.toml" original = config.load(config_file) config.write(tmp_config, original) reloaded = config.load(tmp_config) assert original == reloaded shutil.rmtree(tmp_dir, ignore_errors=True) @pytest.mark.parametrize( "relative_base, src, expected_files", [ # relative single file ( TEST_DATA_DIR, "minimal_static/svg/61.svg", {locate_test_file("minimal_static/svg/61.svg")}, ), # relative pattern ( TEST_DATA_DIR, "linear_gradient_transform*.svg", { locate_test_file("linear_gradient_transform.svg"), locate_test_file("linear_gradient_transform_2.svg"), locate_test_file("linear_gradient_transform_3.svg"), }, ), # absolute single file ( TEST_DATA_DIR, locate_test_file("minimal_static/svg/61.svg").resolve(), {locate_test_file("minimal_static/svg/61.svg")}, ), # absolute pattern ( None, TEST_DATA_DIR.resolve() / "**" / "linear_gradient_transform_*.svg", { locate_test_file("linear_gradient_transform_2.svg"), locate_test_file("linear_gradient_transform_3.svg"), }, ), ], ) def test_resolve_src(relative_base, src, expected_files): assert set(config._resolve_src(relative_base, str(src))) == expected_files nanoemoji-0.16.0/tests/conftest.py000066400000000000000000000035551523660054300171320ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from absl import flags import pytest from test_helper import active_temp_dirs, cleanup_temp_dirs, forget_temp_dirs @pytest.fixture(autouse=True) def absl_flags(): flags.FLAGS(["unittest"]) # https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object outcome = yield result = outcome.get_result() attr_name = result.when + "_result" setattr(item, attr_name, result) @pytest.fixture(autouse=True, scope="function") def _cleanup_temporary_dirs(request): # The mkdtemp() docs say the user is responsible for deleting the directory # and its contents when done with it. So we use an autouse fixture that # automatically removes all the temp dirs at the end of the test module yield # teardown happens after the 'yield' if request.node.setup_result.passed and request.node.call_result.passed: print(f"Cleaning up {len(active_temp_dirs())} temp dirs") # assert False cleanup_temp_dirs() else: print( f"NOT cleaning up {len(active_temp_dirs())} temp dirs ({active_temp_dirs()}) to ease troubleshooting" ) forget_temp_dirs() nanoemoji-0.16.0/tests/cpal_color_indices.svg000066400000000000000000000006251523660054300212620ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/currentColor.svg000066400000000000000000000002031523660054300201200ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/currentColor_with_opacity.svg000066400000000000000000000002211523660054300227030ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/disjont_set_test.py000066400000000000000000000015641523660054300206670ustar00rootroot00000000000000from nanoemoji.disjoint_set import DisjointSet import pytest @pytest.mark.parametrize( "items, unions, expected_sets", [ ( range(1, 10), ((2, 3), (4, 5), (6, 7), (5, 6)), ((1,), (2, 3), (4, 5, 6, 7), (8,), (9,)), ), ( range(10), ((0, 10), (1, 9), (2, 8), (0, 1), (0, 8)), ((0, 1, 2, 8, 9, 10), (3,), (4,), (5,), (6,), (7,)), ), ( ("A", "B", "C", "D", "E", "duck", "an svg path"), (("C", "duck"), ("D", "an svg path"), ("A", "D")), (("A", "D", "an svg path"), ("B",), ("C", "duck"), ("E",)), ), ], ) def test_disjoint_set(items, unions, expected_sets): dj = DisjointSet() for item in items: dj.make_set(item) for union in unions: dj.union(*union) assert dj.sorted() == expected_sets nanoemoji-0.16.0/tests/emoji_u200c.svg000066400000000000000000000000761523660054300174630ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/emoji_u25fd.svg000066400000000000000000000005201523660054300175510ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/emoji_u263a.svg000066400000000000000000000017111523660054300174670ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/emoji_u42.svg000066400000000000000000000002551523660054300172430ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/emoji_u43.svg000066400000000000000000000004301523660054300172370ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/empty.svg000066400000000000000000000001061523660054300165770ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/extract_svgs_test.py000066400000000000000000000047251523660054300210600ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Tests for extract_svgs.py from fontTools import ttLib from nanoemoji.extract_svgs import svg_glyphs, _remove_glyph_elements from picosvg.svg import SVG import pytest from test_helper import cleanup_temp_dirs, locate_test_file, run_nanoemoji @pytest.fixture(scope="module", autouse=True) def _cleanup_temporary_dirs(): # The mkdtemp() docs say the user is responsible for deleting the directory # and its contents when done with it. So we use an autouse fixture that # automatically removes all the temp dirs at the end of the test module yield # teardown happens after the 'yield' cleanup_temp_dirs() def test_find_svg_ids(): # make a simple svg font tmp_dir = run_nanoemoji( ( "--color_format", "picosvg", locate_test_file("emoji_u42.svg"), ) ) font_file = tmp_dir / "Font.ttf" assert font_file.is_file() gids = set() font = ttLib.TTFont(font_file) for gid, raw_svg in svg_glyphs(font): gids.add(gid) assert len(gids) == 1 assert font["cmap"].getBestCmap()[0x42] == font.getGlyphName(next(iter(gids))) @pytest.mark.parametrize( "initial_svg, gids_to_remove, expected_result", [ # remove nothing from an svg that has only one glyph ("otsvg_bungee_style.svg", set(), "otsvg_bungee_style.svg"), # remove all but one from a nanoemoji-style multi-glyph svg ("otsvg_nanoemoji_style.svg", {2, 3}, "otsvg_nanoemoji_style_glyph4.svg"), ], ) def test_remove_glyph_elements(initial_svg, gids_to_remove, expected_result): initial_svg = locate_test_file(initial_svg) expected_result = locate_test_file(expected_result) actual_result = _remove_glyph_elements( SVG.parse(initial_svg), gids_to_remove ).tostring(pretty_print=True) expected_result = SVG.parse(expected_result).tostring(pretty_print=True) assert expected_result == actual_result nanoemoji-0.16.0/tests/features_test.py000066400000000000000000000011501523660054300201470ustar00rootroot00000000000000from textwrap import dedent from nanoemoji.features import generate_fea, DEFAULT_GSUB_FEATURE_TAG import pytest @pytest.mark.parametrize("feature_tag", (DEFAULT_GSUB_FEATURE_TAG, "rlig")) def test_generate_fea(feature_tag): rgi_sequences = [(0x1F64C,), (0x1F64C, 0x1F3FB), (0x1F64C, 0x1F3FC)] assert generate_fea(rgi_sequences, feature_tag=feature_tag) == dedent(f"""\ languagesystem DFLT dflt; languagesystem latn dflt; feature {feature_tag} {{ sub g_1f64c g_1f3fb by g_1f64c_1f3fb; sub g_1f64c g_1f3fc by g_1f64c_1f3fc; }} {feature_tag}; """) nanoemoji-0.16.0/tests/flipped_reused_shape.svg000066400000000000000000000002421523660054300216140ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/fonts/000077500000000000000000000000001523660054300160545ustar00rootroot00000000000000nanoemoji-0.16.0/tests/fonts/Nabla.subset.ttx000066400000000000000000002515231523660054300211460ustar00rootroot00000000000000 Copyright 2022 The Nabla Project Authors (https://github.com/justvanrossum/nabla) Nabla Regular 1.002;TYPT;Nabla-Regular Nabla Regular Version 1.002 Nabla-Regular nanoemoji-0.16.0/tests/fonts/OFL.txt000066400000000000000000000104411523660054300172350ustar00rootroot00000000000000Copyright 2022 The Nabla Project Authors (https://github.com/justvanrossum/nabla) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. nanoemoji-0.16.0/tests/glyph_reuse_test.py000066400000000000000000000041311523660054300206610ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji.config import _DEFAULT_CONFIG from nanoemoji.glyph_reuse import GlyphReuseCache, ReuseResult from picosvg.svg_transform import Affine2D import pytest # Not try to fully exercise affine_between, just to sanity check things somewhat work @pytest.mark.parametrize( "path_a, path_b, expected_result", [ ( "M-1,-1 L 0,1 L 1, -1 z", "M-2,-2 L 0,2 L 2, -2 z", ReuseResult(glyph_name="A", transform=Affine2D.identity().scale(2)), ), # https://github.com/googlefonts/nanoemoji/issues/313 ( "M818.7666015625,133.60003662109375 C818.7666015625,130.28631591796875 816.080322265625,127.5999755859375 812.7666015625,127.5999755859375 C809.4529418945312,127.5999755859375 806.7666015625,130.28631591796875 806.7666015625,133.60003662109375 C806.7666015625,136.9136962890625 809.4529418945312,139.60003662109375 812.7666015625,139.60003662109375 C816.080322265625,139.60003662109375 818.7666015625,136.9136962890625 818.7666015625,133.60003662109375 Z", "M1237.5,350 C1237.5,18.629150390625 968.870849609375,-250 637.5,-250 C306.1291198730469,-250 37.5,18.629150390625 37.5,350 C37.5,681.370849609375 306.1291198730469,950 637.5,950 C968.870849609375,950 1237.5,681.370849609375 1237.5,350 Z", None, ), ], ) def test_glyph_reuse_cache(path_a, path_b, expected_result): reuse_cache = GlyphReuseCache(_DEFAULT_CONFIG.reuse_tolerance) reuse_cache.add_glyph("A", path_a) assert reuse_cache.try_reuse(path_b) == expected_result nanoemoji-0.16.0/tests/glyphmap_games.ttx000066400000000000000000000016521523660054300204650ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/glyphmap_test.py000066400000000000000000000027361523660054300201650ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji.glyphmap import load_from, GlyphMapping from pathlib import Path import pytest import io @pytest.mark.parametrize( "glyph_mapping", ( GlyphMapping(Path("duck/file.svg"), None, (), "q"), GlyphMapping(Path("file.svg"), None, (0x32,), "q2"), GlyphMapping(Path("duck/file.svg"), None, (0x123, 0x234), "g_123_234"), # this name contains a comma, we check that csv.writer correctly escapes it GlyphMapping(Path("foo/bar,baz.svg"), None, (0x0041,), "g_41"), ), ) def test_glyphmap_round_trip(glyph_mapping): csv_line = glyph_mapping.csv_line() out = io.StringIO() # Write twice to sanity check we can load a multi-line file out.write(csv_line + "\n") out.write(csv_line + "\n") restored = load_from(io.StringIO(out.getvalue())) assert len(restored) == 2 assert restored[0] == glyph_mapping assert restored[1] == glyph_mapping nanoemoji-0.16.0/tests/gradient_non_square_viewbox.svg000066400000000000000000000005301523660054300232340ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/gradient_opacity.svg000066400000000000000000000012451523660054300207730ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/group_opacity.svg000066400000000000000000000013261523660054300203320ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/group_opacity_from_colr_1.svg000066400000000000000000000016151523660054300226150ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/group_opacity_picosvg.ttx000066400000000000000000000050351523660054300221050ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <path d="M19,11 L61,11 L61,121 L19,121 Z M29,21 L29,111 L51,111 L51,21 Z" fill="black" opacity="0.8"/> <g fill="black" opacity="0.6"> <path d="M10,30 L100,30 L100,120 L10,120 L10,30 Z" fill="red"/> <path d="M5,25 L105,25 L105,125 L5,125 Z M15,35 L15,115 L95,115 L95,35 Z" fill="blue"/> </g> <path d="M105,50 Q105,67.475 100.288,80.04 Q94.678,95 85,95 Q75.322,95 69.712,80.04 Q65,67.475 65,50 Q65,32.525 69.712,19.96 Q75.322,5 85,5 Q94.678,5 100.288,19.96 Q105,32.525 105,50 Z M95,50 Q95,34.338 90.925,23.471 Q87.748,15 85,15 Q82.252,15 79.075,23.471 Q75,34.338 75,50 Q75,65.662 79.075,76.529 Q82.252,85 85,85 Q87.748,85 90.925,76.529 Q95,65.662 95,50 Z" fill="black"/> </g> </svg> nanoemoji-0.16.0/tests/group_opacity_reuse.svg000066400000000000000000000004631523660054300215360ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/group_opacity_reuse_picosvg.ttx000066400000000000000000000043221523660054300233060ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <path d="M60,10 L80,10 L80,60 L60,60 L60,10 Z" fill="black" id="e000.0"/> <g fill="black" opacity="0.6"> <use xlink:href="#e000.0" x="-280" y="16" transform="matrix(5 0 0 0.4 1120 9.6)" fill="red"/> <use xlink:href="#e000.0" x="-140" y="26" transform="matrix(3 0 0 0.4 280 15.6)" fill="blue"/> </g> </g> </svg> nanoemoji-0.16.0/tests/involutory_matrix.svg000066400000000000000000000010711523660054300212610ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/involutory_matrix_picosvg.ttx000066400000000000000000000042711523660054300230400ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <defs> <radialGradient id="g1" fx="-32" fy="32" cx="-64" cy="64" r="64" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.99999 0 0 1 0 0)"> <stop offset="0" stop-color="red"/> <stop offset="1" stop-color="blue"/> </radialGradient> </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <path d="M0,0 L128,0 L128,128 L0,128 L0,0 Z" fill="url(#g1)"/> </g> </svg> nanoemoji-0.16.0/tests/linear_gradient_rect.svg000066400000000000000000000006071523660054300216130ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/linear_gradient_rect_colr_1.ttx000066400000000000000000000070441523660054300230740ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/linear_gradient_rect_from_colr_1.svg000066400000000000000000000005461523660054300240770ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/linear_gradient_transform.svg000066400000000000000000000011511523660054300226640ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/linear_gradient_transform_2.svg000066400000000000000000000010451523660054300231070ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/linear_gradient_transform_3.svg000066400000000000000000000011511523660054300231060ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/maximum_color_test.py000066400000000000000000000214211523660054300212070ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Integration tests for nanoemoji.maximum_color import copy from fontTools import ttLib from nanoemoji.keep_glyph_names import keep_glyph_names from nanoemoji.colr_to_svg import _FOREGROUND_COLOR_INDEX from picosvg.svg import SVG from pathlib import Path import shutil import pytest import sys from test_helper import cleanup_temp_dirs, locate_test_file, run, run_nanoemoji from typing import Tuple def _build_initial_font(color_format: str) -> Path: tmp_dir = run_nanoemoji( ( "--color_format", color_format, locate_test_file("emoji_u42.svg"), ) ) initial_font_file = tmp_dir / "Font.ttf" assert initial_font_file.is_file() return initial_font_file def _maximize_color(initial_font_file: Path, additional_flags: Tuple[str, ...]) -> Path: # Moar color out_dir = initial_font_file.parent / "maximum_color" run( ( sys.executable, "-m", "nanoemoji.maximum_color", "--build_dir", out_dir, ) + additional_flags + (initial_font_file,) ) maxmium_font_file = out_dir / "Font.ttf" assert maxmium_font_file.is_file() return maxmium_font_file @pytest.mark.parametrize( "input_file", [ "Font.ttf", # test that we don't crash when filename contains spaces "Color Font.ttf", ], ) @pytest.mark.parametrize( "color_format, expected_new_tables", [ ("picosvg", {"COLR", "CPAL"}), ("glyf_colr_1", {"SVG "}), ], ) @pytest.mark.parametrize("bitmaps", [True, False]) def test_build_maximum_font(color_format, expected_new_tables, bitmaps, input_file): initial_font_file = _build_initial_font(color_format) input_file = initial_font_file.parent / input_file initial_font_file.rename(input_file) bitmap_flag = "--nobitmaps" if bitmaps: bitmap_flag = "--bitmaps" expected_new_tables = copy.copy(expected_new_tables) expected_new_tables.update({"CBDT", "CBLC"}) maxmium_font_file = _maximize_color(input_file, (bitmap_flag,)) initial_font = ttLib.TTFont(input_file) maximum_font = ttLib.TTFont(maxmium_font_file) assert set(maximum_font.keys()) - set(initial_font.keys()) == expected_new_tables @pytest.mark.parametrize("colr_version", [None, 0, 1]) def test_build_colrv0_from_svg(colr_version): initial_font_file = _build_initial_font("picosvg") additional_flags = () if colr_version is not None: additional_flags = ("--colr_version", str(colr_version)) maxmium_font_file = _maximize_color(initial_font_file, additional_flags) initial_font = ttLib.TTFont(initial_font_file) maximum_font = ttLib.TTFont(maxmium_font_file) assert set(maximum_font.keys()) - set(initial_font.keys()) == {"COLR", "CPAL"} expected_version = 1 if colr_version is None else colr_version assert maximum_font["COLR"].version == expected_version @pytest.mark.parametrize("keep_names", [True, False]) def test_keep_glyph_names(keep_names): initial_font_file = _build_initial_font("glyf_colr_1") # set identifiable glyph names font = ttLib.TTFont(initial_font_file) keep_glyph_names(font) font.setGlyphOrder(["duck_" + gn for gn in font.getGlyphOrder()]) font.save(initial_font_file) keep_glyph_names_flag = "--keep_glyph_names" if not keep_names: keep_glyph_names_flag = "--nokeep_glyph_names" maxmium_font_file = _maximize_color(initial_font_file, (keep_glyph_names_flag,)) maximum_font = ttLib.TTFont(maxmium_font_file) if keep_names: assert all( gn.startswith("duck_") for gn in maximum_font.getGlyphOrder() ), maximum_font.getGlyphOrder() else: assert all( not gn.startswith("duck_") for gn in maximum_font.getGlyphOrder() ), maximum_font.getGlyphOrder() def test_zero_advance_width_colrv1_to_svg(): tmp_dir = run_nanoemoji( ( "--color_format", "glyf_colr_1", # use proportional widths, based on viewBox.w "--width=0", # don't clip to viewBox else zero-width glyph disappears "--noclip_to_viewbox", locate_test_file("emoji_u42.svg"), # this one has viewBox="0 0 0 1200", i.e. zero width, like # combining marks usually are (e.g. 'acutecomb') locate_test_file("u0301.svg"), ) ) initial_font_file = tmp_dir / "Font.ttf" assert initial_font_file.is_file() initial_font = ttLib.TTFont(initial_font_file) # sanity check widths are proportional and we have 2 colr glyphs assert initial_font["hmtx"]["B"] == (1200, 0) assert initial_font["hmtx"]["acutecomb"] == (0, 0) assert initial_font["COLR"].table.BaseGlyphList.BaseGlyphCount == 2 maxmium_font_file = _maximize_color(initial_font_file, ()) maximum_font = ttLib.TTFont(maxmium_font_file) assert "COLR" in maximum_font assert maximum_font["COLR"].table.BaseGlyphList.BaseGlyphCount == 2 assert "SVG " in maximum_font assert len(maximum_font["SVG "].docList) == 2 # check that 'acutecomb' still has 0 advance width assert initial_font["hmtx"]["acutecomb"] == (0, 0) # it has not been cropped away (has a non-empty bounding box) doc = maximum_font["SVG "].docList[1] assert doc.startGlyphID == doc.endGlyphID == maximum_font.getGlyphID("acutecomb") svg = SVG.fromstring(doc.data) shapes = list(svg.shapes()) assert len(shapes) == 1 bbox = shapes[0].bounding_box() assert bbox.w > 0 assert bbox.h > 0 # its bbox matches the respective COLR ClipBox dimensions (quantized to 10) clipBox = maximum_font["COLR"].table.ClipList.clips["acutecomb"] assert abs(bbox.w - (clipBox.xMax - clipBox.xMin)) <= 10 assert abs(bbox.h - (clipBox.yMax - clipBox.yMin)) <= 10 # the SVG shape's horizontal positioning also matches the respective COLR glyph assert abs(bbox.x - clipBox.xMin) <= 10 def test_foreground_colr_to_svg_currentColor(tmp_path): # Check that COLR 0xFFFF palette index for 'foreground color' gets translated # to SVG's fill="currentColor" when maximum_color'ing COLR => OT-SVG # https://github.com/googlefonts/nanoemoji/issues/405 svg_file = tmp_path / "u0041.svg" shutil.copyfile(locate_test_file("currentColor.svg"), svg_file) run_nanoemoji(("--color_format", "glyf_colr_1", svg_file), tmp_dir=tmp_path) initial_font_file = tmp_path / "Font.ttf" assert initial_font_file.is_file() initial_font = ttLib.TTFont(initial_font_file) assert "COLR" in initial_font colr = initial_font["COLR"].table assert colr.BaseGlyphList.BaseGlyphCount == 1 assert ( colr.BaseGlyphList.BaseGlyphPaintRecord[0].Paint.Paint.PaletteIndex == _FOREGROUND_COLOR_INDEX ) maxmium_font_file = _maximize_color(initial_font_file, ()) maximum_font = ttLib.TTFont(maxmium_font_file) assert "COLR" in initial_font assert "SVG " in maximum_font assert len(maximum_font["SVG "].docList) == 1 assert 'fill="currentColor"' in maximum_font["SVG "].docList[0].data def test_colr_to_svg_with_colored_notdef(tmp_path): initial_font = ttLib.TTFont() # this subset of Nabla only contains notdef, space and numbersign initial_font.importXML(locate_test_file("fonts/Nabla.subset.ttx")) initial_font_file = tmp_path / "Nabla.subset.ttf" initial_font.save(initial_font_file) maxmium_font_file = _maximize_color(initial_font_file, ()) maximum_font = ttLib.TTFont(maxmium_font_file) # check .notdef glyph is still the first glyph and that space character # follows it and has its codepoint assigned in cmap assert maximum_font.getGlyphOrder()[:3] == [".notdef", "space", "numbersign"] assert maximum_font["cmap"].getBestCmap() == {0x20: "space", ord("#"): "numbersign"} # check that SVG table contains a .notdef glyph as GID=0 in a distinct SVG document # from the one containing the numbersign assert "SVG " in maximum_font assert len(maximum_font["SVG "].docList) == 2 assert maximum_font["SVG "].docList[0].startGlyphID == 0 assert maximum_font["SVG "].docList[0].startGlyphID == 0 assert maximum_font["SVG "].docList[1].endGlyphID == 2 assert maximum_font["SVG "].docList[1].endGlyphID == 2 nanoemoji-0.16.0/tests/minimal_static/000077500000000000000000000000001523660054300177205ustar00rootroot00000000000000nanoemoji-0.16.0/tests/minimal_static/config.toml000066400000000000000000000003051523660054300220600ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "glyf_colr_1" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/config_cbdt.toml000066400000000000000000000002761523660054300230630ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "cbdt" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/config_picosvg.toml000066400000000000000000000003011523660054300236060ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "picosvg" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/config_sbix.toml000066400000000000000000000002761523660054300231140ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "sbix" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/config_sbix_3res.toml000066400000000000000000000003401523660054300240400ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "sbix" bitmap_resolutions = [32, 72, 96] [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/config_sbix_hires.toml000066400000000000000000000003361523660054300243030ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "sbix" bitmap_resolutions = [256, 512] [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/config_untouchedsvg.toml000066400000000000000000000003061523660054300246570ustar00rootroot00000000000000output_file = "Font.ttf" color_format = "untouchedsvg" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = ["svg/*.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/minimal_static/svg/000077500000000000000000000000001523660054300205175ustar00rootroot00000000000000nanoemoji-0.16.0/tests/minimal_static/svg/61.svg000066400000000000000000000003241523660054300214650ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/minimal_vf/000077500000000000000000000000001523660054300170445ustar00rootroot00000000000000nanoemoji-0.16.0/tests/minimal_vf/bold/000077500000000000000000000000001523660054300177645ustar00rootroot00000000000000nanoemoji-0.16.0/tests/minimal_vf/bold/61.svg000066400000000000000000000001541523660054300207330ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/minimal_vf/config.toml000066400000000000000000000004561523660054300212130ustar00rootroot00000000000000output_file = "MinimalVF.ttf" [axis.wght] name = "Weight" # ufo2ft fails if default is not at a master. default = 300 [master.thin] style_name = "Thin" srcs = ["thin/*.svg"] [master.thin.position] wght = 300 [master.bold] style_name = "Bold" srcs = ["bold/*.svg"] [master.bold.position] wght = 700nanoemoji-0.16.0/tests/minimal_vf/thin/000077500000000000000000000000001523660054300200065ustar00rootroot00000000000000nanoemoji-0.16.0/tests/minimal_vf/thin/61.svg000066400000000000000000000001601523660054300207520ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/multi_toml/000077500000000000000000000000001523660054300171105ustar00rootroot00000000000000nanoemoji-0.16.0/tests/multi_toml/a.toml000066400000000000000000000003331523660054300202240ustar00rootroot00000000000000 family = "A" output_file = "A.ttf" color_format = "glyf_colr_1" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = [ "a/emoji_u270d.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/multi_toml/a/000077500000000000000000000000001523660054300173305ustar00rootroot00000000000000nanoemoji-0.16.0/tests/multi_toml/a/emoji_u270d.svg000066400000000000000000000012751523660054300221020ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/multi_toml/b.toml000066400000000000000000000003331523660054300202250ustar00rootroot00000000000000 family = "B" output_file = "B.ttf" color_format = "glyf_colr_1" [axis.wght] name = "Weight" default = 400 [master.regular] style_name = "Regular" srcs = [ "b/emoji_u270d.svg"] [master.regular.position] wght = 400 nanoemoji-0.16.0/tests/multi_toml/b/000077500000000000000000000000001523660054300173315ustar00rootroot00000000000000nanoemoji-0.16.0/tests/multi_toml/b/emoji_u270d.svg000066400000000000000000000051121523660054300220750ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/nanoemoji_test.py000066400000000000000000000361261523660054300203230ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Integration tests for nanoemoji from collections import defaultdict from fontTools.ttLib import TTFont from fontTools.ttLib.tables import otTables as ot from functools import lru_cache import io from lxml import etree # pytype: disable=import-error from nanoemoji import config from nanoemoji.glyph import glyph_name from nanoemoji.util import only import operator import os from pathlib import Path from picosvg.svg import SVG from picosvg.svg_transform import Affine2D import pytest from PIL import Image import shutil import subprocess import tempfile from test_helper import ( assert_expected_ttx, bool_flag, color_font_config, locate_test_file, mkdtemp, cleanup_temp_dirs, run_nanoemoji, ) @lru_cache() def generate_font_memoized(config_file: Path, tmp_dir=None) -> TTFont: tmp_dir = run_nanoemoji((config_file,), tmp_dir=tmp_dir) font = TTFont(tmp_dir / "Font.ttf") return font def _svg_element_names(xpath, svg_content): return tuple( etree.QName(e).localname for e in SVG.fromstring(svg_content).xpath(xpath.replace("/", "/svg:")) ) def _svg_element_attributes(xpath, svg_content): return SVG.fromstring(svg_content).xpath_one(xpath.replace("/", "/svg:")).attrib def test_build_static_font_default_config_cli_svg_list(): tmp_dir = run_nanoemoji((locate_test_file("minimal_static/svg/61.svg"),)) font = TTFont(tmp_dir / "Font.ttf") assert "fvar" not in font def _build_and_check_ttx(config_overrides, svgs, expected_ttx): config_file = mkdtemp() / "config.toml" font_config, glyph_inputs = color_font_config( config_overrides, svgs, tmp_dir=config_file.parent ) del glyph_inputs config.write(config_file, font_config) print(config_file, font_config) run_nanoemoji((str(config_file),), tmp_dir=config_file.parent) font = TTFont(config_file.parent / "Font.ttf") assert_expected_ttx(svgs, font, expected_ttx) # Drop content outside viewbox # https://github.com/googlefonts/nanoemoji/issues/200 def test_build_static_font_clipped(): _build_and_check_ttx({}, ("emoji_u25fd.svg",), "outside_viewbox_clipped_colr_1.ttx") # Retain content outside viewbox # https://github.com/googlefonts/nanoemoji/issues/200 def test_build_static_font_unclipped(): _build_and_check_ttx( {"clip_to_viewbox": False}, ("emoji_u25fd.svg",), "outside_viewbox_not_clipped_colr_1.ttx", ) def test_build_variable_font(): tmp_dir = run_nanoemoji((locate_test_file("minimal_vf/config.toml"),)) font = TTFont(tmp_dir / "MinimalVF.ttf") assert "fvar" in font def test_build_picosvg_font(): tmp_dir = run_nanoemoji((locate_test_file("minimal_static/config_picosvg.toml"),)) font = TTFont(tmp_dir / "Font.ttf") # fill=none ellipse dropped, rect became path, everything is under a group svg_content = font["SVG "].docList[0][0] assert _svg_element_names("/svg/g/*", svg_content) == ("path", "path"), svg_content def test_build_untouchedsvg_font(): tmp_dir = run_nanoemoji( (locate_test_file("minimal_static/config_untouchedsvg.toml"),) ) font = TTFont(tmp_dir / "Font.ttf") assert "SVG " in font font = TTFont(tmp_dir / "Font.ttf") svg_content = font["SVG "].docList[0][0] # one group introduced assert _svg_element_names("/svg/*", svg_content) == ("g",), svg_content # rect stayed rect, fill non ellipse still around assert _svg_element_names("/svg/g/*", svg_content) == ( "path", "rect", "ellipse", ), svg_content # transform OT-SVG=>UPEM is not identity g_attrs = _svg_element_attributes("/svg/g", svg_content) assert "transform" in g_attrs transform = Affine2D.fromstring(g_attrs["transform"]) assert transform != Affine2D.identity(), transform def _assert_table_size_cmp(table_tag, op, original_font, config_file, **options): cmd = (config_file,) + tuple(bool_flag(k, v) for k, v in options.items()) tmp_dir = run_nanoemoji(cmd) font = TTFont(only(tmp_dir.glob("*.ttf"))) new_size = len(font.getTableData(table_tag)) original_size = len(original_font.getTableData(table_tag)) assert op( new_size, original_size ), f"{table_tag} was {original_size}, now {new_size}" @pytest.mark.parametrize("use_pngquant", [True, False]) @pytest.mark.parametrize("use_zopflipng", [True, False]) def test_build_sbix_font(use_pngquant, use_zopflipng): config_file = locate_test_file("minimal_static/config_sbix.toml") font = generate_font_memoized(config_file) assert "sbix" in font # check building the same font without zopflipng/pngquant produces a larger table if not use_zopflipng or not use_pngquant: _assert_table_size_cmp( "sbix", operator.gt, font, config_file, use_pngquant=use_pngquant, use_zopflipng=use_zopflipng, ) def test_build_sbix_font_3res(): config_file = locate_test_file("minimal_static/config_sbix_3res.toml") font = generate_font_memoized(config_file) assert "sbix" in font sbix = font["sbix"] count_by_ppem_resolution = defaultdict(int) for ppem, strike in sbix.strikes.items(): for glyph_name, glyph in strike.glyphs.items(): if glyph.imageData is None: continue image = Image.open(io.BytesIO(glyph.imageData)) w, h = image.size assert w == h, f"{glyph_name} has non-square size {w} x {h}" count_by_ppem_resolution[(ppem, w)] += 1 # (ppem, glyph_count) tuples # the weird ppem is adjusted from 32, 72, 96 assert dict(count_by_ppem_resolution) == { (27, 32): 1, (61, 72): 1, (82, 96): 1, }, f"{count_by_ppem_resolution}" def test_build_sbix_font_hires(): config_file = locate_test_file("minimal_static/config_sbix_hires.toml") font = generate_font_memoized(config_file) assert "sbix" in font sbix = font["sbix"] count_by_ppem_resolution = defaultdict(int) for ppem, strike in sbix.strikes.items(): for glyph_name, glyph in strike.glyphs.items(): if glyph.imageData is None: continue image = Image.open(io.BytesIO(glyph.imageData)) w, h = image.size assert w == h, f"{glyph_name} has non-square size {w} x {h}" count_by_ppem_resolution[(ppem, w)] += 1 # There should be two (ppem, resolution) values, each with one value assert all( res > 128 for _, res in count_by_ppem_resolution.keys() ), f"{count_by_ppem_resolution}" assert len(count_by_ppem_resolution) == 2, f"{count_by_ppem_resolution}" assert set(count_by_ppem_resolution.values()) == {1}, f"{count_by_ppem_resolution}" @pytest.mark.parametrize("use_pngquant", [True, False]) @pytest.mark.parametrize("use_zopflipng", [True, False]) def test_build_cbdt_font(use_pngquant, use_zopflipng): config_file = locate_test_file("minimal_static/config_cbdt.toml") font = generate_font_memoized(config_file) assert "CBDT" in font assert "CBLC" in font # check building the same font without zopflipng/pngquant produces a larger table if not use_zopflipng or not use_pngquant: _assert_table_size_cmp( "CBDT", operator.gt, font, config_file, use_pngquant=use_pngquant, use_zopflipng=use_zopflipng, ) @pytest.mark.parametrize( "config_file", [ # https://github.com/googlefonts/nanoemoji/issues/385 "cbdt/config.toml", ], ) @pytest.mark.parametrize("use_pngquant", [True, False]) @pytest.mark.parametrize("use_zopflipng", [True, False]) def test_build_compat_font(config_file, use_pngquant, use_zopflipng): config_file = locate_test_file(config_file) font = generate_font_memoized(config_file) assert "CBDT" in font assert "CBLC" in font # check building the same font without zopflipng/pngquant produces larger bitmaps if not use_zopflipng or not use_pngquant: _assert_table_size_cmp( "CBDT", operator.gt, font, config_file, use_pngquant=use_pngquant, use_zopflipng=use_zopflipng, ) def test_the_curious_case_of_the_parentless_reused_el(): # https://github.com/googlefonts/nanoemoji/issues/346 svgs = [ f"parentless_reused_el/emoji_u{codepoints}.svg" for codepoints in ("0023_20e3", "1f170", "1f171") ] tmp_dir = run_nanoemoji( ( "--color_format=picosvg", "--pretty_print", "--keep_glyph_names", *(locate_test_file(svg) for svg in svgs), ) ) font = TTFont(tmp_dir / "Font.ttf") assert_expected_ttx( svgs, font, "parentless_reused_el.ttx", include_tables=["GlyphOrder", "SVG "] ) def test_glyphmap_games(): # https://github.com/googlefonts/nanoemoji/issues/354 # We want to see both glyphs but only one cmap'd, and the use of our special naming scheme svgs = [ "emoji_u25fd.svg", "emoji_u42.svg", ] tmp_dir = run_nanoemoji( ( "--color_format=glyf_colr_1", "--keep_glyph_names", "--glyphmap_generator=write_test_glyphmap", *(locate_test_file(svg) for svg in svgs), ) ) font = TTFont(tmp_dir / "Font.ttf") # We don't really need glyf but ... perhaps it's informative assert_expected_ttx( svgs, font, "glyphmap_games.ttx", include_tables=["GlyphOrder", "cmap"] ) @pytest.mark.parametrize( "color_format, expected_ttx", [ ("glyf_colr_1", "omit_empty_color_glyphs_colr.ttx"), ("picosvg", "omit_empty_color_glyphs_svg.ttx"), ], ) def test_omit_empty_color_glyphs(color_format, expected_ttx): svgs = [ "emoji_u200c.svg", # whitespace glyph, contains no paths "emoji_u42.svg", ] tmp_dir = run_nanoemoji( ( f"--color_format={color_format}", "--pretty_print", "--keep_glyph_names", *(locate_test_file(svg) for svg in svgs), ) ) font = TTFont(tmp_dir / "Font.ttf") if "COLR" in font: colr = font["COLR"].table assert len(colr.BaseGlyphList.BaseGlyphPaintRecord) == 1 else: svg = font["SVG "] assert len(svg.docList) == 1 assert_expected_ttx( svgs, font, expected_ttx, include_tables=["GlyphOrder", "cmap", "glyf", "COLR", "SVG "], ) # https://github.com/googlefonts/nanoemoji/issues/367 def test_path_to_src_matters(): def _glyph(font): assert font["COLR"].version == 1 colr_table = font["COLR"].table assert colr_table.BaseGlyphList.BaseGlyphCount == 1 paint = colr_table.BaseGlyphList.BaseGlyphPaintRecord[0].Paint assert paint.Format == ot.PaintFormat.PaintGlyph return font["glyf"][paint.Glyph] tomls = [ "multi_toml/a.toml", "multi_toml/b.toml", ] tmp_dir = run_nanoemoji(tuple(locate_test_file(toml) for toml in tomls)) font_a = TTFont(tmp_dir / "A.ttf") font_b = TTFont(tmp_dir / "B.ttf") # Each font should define a single PaintGlyph and the glyph it uses shouldn't be identical assert _glyph(font_a) != _glyph(font_b) def test_input_symlinks_support(tmp_path): # Symbolic links are not resolved but treated as distinct input files. shutil.copyfile(locate_test_file("emoji_u42.svg"), tmp_path / "emoji_u42.svg") # $ ln -s emoji_u43.svg emoji_u42.svg (tmp_path / "emoji_u43.svg").symlink_to(tmp_path / "emoji_u42.svg") # $ ln -s emoji_u66_69.svg emoji_u42.svg (tmp_path / "emoji_u66_69.svg").symlink_to(tmp_path / "emoji_u42.svg") run_nanoemoji( ( tmp_path / "emoji_u42.svg", # glyph 'B' tmp_path / "emoji_u43.svg", # glyph 'C' tmp_path / "emoji_u66_69.svg", # ligature 'f_i' "--keep_glyph_names", ), tmp_dir=tmp_path, ) font = TTFont(tmp_path / "Font.ttf") colr_table = font["COLR"].table # check we get three identical color glyphs with the same Paint assert colr_table.BaseGlyphList.BaseGlyphCount == 3 assert colr_table.BaseGlyphList.BaseGlyphPaintRecord[0].BaseGlyph == "B" assert colr_table.BaseGlyphList.BaseGlyphPaintRecord[1].BaseGlyph == "C" assert colr_table.BaseGlyphList.BaseGlyphPaintRecord[2].BaseGlyph == "f_i" assert ( colr_table.BaseGlyphList.BaseGlyphPaintRecord[0].Paint == (colr_table.BaseGlyphList.BaseGlyphPaintRecord[1].Paint) == colr_table.BaseGlyphList.BaseGlyphPaintRecord[2].Paint ) # check that the symlinked ligature was built as usual ligatures = font["GSUB"].table.LookupList.Lookup[0].SubTable[0].ligatures assert "f" in ligatures assert len(ligatures["f"]) == 1 assert ligatures["f"][0].Component == ["i"] assert ligatures["f"][0].LigGlyph == "f_i" @pytest.mark.parametrize( "color_format", [ "glyf_colr_1", "picosvg", # TODO: This is disabled because CBDT currently forces width=0 to xMax # https://github.com/googlefonts/nanoemoji/issues/404 # "cbdt", ], ) def test_glyph_with_zero_advance_width(color_format, tmp_path): cp = 0x0301 gname = glyph_name(cp) acutecomb_svg = locate_test_file(f"u{cp:04x}.svg") assert SVG.parse(acutecomb_svg).view_box() == (0, 0, 0, 1200) run_nanoemoji( ( acutecomb_svg, "--width=0", "--noclip_to_viewbox", "--keep_glyph_names", f"--color_format={color_format}", ), tmp_dir=tmp_path, ) font = TTFont(tmp_path / "Font.ttf") if "COLR" in font: colr = font["COLR"].table assert len(colr.BaseGlyphList.BaseGlyphPaintRecord) == 1 assert colr.BaseGlyphList.BaseGlyphPaintRecord[0].BaseGlyph == gname if "SVG " in font: svg = font["SVG "] assert len(svg.docList) == 1 gid = font.getGlyphID(gname) assert svg.docList[0][1:] == (gid, gid) # (start, end) if "CBDT" in font: cbdt = font["CBDT"] assert len(cbdt.strikeData) == 1 assert list(cbdt.strikeData[0].keys()) == [gname] assert font["hmtx"][gname] == (0, 0) @pytest.mark.parametrize("font_type", ["static", "vf"]) def test_build_output_file_with_spaces(font_type): tmp_dir = run_nanoemoji( ( locate_test_file(f"minimal_{font_type}/config.toml"), "--output_file", "Font With Spaces.ttf", ) ) output_file = tmp_dir / "Font With Spaces.ttf" assert output_file.is_file() assert "COLR" in TTFont(output_file) nanoemoji-0.16.0/tests/narrow_rects/000077500000000000000000000000001523660054300174335ustar00rootroot00000000000000nanoemoji-0.16.0/tests/narrow_rects/a.svg000066400000000000000000000001721523660054300203740ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/narrow_rects/b.svg000066400000000000000000000001721523660054300203750ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/narrow_rects/c.svg000066400000000000000000000001751523660054300204010ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/omit_empty_color_glyphs_colr.ttx000066400000000000000000000054041523660054300234600ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/omit_empty_color_glyphs_svg.ttx000066400000000000000000000037151523660054300233230ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <g id="glyph2" transform="matrix(120 0 0 120 37.5 -950)"> <path d="M4,4 L8,4 L8,8 L4,8 L4,4 Z" fill="orange"/> </g> </svg> nanoemoji-0.16.0/tests/one-o-clock.svg000066400000000000000000000022661523660054300175600ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/one_rect.svg000066400000000000000000000002541523660054300172430ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/one_rect_glyf.ttx000066400000000000000000000035331523660054300203070ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/one_rect_transformed.ttx000066400000000000000000000056351523660054300216770ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/otsvg_bungee_style.svg000066400000000000000000000003011523660054300213450ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/otsvg_nanoemoji_style.svg000066400000000000000000000010411523660054300220610ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/otsvg_nanoemoji_style_glyph4.svg000066400000000000000000000004731523660054300233600ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/outside_viewbox_clipped_colr_1.ttx000066400000000000000000000101511523660054300236400ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/outside_viewbox_not_clipped_colr_1.ttx000066400000000000000000000112431523660054300245230ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/paint_test.py000066400000000000000000000162071523660054300174550ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from fontTools.ttLib.tables import otTables as ot from nanoemoji.paint import Color, _decompose_uniform_transform from nanoemoji.paint import * import pytest @pytest.mark.parametrize( "paint, colors, expected_ufo_paint", [ ( PaintSolid(), [Color.fromstring("black")], { "Format": ot.PaintFormat.PaintSolid, "PaletteIndex": 0, "Alpha": 1, }, ), ], ) def test_to_ufo_paint(paint, colors, expected_ufo_paint): assert paint.to_ufo_paint(colors) == expected_ufo_paint @pytest.mark.parametrize( "input_point, paint, expected_point", [ # No modification should occur ( Point(1, 1), PaintSolid(), Point(1, 1), ), # Rotate 1,0 90 degrees around origin ( Point(1, 0), PaintRotate( paint=PaintSolid(), angle=90, ), Point(0, 1), ), # Rotate 1,0 90 degrees around 2,1 ( Point(1, 0), PaintRotateAroundCenter( paint=PaintSolid(), angle=90, center=Point(2, 0), ), Point(2, -1), ), # x-skew 0,1 45 degrees around origin ( Point(0, 1), PaintSkew( paint=PaintSolid(), xSkewAngle=45, ySkewAngle=0, ), Point(-1, 1), ), # 2-skew 0, 1 45 degrees around (0, 1) ( Point(0, 1), PaintSkewAroundCenter( paint=PaintSolid(), xSkewAngle=45, ySkewAngle=0, center=Point(0, 1), ), Point(0, 1), ), ], ) def test_gettransform(input_point, paint, expected_point): assert paint.gettransform().map_point(input_point).round(6) == expected_point @pytest.mark.parametrize( "transform, target, expected_result", [ # Nop ( Affine2D.identity(), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), ), # Int16 translation (expected to be typical case) ( Affine2D.fromstring("translate(-5, 10)"), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintTranslate( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), dx=-5, dy=10, ), ), # Non-Int16 translation ( Affine2D.fromstring("translate(-5.5, 10)"), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintTransform( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), transform=(1, 0, 0, 1, -5.5, 10), ), ), # Uniform scaling ( Affine2D.fromstring("scale(1.75)"), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintScaleUniform( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), scale=1.75, ), ), # Non-uniform scaling ( Affine2D.fromstring("scale(1.75, 1.5)"), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintScale( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), scaleX=1.75, scaleY=1.5, ), ), # Scaling unsafe for f2dot14 ( Affine2D.fromstring("scale(3.1)"), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintTransform( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), transform=(3.1, 0, 0, 3.1, 0, 0), ), ), # PaintScaleAroundCenter ( Affine2D(0.5, 0, 0, 1.5, 30, 40), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintScaleAroundCenter( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), scaleX=0.5, scaleY=1.5, center=Point(60, -80), ), ), # PaintScaleAroundCenter whilst avoiding / 0 ( Affine2D(1, 0, 0, 0.5, 0, 100), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintScaleAroundCenter( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), scaleX=1, scaleY=0.5, center=Point(0, 200), ), ), # Do NOT PaintScaleAroundCenter when translation isn't ~int ( Affine2D(0.45, 0, 0, 1.5, 30, 40), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintTransform( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), transform=(0.45, 0, 0, 1.5, 30, 40), ), ), # PaintScaleUniformAroundCenter ( Affine2D(0.75, 0, 0, 0.75, -20, 40), PaintGlyph(glyph="A Glyph", paint=PaintSolid()), PaintScaleUniformAroundCenter( paint=PaintGlyph(glyph="A Glyph", paint=PaintSolid()), scale=0.75, center=Point(-80, 160), ), ), ], ) def test_transformed(transform, target, expected_result): assert transformed(transform, target) == expected_result @pytest.mark.parametrize( "transform, expected_uniform_transform, expected_remaining_transform", [ ( Affine2D(2, 0, 0, 1, 0, 0), Affine2D(2, 0, 0, 2, 0, 0), Affine2D(1, 0, 0, 0.5, 0, 0), ), ( Affine2D(8, 0, 0, -8, 0, 1024), Affine2D(8, 0, 0, -8, 0, 1024), Affine2D(1, 0, 0, 1, 0, 0), ), ( Affine2D.fromstring("rotate(-90) translate(50, -100)"), Affine2D(1, 0, 0, 1, 50, -100), Affine2D(0, -1.0, 1.0, 0, 0, 0), ), ( Affine2D.fromstring("rotate(90) scale(2)"), Affine2D(2, 0, 0, 2, 0, 0), Affine2D(0, 1.0, -1.0, 0, 0, 0), ), ( Affine2D.fromstring("translate(50, -100) scale(2) rotate(90)"), Affine2D(2, 0, 0, 2, -100, -50), Affine2D(0, 1.0, -1.0, 0, 0, 0), ), ], ) def test_decompose_uniform_transform( transform, expected_uniform_transform, expected_remaining_transform ): assert _decompose_uniform_transform(transform) == ( expected_uniform_transform, expected_remaining_transform, ) nanoemoji-0.16.0/tests/parentless_reused_el.ttx000066400000000000000000000021521523660054300216730ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M0,0 L64,0 L64,64 L0,64 L0,0 Z" id="g_23_20e3.0"/> </defs> <g id="glyph4" transform="matrix(9.375 0 0 9.375 37.5 -950)"> <use xlink:href="#g_23_20e3.0" x="32" y="32" fill="red"/> </g> <g id="glyph5" transform="matrix(9.375 0 0 9.375 37.5 -950)"> <use xlink:href="#g_23_20e3.0" x="64" y="64" fill="blue"/> </g> <g id="glyph6" transform="matrix(9.375 0 0 9.375 37.5 -950)"> <use xlink:href="#g_23_20e3.0" fill="yellow"/> </g> </svg> nanoemoji-0.16.0/tests/parentless_reused_el/000077500000000000000000000000001523660054300211325ustar00rootroot00000000000000nanoemoji-0.16.0/tests/parentless_reused_el/emoji_u0023_20e3.svg000066400000000000000000000002031523660054300243330ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/parentless_reused_el/emoji_u1f170.svg000066400000000000000000000002161523660054300237600ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/parentless_reused_el/emoji_u1f171.svg000066400000000000000000000002171523660054300237620ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/parts_test.py000066400000000000000000000223111523660054300174640ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from nanoemoji.parts import ReusableParts from nanoemoji.util import only from picosvg.geometric_types import Rect from picosvg.svg import SVG from picosvg import svg_meta from picosvg.svg_types import SVGCircle, SVGPath, SVGRect from picosvg.svg_reuse import affine_between from pathlib import Path import pprint import pytest import re from test_helper import cleanup_temp_dirs, locate_test_file, mkdtemp @pytest.fixture(scope="module", autouse=True) def _cleanup_temporary_dirs(): # The mkdtemp() docs say the user is responsible for deleting the directory # and its contents when done with it. So we use an autouse fixture that # automatically removes all the temp dirs at the end of the test module yield # teardown happens after the 'yield' cleanup_temp_dirs() # BUG? rect(2,1) and rect(1,2) do NOT normalize the same. # TODO we get pointless precision, e.g. 1.2000000000000002 def _svg_commands(path: str) -> str: print(path) svg_cmds = "".join(svg_meta.cmds()) return re.sub(f"[^{svg_cmds}]+", "", path) def check_num_shapes(parts: ReusableParts, expected_shape_sets: int): assert len(parts.shape_sets) == expected_shape_sets, ",".join( sorted(str(p) for p in parts.shape_sets.keys()) ) def _from_svg(svg, view_box=None) -> ReusableParts: if isinstance(svg, str): svg = SVG.fromstring(svg) elif isinstance(svg, Path): svg = SVG.parse(svg) if view_box is None: view_box = svg.view_box() parts = ReusableParts(view_box=view_box) parts.add(svg) return parts def test_add_svg(): parts = _from_svg(""" """) check_num_shapes(parts, 1) def test_collects_normalized_shapes(): parts = _from_svg(""" """) check_num_shapes(parts, 2) def test_simple_merge(): p1 = _from_svg(""" """) check_num_shapes(p1, 1) p2 = _from_svg(""" """) check_num_shapes(p2, 2) p1.add(p2) check_num_shapes(p1, 2) def test_file_io(): parts = _from_svg(locate_test_file("rect.svg")) check_num_shapes(parts, 1) tmp_dir = mkdtemp() tmp_file = tmp_dir / "rect.json" tmp_file.write_text(parts.to_json()) assert parts == ReusableParts.loadjson(tmp_file), parts.to_json() # Note that this is not meant to be the primary test of reuse, that's in # picosvg. This just checks we use those capabilities in the expected manner. @pytest.mark.parametrize( "svg", [ SVG.fromstring(""" """).topicosvg(), # https://github.com/googlefonts/nanoemoji/issues/415 arc normalization SVG.fromstring(""" """).topicosvg(), ], ) def test_reuse_finds_single_donor(svg): parts = _from_svg(svg.tostring()) # There should be one shape used to create all the others maybe_reuses = [parts.try_reuse(s.as_path()) for s in svg.shapes()] assert all(ri is not None for ri in maybe_reuses), "All shapes should have results" scale_up = { ri for ri in maybe_reuses if not all(v <= 1.0 for v in ri.transform.getscale()) } assert not scale_up, f"Should prefer to scale big to little {scale_up}" assert ( len({ri.shape for ri in maybe_reuses}) == 1 ), f"{maybe_reuses} should all reuse the same shape" # Feed in two identical svgs, just one of them multiplies viewbox and coords by 10 def test_reuse_with_inconsistent_square_viewbox(): little = locate_test_file("rect.svg") big = locate_test_file("rect_10x.svg") r1 = _from_svg(little) assert r1.view_box == Rect(0, 0, 10, 10) r1.add(_from_svg(big)) r1.compute_donors() r2 = _from_svg(big) assert r2.view_box == Rect(0, 0, 100, 100) r2.add(_from_svg(little)) r2.compute_donors() check_num_shapes(r1, 1) check_num_shapes(r2, 1) assert only(r1.shape_sets.values()) == { "M2,2 L8,2 L8,4 L2,4 L2,2 Z", "M4,4 L10,4 L10,6 L4,6 L4,4 Z", }, "There should be 2 (not 4) shapes after scaled merge. r1 should use the little viewbox." assert only(r2.shape_sets.values()) == { "M20,20 L80,20 L80,40 L20,40 L20,20 Z", "M40,40 L100,40 L100,60 L40,60 L40,40 Z", }, "There should be 2 (not 4) shapes after scaled merge. r2 should use the big viewbox." def test_arcs_become_cubics(): parts = _from_svg(""" """) norm, path = only(parts.shape_sets.items()) path = only(path) assert (_svg_commands(norm), _svg_commands(path)) == ( "Mccccz", "MCCCCZ", ), f"Wrong command types\nnorm {norm}\npath {path}" # scaling turns arcs into cubics # we need them to reuse regardless def test_scaled_merge_arcs_to_cubics(): parts = _from_svg(locate_test_file("circle_10x.svg")) part2 = _from_svg(locate_test_file("circle.svg")) assert parts.view_box == Rect(0, 0, 100, 100) assert part2.view_box == Rect(0, 0, 10, 10) parts.add(part2) assert len(parts.shape_sets) == 1, parts.to_json() norm, paths = only(parts.shape_sets.items()) path_cmds = tuple(_svg_commands(p) for p in paths) assert (_svg_commands(norm),) + path_cmds == ( "Mccccz", "MCCCCZ", "MCCCCZ", ), f"Path damaged\nnorm {norm}\npaths {paths}" def _start_at_origin(path): cmd, args = next(iter(path)) assert cmd == "M" x, y = args return path.move(-x, -y) # SVGs with varied width that contains squares should push squares # into the part store, not get mangled into rectangles. def test_squares_stay_squares(): parts = ReusableParts(view_box=Rect(0, 0, 10, 10)) parts.add(SVG.parse(locate_test_file("square_vbox_narrow.svg"))) parts.add(SVG.parse(locate_test_file("square_vbox_square.svg"))) parts.add(SVG.parse(locate_test_file("square_vbox_narrow.svg"))) # Every square should have normalized the same assert len(parts.shape_sets) == 1, parts.to_json() paths = only(parts.shape_sets.values()) paths = [_start_at_origin(SVGPath(d=p)).relative(inplace=True) for p in paths] assert {p.d for p in paths} == { "M0,0 l3,0 l0,3 l-3,0 l0,-3 z" }, "The square should remain 3x3; converted to relative and starting at 0,0 they should be identical" def test_negative_tolerance_no_reuse(): """reuse_tolerance=-1 means no reuse: shapes that would normally be grouped together (same rects at different scales) must stay in separate shape sets, and compute_donors must not crash (it used to raise ZeroDivisionError because _compute_donor passed -1 tolerance through to affine_between). See https://github.com/googlefonts/picosvg/pull/334""" svg = SVG.fromstring(""" """).topicosvg() # With positive tolerance these two rects normalize to the same shape reuse_parts = ReusableParts(view_box=Rect(0, 0, 10, 10), reuse_tolerance=0.1) reuse_parts.add(svg) assert len(reuse_parts.shape_sets) == 1, "rects should be reusable" # With -1 tolerance each shape is its own group, no reuse no_reuse_parts = ReusableParts(view_box=Rect(0, 0, 10, 10), reuse_tolerance=-1) no_reuse_parts.add(svg) assert len(no_reuse_parts.shape_sets) == 2, "rects should NOT be grouped" # Each shape set has exactly one member that is its own donor no_reuse_parts.compute_donors() for norm, shapes in no_reuse_parts.shape_sets.items(): assert len(shapes) == 1 assert no_reuse_parts._donor_cache[norm] == only(shapes) nanoemoji-0.16.0/tests/png_test.py000066400000000000000000000012171523660054300171210ustar00rootroot00000000000000from nanoemoji.png import PNG import pytest def test_good_png_signature(): # example from https://en.wikipedia.org/wiki/Portable_Network_Graphics data = bytes( int(b, 16) for b in ( "89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 " "00 00 00 01 00 00 00 01 08 02 00 00 00 90 77 53 " "DE 00 00 00 0C 49 44 41 54 08 D7 63 F8 CF C0 00 " "00 03 01 01 00 18 DD 8D B0 00 00 00 00 49 45 4E " "44 AE 42 60 82" ).split() ) assert PNG(data) == data def test_bad_png_signature(): with pytest.raises(ValueError, match="bad signature"): PNG(b"
nanoemoji-0.16.0/tests/radial_gradient_rect.svg000066400000000000000000000006061523660054300215740ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/radial_gradient_rect_colr_1.ttx000066400000000000000000000073411523660054300230560ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/radial_gradient_rect_from_colr_1.svg000066400000000000000000000006301523660054300240530ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/radial_gradient_square.svg000066400000000000000000000006111523660054300221330ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/radial_gradient_transform.svg000066400000000000000000000010431523660054300226460ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/radial_gradient_transform_2.svg000066400000000000000000000016021523660054300230700ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect.svg000066400000000000000000000003621523660054300164020ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect2.svg000066400000000000000000000003701523660054300164630ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_10.svg000066400000000000000000000001621523660054300167000ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_1000.svg000066400000000000000000000002521523660054300170400ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_10x.svg000066400000000000000000000003741523660054300170750ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_cbdt.ttx000066400000000000000000000072361523660054300174250ustar00rootroot00000000000000
nanoemoji-0.16.0/tests/rect_colr_0.ttx000066400000000000000000000053431523660054300176640ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_colr_1.ttx000066400000000000000000000067201523660054300176650ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_from_colr_0.svg000066400000000000000000000002761523660054300206670ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_from_colr_1.svg000066400000000000000000000003271523660054300206650ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rect_picosvg.ttx000066400000000000000000000040711523660054300201550ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M2,2 L8,2 L8,4 L2,4 L2,2 Z" id="e000.0" fill="blue"/> </defs> <g id="glyph2" transform="matrix(10 0 0 10 0 -100)"> <use xlink:href="#e000.0"/> <use xlink:href="#e000.0" x="2" y="2" opacity="0.8"/> </g> </svg> nanoemoji-0.16.0/tests/rect_sbix.ttx000066400000000000000000000037061523660054300174540ustar00rootroot00000000000000 e000.png nanoemoji-0.16.0/tests/rect_untouchedsvg.ttx000066400000000000000000000036761523660054300212330ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg"><g id="glyph2" transform="matrix(10 0 0 10 0 -100)"><rect x="2" y="2" width="6" height="2" fill="blue"/><rect x="4" y="4" width="6" height="2" fill="blue" opacity="0.8"/></g></svg> nanoemoji-0.16.0/tests/rects_colr_1.ttx000066400000000000000000000111721523660054300200450ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reorder_glyphs_test.py000066400000000000000000000125201523660054300213640ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from fontTools import ttLib from functools import reduce from nanoemoji.reorder_glyphs import ( _REORDER_RULES, ReorderCoverage, ReorderList, _sort_by_gid, reorder_glyphs, ) from nanoemoji.util import load_fully from nanoemoji import write_font from nanoemoji.util import only import os from pathlib import Path import pytest import tempfile import test_helper def _dotted_converter(item, dotted_attr): attr_names = dotted_attr.split(".") assert attr_names while attr_names: attr_name = attr_names.pop(0) item = item.getConverterByName(attr_name) # Do we have to descend? if attr_names: item = item.tableClass() return item def test_metadata_is_valid(): for (clazz, fmt), reorders in _REORDER_RULES.items(): instance = clazz() instance.Format = fmt assert ( instance.getConverters() ), f"Lack of converters suggests {clazz} dislikes Format {fmt}" for reorder in reorders: if isinstance(reorder, ReorderCoverage): assert ( _dotted_converter(instance, reorder.coverage_attr) is not None ), f"No {clazz} {fmt} {reorder.coverage_attr}" if reorder.parallel_list_attr: assert ( _dotted_converter(instance, reorder.parallel_list_attr) is not None ), f"No {clazz} {fmt} {reorder.parallel_list_attr}" elif isinstance(reorder, ReorderList): assert ( _dotted_converter(instance, reorder.list_attr) is not None ), f"No {clazz} {fmt} {reorder.list_attr}" else: raise NotImplementedError(type(reorder)) def test_sort_just_glyphs(): glyphs = ["a", "b", "c", "d"] gids = [42, 0, 4, 1] _sort_by_gid(lambda gn: gids[glyphs.index(gn)], glyphs, None) assert glyphs == ["b", "d", "c", "a"] def test_sort_parallel_list(): glyphs = ["a", "b", "c", "d"] parallel = ["aa", "bb", "cc", "dd"] gids = [0, 42, 16, 2] _sort_by_gid(lambda gn: gids[glyphs.index(gn)], glyphs, parallel) assert glyphs == ["a", "d", "c", "b"] assert parallel == ["aa", "dd", "cc", "bb"] def test_reorder_actual_font(): def _pair_pos(font): # Initial state should be we have a GPOS with PairPos lookup for ab, ac pair_pos = only( reduce( lambda a, c: a + c.SubTable, font["GPOS"].table.LookupList.Lookup, [] ) ) return tuple( ( pair_pos.Coverage.glyphs[i], [ (pvr.SecondGlyph, pvr.Value1.XAdvance) for pvr in pair_pos.PairSet[i].PairValueRecord ], ) for i, pair_set in enumerate(pair_pos.PairSet) ) # tell the boxes to get closer together when ordered ab # use GPOS, PairPos Format1 which has Coverage and a Value, a parallel sorted List[ValueRecord] fea = r""" languagesystem DFLT dflt; languagesystem latn dflt; feature kern { position a b -12; position b b -14; position b c -16; } kern; """ with tempfile.TemporaryDirectory() as temp_dir: fea_file = Path(temp_dir) / "fea.fea" fea_file.write_text(fea) # upem 24, input svgs are on a 0 0 24 24 viewBox svgs = tuple( test_helper.locate_test_file(f"narrow_rects/{c}.svg") for c in "abc" ) config, glyph_inputs = test_helper.color_font_config( { "upem": 24, "fea_file": fea_file, }, svgs, tmp_dir=Path(temp_dir), codepoint_fn=lambda svg_file, _: (ord(svg_file.stem),), ) _, font = write_font._generate_color_font(config, glyph_inputs) # Initial state assert _pair_pos(font) == (("a", [("b", -12)]), ("b", [("b", -14), ("c", -16)])) # reverse the glyph order from a, b, c to c, b, a new_glyph_order = list(reversed(font.getGlyphOrder())) reorder_glyphs(font, new_glyph_order) # Confirm swap applied to Coverage and pair pos parallel array assert _pair_pos(font) == (("b", [("c", -16), ("b", -14)]), ("a", [("b", -12)])) def test_invalid_new_glyph_order(): font = ttLib.TTFont() font.setGlyphOrder([".notdef", "a", "b", "c"]) with pytest.raises( ValueError, match="New glyph order contains 3 glyphs, but font has 4 glyphs" ): reorder_glyphs(font, new_glyph_order=[".notdef", "a", "b"]) with pytest.raises( ValueError, match="New glyph order does not contain the same set of glyphs as the font:", ): reorder_glyphs(font, new_glyph_order=[".notdef", "a", "b", "d"]) nanoemoji-0.16.0/tests/reuse_shape_varying_fill.svg000066400000000000000000000003711523660054300225150ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reuse_shape_varying_fill.ttx000066400000000000000000000041521523660054300225360ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M40,10 L60,10 L60,30 L40,30 L40,10 Z" id="e000.0"/> </defs> <g id="glyph2" transform="translate(0, -100)"> <use xlink:href="#e000.0" fill="yellow"/> <use xlink:href="#e000.0" y="30" fill="red"/> <use xlink:href="#e000.0" y="60" fill="blue"/> </g> </svg> nanoemoji-0.16.0/tests/reused_rect_glyf.ttx000066400000000000000000000046111523660054300210130ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape.svg000066400000000000000000000035051523660054300201160ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape_2.svg000066400000000000000000000004741523660054300203410ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape_2_picosvg.ttx000066400000000000000000000044021523660054300221060ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M200,100 A100 100 0 1 1 0,100 A100 100 0 1 1 200,100 Z" id="e000.0"/> </defs> <g id="glyph2" transform="matrix(0.5 0 0 0.5 0 -100)"> <use xlink:href="#e000.0" fill="#F49924"/> <use xlink:href="#e000.0" x="44.545" y="50" transform="matrix(0.15 0 0 0.218 37.863 39.1)" fill="#7E4418"/> <use xlink:href="#e000.0" x="125.455" y="50" transform="matrix(0.15 0 0 0.218 106.637 39.1)" fill="#7E4418"/> </g> </svg> nanoemoji-0.16.0/tests/reused_shape_glyf.ttx000066400000000000000000000116111523660054300211540ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape_with_gradient.svg000066400000000000000000000026651523660054300230340ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape_with_gradient_colr.ttx000066400000000000000000000167071523660054300240750ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape_with_gradient_from_colr_1.svg000066400000000000000000000041201523660054300253020ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/reused_shape_with_gradient_svg.ttx000066400000000000000000000060451523660054300237270ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M77.56,63.99 A13.56 13.56 0 1 1 50.44,63.99 A13.56 13.56 0 1 1 77.56,63.99 Z" id="e000.0"/> <radialGradient id="g1" cx="59.958" cy="61.112" r="13.562" gradientUnits="userSpaceOnUse"> <stop offset="0.014" stop-color="#FFEB3B"/> <stop offset="0.626" stop-color="#FCCD31"/> <stop offset="1" stop-color="#FBC02D"/> </radialGradient> <radialGradient id="g2" cx="59.813" cy="60.675" r="13.562" gradientUnits="userSpaceOnUse"> <stop offset="0.005" stop-color="#81C784"/> <stop offset="0.201" stop-color="#72BE76"/> <stop offset="0.719" stop-color="#50A854"/> <stop offset="1" stop-color="#43A047"/> </radialGradient> <radialGradient id="g4" cx="59.765" cy="60.435" r="13.562" gradientUnits="userSpaceOnUse"> <stop offset="0" stop-color="#E57373"/> <stop offset="0.197" stop-color="#E56564"/> <stop offset="0.718" stop-color="#E54542"/> <stop offset="1" stop-color="#E53935"/> </radialGradient> </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <use xlink:href="#e000.0" fill="url(#g1)"/> <use xlink:href="#e000.0" y="36.94" fill="url(#g2)"/> <use xlink:href="#e000.0" y="-36.93" fill="url(#g4)"/> </g> </svg> nanoemoji-0.16.0/tests/rgb_rgba.svg000066400000000000000000000014111523660054300172060ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rotated_bounds_1.svg000066400000000000000000000002121523660054300206730ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/rotated_bounds_2.svg000066400000000000000000000004461523660054300207050ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/smiley_cheeks_gradient_svg.ttx000066400000000000000000000046011523660054300230450ustar00rootroot00000000000000 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> <path d="M44.75,69.83 A17.5 17.5 0 1 1 9.75,69.83 A17.5 17.5 0 1 1 44.75,69.83 Z" id="e000.0" fill="url(#g1)"/> <radialGradient id="g1" cx="27.251" cy="73.507" r="19.038" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 0.95 0 0)"> <stop offset="0" stop-color="#ED7770" stop-opacity="0.8"/> <stop offset="0.9" stop-color="#ED7770" stop-opacity="0"/> </radialGradient> </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> <use xlink:href="#e000.0"/> <use xlink:href="#e000.0" x="73.5"/> </g> </svg> nanoemoji-0.16.0/tests/square_vbox_narrow.svg000066400000000000000000000002521523660054300213710ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/square_vbox_narrow_from_colr_1.svg000066400000000000000000000001751523660054300236570ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/square_vbox_square.svg000066400000000000000000000002531523660054300213620ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/square_vbox_square_from_colr_1.svg000066400000000000000000000001751523660054300236470ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/square_vbox_wide.svg000066400000000000000000000002531523660054300210120ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/square_vbox_wide_from_colr_1.svg000066400000000000000000000001751523660054300232770ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/svg_colr_svg_test.py000066400000000000000000000072101523660054300210310ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import pytest import test_helper from picosvg.svg import SVG from picosvg.svg_meta import ntos from picosvg.svg_transform import Affine2D from nanoemoji import write_font from nanoemoji.colr_to_svg import colr_to_svg # TODO otf test @pytest.mark.parametrize( "svg_in, expected_svg_out, config_overrides", [ # simple fill on rect ("rect.svg", "rect_from_colr_0.svg", {"color_format": "glyf_colr_0"}), ("rect.svg", "rect_from_colr_1.svg", {"color_format": "glyf_colr_1"}), # linear gradient on rect ( "linear_gradient_rect.svg", "linear_gradient_rect_from_colr_1.svg", {"color_format": "glyf_colr_1"}, ), # radial gradient on rect ( "radial_gradient_rect.svg", "radial_gradient_rect_from_colr_1.svg", {"color_format": "glyf_colr_1"}, ), # https://github.com/googlefonts/nanoemoji/issues/327: PaintComposite ( "group_opacity.svg", "group_opacity_from_colr_1.svg", {"color_format": "glyf_colr_1"}, ), # Roundtrip SVG containing reused shapes with gradients # https://github.com/googlefonts/nanoemoji/issues/334 ( "reused_shape_with_gradient.svg", "reused_shape_with_gradient_from_colr_1.svg", { "color_format": "glyf_colr_1", # test_helper's default upem=100 is too coarse for glyf integer # coordinates to approximate circles with quadratic beziers, hence # we make it bigger below "upem": 1024, "width": 1275, "ascender": 950, "descender": -250, }, ), # Roundtrip a narrow svg, retaining proportion ( "square_vbox_narrow.svg", "square_vbox_narrow_from_colr_1.svg", { "width": 0, }, ), # Roundtrip a square svg, retaining proportion ( "square_vbox_square.svg", "square_vbox_square_from_colr_1.svg", { "width": 0, }, ), # Roundtrip a wide svg, retaining proportion ( "square_vbox_wide.svg", "square_vbox_wide_from_colr_1.svg", { "width": 0, }, ), ], ) def test_svg_to_colr_to_svg(svg_in, expected_svg_out, config_overrides): config, glyph_inputs = test_helper.color_font_config( config_overrides, (svg_in,), ) _, ttfont = write_font._generate_color_font(config, glyph_inputs) svg_before = SVG.parse(str(test_helper.locate_test_file(svg_in))) font_to_svg_scale = svg_before.view_box().h / config.upem svgs_from_font = tuple( colr_to_svg( lambda _: svg_before.view_box(), ttfont, rounding_ndigits=3 ).values() ) assert len(svgs_from_font) == 1 svg_expected = SVG.parse(str(test_helper.locate_test_file(expected_svg_out))) test_helper.svg_diff(svgs_from_font[0], svg_expected) nanoemoji-0.16.0/tests/svg_path_test.py000066400000000000000000000046101523660054300201500ustar00rootroot00000000000000from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath from nanoemoji.svg_path import SVGPathPen, draw_svg_path import pytest class DummyGlyph: def draw(self, pen): pen.moveTo((0, 0)) pen.lineTo((0, 10)) pen.lineTo((10, 10)) pen.lineTo((10, 0)) pen.closePath() pen.moveTo((0, 15)) pen.curveTo((0, 20), (10, 20), (10, 15)) pen.closePath() pen.moveTo((0, -5)) pen.qCurveTo((0, -8), (3, -10), (7, -10), (10, -8), (10, -5)) pen.endPath() def test_addComponent_decompose(): pen = SVGPathPen(glyphSet={"a": DummyGlyph()}) pen.addComponent("a", Affine2D.identity()) assert pen.path.d == ( "M0,0 L0,10 L10,10 L10,0 Z " "M0,15 C0,20 10,20 10,15 Z " "M0,-5 Q0,-8 1.5,-9 Q3,-10 5,-10 Q7,-10 8.5,-9 Q10,-8 10,-5" ) def test_addComponent_decompose_with_transform(): pen = SVGPathPen(glyphSet={"a": DummyGlyph()}) pen.addComponent("a", Affine2D(2, 0, 0, 2, 0, 0)) assert pen.path.d == ( "M0,0 L0,20 L20,20 L20,0 Z " "M0,30 C0,40 20,40 20,30 Z " "M0,-10 Q0,-16 3,-18 Q6,-20 10,-20 Q14,-20 17,-18 Q20,-16 20,-10" ) def test_draw_onto_existing_path(): path = SVGPath(d="M0,0 L0,10 L10,10 L10,0 Z") pen = SVGPathPen(path=path) pen.moveTo((0, 15)) pen.lineTo((5, 20)) pen.lineTo((10, 15)) pen.closePath() assert path.d == "M0,0 L0,10 L10,10 L10,0 Z M0,15 L5,20 L10,15 Z" def test_addComponent_missing(): pen = SVGPathPen(glyphSet={"a": DummyGlyph()}) with pytest.raises(KeyError): pen.addComponent("b", Affine2D.identity()) @pytest.mark.parametrize( "d", [ "M0,0 L0,10 L10,10 L10,0 Z", "M0,0 L0,10 L10,10 L10,0", "M0,0 L0,10 L10,10 L10,0 Z M12,0 L12,10 L22,10 L22,0", "M0,0 L0,10 L10,10 L10,0 M12,0 L12,10 L22,10 L22,0 Z", "M0,0 C0,3 2,5 5,5 C8,5 10,3 10,0 C10,-3 8,-5 5,-5 C2,-5 0,-3 0,0 Z", "M0,0 Q0,10 10,10 Q20,10 20,0 Z", ], ) def test_roundtrip_path_with_pen(d): path = SVGPath(d=d) pen = SVGPathPen() draw_svg_path(path, pen) assert pen.path.d == d def test_draw_svg_close_subpaths(): path = SVGPath(d="M0,0 L0,10 L10,10 L10,0 M12,0 L12,10 L22,10 L22,0") pen = SVGPathPen() draw_svg_path(path, pen, close_subpaths=True) assert pen.path.d == "M0,0 L0,10 L10,10 L10,0 Z M12,0 L12,10 L22,10 L22,0 Z" nanoemoji-0.16.0/tests/test_helper.py000066400000000000000000000224431523660054300176200ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import difflib import io import os import re import shutil import subprocess import sys from lxml import etree from fontTools import ttLib from nanoemoji import codepoints from nanoemoji import config from nanoemoji import features from nanoemoji.glyph import glyph_name from nanoemoji import write_font from nanoemoji.png import PNG from pathlib import Path from picosvg.svg import SVG import pytest import shutil import tempfile TEST_DATA_DIR = Path(__file__).parent def locate_test_file(filename) -> Path: return TEST_DATA_DIR / filename def parse_svg(filename, locate=False, topicosvg=True): if locate: filename = locate_test_file(filename) svg = SVG.parse(filename) return svg.topicosvg(inplace=True) if topicosvg else svg def rasterize_svg(input_file: Path, output_file: Path, resolution: int = 128) -> PNG: if resolution <= 0: raise ValueError( f"Rendering {resolution} x {resolution} for {input_file} doesn't make sense" ) resvg = shutil.which("resvg") if not resvg: pytest.skip("resvg not installed") result = subprocess.run( [ resvg, "-h", f"{resolution}", "-w", f"{resolution}", input_file, output_file, ] ) return PNG.read_from(output_file) def color_font_config( config_overrides, svgs, tmp_dir=None, codepoint_fn=lambda svg_file, idx: (0xE000 + idx,), glyphname_fn=None, ): if tmp_dir is None: tmp_dir = Path(tempfile.gettempdir()) svgs = tuple(locate_test_file(s) for s in svgs) fea_file = tmp_dir / "test.fea" rgi_seqs = tuple(codepoints.from_filename(str(f)) for f in svgs) with open(fea_file, "w") as f: f.write(features.generate_fea(rgi_seqs)) font_config = ( config.load(config_file=None, additional_srcs=svgs) ._replace( family="UnitTest", upem=100, ascender=100, descender=0, width=100, keep_glyph_names=True, fea_file=str(fea_file), ) ._replace(**config_overrides) ) has_svgs = font_config.has_svgs has_picosvgs = font_config.has_picosvgs has_bitmaps = font_config.has_bitmaps svg_inputs = [(None, None)] * len(svgs) if has_svgs: svg_inputs = [ (Path(os.path.relpath(svg)), parse_svg(svg, topicosvg=has_picosvgs)) for svg in svgs ] bitmap_inputs = [(None, None)] * len(svgs) if has_bitmaps: bitmap_inputs = [] for resolution in font_config.bitmap_resolutions: bitmap_inputs.extend( [ ( tmp_dir / (svg.stem + ".png"), rasterize_svg(svg, tmp_dir / (svg.stem + ".png"), resolution), ) for svg in svgs ] ) if glyphname_fn is None: def glyphname_fn(svg_file, idx): return glyph_name(codepoint_fn(svg_file, idx)) return ( font_config, [ write_font.InputGlyph( svg_file, bitmap_file, codepoint_fn(svg_file, idx), glyphname_fn(svg_file, idx), svg, bitmap, ) for idx, ((svg_file, svg), (bitmap_file, bitmap)) in enumerate( zip(svg_inputs, bitmap_inputs) ) ], ) def reload_font(ttfont): tmp = io.BytesIO() ttfont.save(tmp) return ttLib.TTFont(tmp) def _save_actual_ttx(expected_ttx, ttx_content): tmp_file = os.path.join(tempfile.gettempdir(), expected_ttx) with open(tmp_file, "w") as f: f.write(ttx_content) return tmp_file def _strip_inline_bitmaps(ttx_content): parser = etree.XMLParser(strip_cdata=False) root = etree.fromstring(bytes(ttx_content, encoding="utf-8"), parser=parser) made_changes = False # bitmapGlyphDataFormat="extfile" doesn't work for sbix so wipe those manually for hexdata in root.xpath("//sbix/strike/glyph/hexdata"): glyph = hexdata.getparent() glyph.remove(hexdata) glyph.text = (glyph.attrib["name"] + "." + glyph.attrib["graphicType"]).strip() made_changes = True # Windows gives \ instead of /, if we see that flip it for imagedata in root.xpath("//extfileimagedata"): imagedata.attrib["value"] = Path(imagedata.attrib["value"]).name made_changes = True if not made_changes: return ttx_content actual_ttx = io.BytesIO() etree.ElementTree(root).write(actual_ttx, encoding="utf-8") # Glue on the *exact* xml decl and wrapping newline saveXML produces return ( '\n' + actual_ttx.getvalue().decode("utf-8") + "\n" ) def assert_expected_ttx( svgs, ttfont, expected_ttx, include_tables=None, skip_tables=("head", "hhea", "maxp", "name", "post", "OS/2"), ): actual_ttx = io.StringIO() # Timestamps inside files #@$@#%@# # force consistent Unix newlines (the expected test files use \n too) ttfont.saveXML( actual_ttx, newlinestr="\n", tables=include_tables, skipTables=skip_tables, bitmapGlyphDataFormat="extfile", ) # Elide ttFont attributes because ttLibVersion may change actual = re.sub(r'\s+ttLibVersion="[^"]+"', "", actual_ttx.getvalue()) actual = _strip_inline_bitmaps(actual) expected_location = locate_test_file(expected_ttx) if os.path.isfile(expected_location): with open(expected_location) as f: expected = f.read() else: tmp_file = _save_actual_ttx(expected_ttx, actual) raise FileNotFoundError( f"Missing expected in {expected_location}. Actual in {tmp_file}" ) if actual != expected: for line in difflib.unified_diff( expected.splitlines(keepends=True), actual.splitlines(keepends=True), fromfile=f"{expected_ttx} (expected)", tofile=f"{expected_ttx} (actual)", ): sys.stderr.write(line) print(f"SVGS: {svgs}") tmp_file = _save_actual_ttx(expected_ttx, actual) pytest.fail(f"{tmp_file} != {expected_ttx}") # Copied from picosvg def drop_whitespace(svg): svg._update_etree() for el in svg.svg_root.iter("*"): if el.text is not None: el.text = el.text.strip() if el.tail is not None: el.tail = el.tail.strip() # Copied from picosvg def pretty_print(svg_tree): def _reduce_text(text): text = text.strip() if text else None return text if text else None # lxml really likes to retain whitespace for e in svg_tree.iter("*"): e.text = _reduce_text(e.text) e.tail = _reduce_text(e.tail) return etree.tostring(svg_tree, pretty_print=True).decode("utf-8") # Copied from picosvg def svg_diff(actual_svg: SVG, expected_svg: SVG): drop_whitespace(actual_svg) drop_whitespace(expected_svg) print(f"A: {pretty_print(actual_svg.toetree())}") print(f"E: {pretty_print(expected_svg.toetree())}") assert actual_svg.tostring() == expected_svg.tostring() def run(cmd): cmd = tuple(str(c) for c in cmd) print("subprocess:", " ".join(cmd)) # very useful on failure env = { # We may need to find nanoemoji and other pip-installed cli tools "PATH": str(Path(shutil.which("nanoemoji")).parent), # We may need to find test modules "PYTHONPATH": os.pathsep.join((str(Path(__file__).parent),)), } # Needed for windows CI to function; ref https://github.com/appveyor/ci/issues/1995 if "SYSTEMROOT" in os.environ: env["SYSTEMROOT"] = os.environ["SYSTEMROOT"] return subprocess.run(cmd, check=True, env=env) def run_nanoemoji(args, tmp_dir=None): if not tmp_dir: tmp_dir = mkdtemp() run( ( "nanoemoji", "--build_dir", str(tmp_dir), ) + tuple(str(a) for a in args) ) assert (tmp_dir / "build.ninja").is_file() return tmp_dir _TEMPORARY_DIRS = set() def active_temp_dirs(): return _TEMPORARY_DIRS def forget_temp_dirs(): global _TEMPORARY_DIRS _TEMPORARY_DIRS = set() assert len(active_temp_dirs()) == 0 # this can occur due to local/global confusion def mkdtemp() -> Path: tmp_dir = Path(tempfile.mkdtemp()) assert tmp_dir not in _TEMPORARY_DIRS _TEMPORARY_DIRS.add(tmp_dir) return tmp_dir def cleanup_temp_dirs(): while _TEMPORARY_DIRS: shutil.rmtree(_TEMPORARY_DIRS.pop(), ignore_errors=True) def bool_flag(name: str, value: bool) -> str: result = "--" if not value: result += "no" result += name return result nanoemoji-0.16.0/tests/transformed_components_overlap.svg000066400000000000000000000007751523660054300237760ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/transformed_components_overlap.ttx000066400000000000000000000112401523660054300240030ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/transformed_gradient_reuse.svg000066400000000000000000000017031523660054300230510ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/transformed_gradient_reuse.ttx000066400000000000000000000130011523660054300230630ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/two-o-clock.svg000066400000000000000000000022531523660054300176040ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/u0301.svg000066400000000000000000000006341523660054300162170ustar00rootroot00000000000000 nanoemoji-0.16.0/tests/util_test.py000066400000000000000000000035371523660054300173210ustar00rootroot00000000000000# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import sys from nanoemoji.util import shell_quote, shell_split import pytest # Source: # https://github.com/python/cpython/blob/653e563/Lib/test/test_subprocess.py#L1198-L1214 LIST2CMDLINE_TEST_DATA = [ (["a b c", "d", "e"], '"a b c" d e'), (['ab"c', "\\", "d"], 'ab\\"c \\ d'), (['ab"c', " \\", "d"], 'ab\\"c " \\\\" d'), (["a\\\\\\b", "de fg", "h"], 'a\\\\\\b "de fg" h'), (['a\\"b', "c", "d"], 'a\\\\\\"b c d'), (["a\\\\b c", "d", "e"], '"a\\\\b c" d e'), (["a\\\\b\\ c", "d", "e"], '"a\\\\b\\ c" d e'), (["ab", ""], 'ab ""'), ] CMDLINE2LIST_TEST_DATA = [(cmdline, args) for args, cmdline in LIST2CMDLINE_TEST_DATA] @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") @pytest.mark.parametrize( "args, expected_cmdline", LIST2CMDLINE_TEST_DATA, ids=[s for _, s in LIST2CMDLINE_TEST_DATA], ) def test_windows_shell_quote(args, expected_cmdline): assert " ".join(shell_quote(s) for s in args) == expected_cmdline @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") @pytest.mark.parametrize( "cmdline, expected_args", CMDLINE2LIST_TEST_DATA, ids=[s for s, _ in CMDLINE2LIST_TEST_DATA], ) def test_windows_shell_split(cmdline, expected_args): assert shell_split(cmdline) == expected_args nanoemoji-0.16.0/tests/write_font_test.py000066400000000000000000000620521523660054300205210ustar00rootroot00000000000000# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import dataclasses import enum import shutil from pathlib import Path from textwrap import dedent from nanoemoji import write_font from nanoemoji.colr import paints_of_type from nanoemoji.config import _DEFAULT_CONFIG from nanoemoji.glyphmap import GlyphMapping from picosvg.svg_transform import Affine2D from ufo2ft.constants import COLR_CLIP_BOXES_KEY from fontTools.ttLib.tables import otTables as ot from picosvg.svg import SVG import pytest import test_helper @pytest.mark.parametrize( "svgs", [("rect.svg", "rect2.svg"), ("one-o-clock.svg", "two-o-clock.svg")] ) @pytest.mark.parametrize( "color_format", ["glyf_colr_0", "glyf_colr_1", "picosvg", "untouchedsvg"] ) @pytest.mark.parametrize("keep_glyph_names", [True, False]) def test_keep_glyph_names(svgs, color_format, keep_glyph_names): config, glyph_inputs = test_helper.color_font_config( {"color_format": color_format, "keep_glyph_names": keep_glyph_names}, svgs ) ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert len(ufo.glyphOrder) == len(ttfont.getGlyphOrder()) if keep_glyph_names: assert ttfont["post"].formatType == 2.0 assert ufo.glyphOrder == ttfont.getGlyphOrder() else: assert ttfont["post"].formatType == 3.0 assert ufo.glyphOrder != ttfont.getGlyphOrder() @pytest.mark.parametrize( "color_format", [ "glyf", "cff_colr_0", "glyf_colr_1", "picosvg", ], ) @pytest.mark.parametrize( "version_major, version_minor, expected", [ (None, None, "1.000"), # default (1, 2, "1.002"), (None, 1, "1.001"), (2, None, "2.000"), (16, 28, "16.028"), (16, 280, "16.280"), ], ) def test_version(color_format, version_major, version_minor, expected): config_overrides = {"color_format": color_format} if version_major is not None: config_overrides["version_major"] = version_major else: version_major = 1 if version_minor is not None: config_overrides["version_minor"] = version_minor else: version_minor = 0 config, glyph_inputs = test_helper.color_font_config( config_overrides, ("rect.svg", "one-o-clock.svg") ) ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert ufo.info.versionMajor == version_major assert ufo.info.versionMinor == version_minor assert ttfont["name"].getDebugName(nameID=5).startswith(f"Version {expected}") @pytest.mark.parametrize( "ascender, descender, linegap", [ ( _DEFAULT_CONFIG.ascender, _DEFAULT_CONFIG.descender, _DEFAULT_CONFIG.linegap, ), (1024, 0, 0), (820, -204, 200), ], ) def test_vertical_metrics(ascender, descender, linegap): config_overrides = { "ascender": ascender, "descender": descender, "linegap": linegap, } config, glyph_inputs = test_helper.color_font_config( config_overrides, ("rect.svg", "one-o-clock.svg") ) ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) hhea = ttfont["hhea"] os2 = ttfont["OS/2"] assert ufo.info.ascender == hhea.ascent == os2.sTypoAscender == ascender assert ufo.info.descender == hhea.descent == os2.sTypoDescender == descender assert hhea.lineGap == os2.sTypoLineGap == linegap # check USE_TYPO_METRICS is set assert os2.fsSelection & (1 << 7) != 0 # These are ufo2ft's fallback WinAscent/WinDescent, good enough for now. # TODO: Set to the actual global yMin/yMax to prevent any clipping? assert os2.usWinAscent == ascender + linegap assert os2.usWinDescent == abs(descender) # always positive # TODO test that width, height are removed from svg # TODO test that enable-background is removed from svg # TODO test that id=glyph# is added to svg # TODO test svg compressed false, svgz true @pytest.mark.parametrize( # color_format, output_format "svgs, expected_ttx, config_overrides", [ # verify glyf removes component if there is only one shape (("one_rect.svg",), "one_rect_glyf.ttx", {"color_format": "glyf"}), # simple fill on rect (("rect.svg",), "rect_colr_0.ttx", {"color_format": "glyf_colr_0"}), (("rect.svg",), "rect_colr_1.ttx", {"color_format": "glyf_colr_1"}), ( ("rect.svg",), "rect_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), (("rect.svg",), "rect_untouchedsvg.ttx", {"color_format": "untouchedsvg"}), # linear gradient on rect ( ("linear_gradient_rect.svg",), "linear_gradient_rect_colr_1.ttx", {"color_format": "glyf_colr_1"}, ), # radial gradient on rect ( ("radial_gradient_rect.svg",), "radial_gradient_rect_colr_1.ttx", {"color_format": "glyf_colr_1"}, ), # reuse shape in different color ( ("rect.svg", "rect2.svg"), "rects_colr_1.ttx", {"color_format": "glyf_colr_1"}, ), # clocks have composites, reuse of composite, and reuse of shape w/diff color ( ("one-o-clock.svg", "two-o-clock.svg"), "clocks_colr_1.ttx", {"color_format": "glyf_colr_1"}, ), ( ("one-o-clock.svg", "two-o-clock.svg"), "clocks_glyf.ttx", {"color_format": "glyf"}, ), ( ("one-o-clock.svg", "two-o-clock.svg"), "clocks_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # passing a negative --reuse_tolerance disables shape reuse ( ("one-o-clock.svg", "two-o-clock.svg"), "clocks_colr_1_noreuse.ttx", {"color_format": "glyf_colr_1", "reuse_tolerance": -1}, ), # clocks share shapes, rects share shapes. Should be two distinct svgs in font. # glyph order must reshuffle to group correctly ( ("one-o-clock.svg", "rect.svg", "two-o-clock.svg", "rect2.svg"), "clocks_rects_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), ( ("one-o-clock.svg", "rect.svg", "two-o-clock.svg", "rect2.svg"), "clocks_rects_untouchedsvg.ttx", {"color_format": "untouchedsvg"}, ), # keep single-component composites if component reused by more than one glyph ( ("one_rect.svg", "one_rect.svg"), "reused_rect_glyf.ttx", {"color_format": "glyf"}, ), # Confirm transforms are in the correct coordinate space # https://github.com/googlefonts/nanoemoji/pull/187 ( ("reused_shape.svg",), "reused_shape_glyf.ttx", {"color_format": "glyf"}, ), # Check gradient coordinates are correctly transformed after shape reuse # https://github.com/googlefonts/nanoemoji/issues/334 ( ("reused_shape_with_gradient.svg",), "reused_shape_with_gradient_colr.ttx", {"color_format": "glyf_colr_1", "pretty_print": True}, ), # Check gradient coordinates are correctly transformed after shape reuse # https://github.com/googlefonts/nanoemoji/issues/334 ( ("reused_shape_with_gradient.svg",), "reused_shape_with_gradient_svg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # Confirm we can apply a user transform, override some basic metrics ( ("one_rect.svg",), "one_rect_transformed.ttx", { "color_format": "glyf_colr_1", "transform": Affine2D.fromstring( "scale(0.5, 0.75) translate(50) rotate(45)" ), "width": 120, }, ), # Check that we use xlink:href to reuse shapes with elements # https://github.com/googlefonts/nanoemoji/issues/266 ( ("reused_shape_2.svg",), "reused_shape_2_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # Safari can't deal with gradientTransform where matrix.inverse() == self, # we work around it by nudging one matrix component by an invisible amount # https://github.com/googlefonts/nanoemoji/issues/268 ( ("involutory_matrix.svg",), "involutory_matrix_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # Check that we do _not_ make composite glyphs with reused paths if # the latter overlap and the transform for the shape reuse is such # that the winding direction is reversed # https://github.com/googlefonts/nanoemoji/issues/287 ( ("transformed_components_overlap.svg",), "transformed_components_overlap.ttx", {"color_format": "glyf_colr_1"}, ), # Check we can produce a group with opacity from a Paint graph # https://github.com/googlefonts/nanoemoji/issues/315 ( ("group_opacity.svg",), "group_opacity_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # Confirm we can reuse elements in a group; was screwing up id generation ( ("group_opacity_reuse.svg",), "group_opacity_reuse_picosvg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # https://github.com/googlefonts/nanoemoji/issues/324 ( ("transformed_gradient_reuse.svg",), "transformed_gradient_reuse.ttx", {"color_format": "glyf_colr_1"}, ), # Fill handling in ot-svg reuse # https://github.com/googlefonts/nanoemoji/issues/337 ( ("reuse_shape_varying_fill.svg",), "reuse_shape_varying_fill.ttx", {"color_format": "picosvg", "pretty_print": True}, ), # Generate simple cbdt ( ("rect2.svg",), "rect_cbdt.ttx", # we end up with out of bounds line metrics with default ascender/descender {"color_format": "cbdt", "ascender": 90, "descender": -20}, ), # Generate proportional cbdt ( ("rect2.svg", "narrow_rects/a.svg"), "proportional_cbdt.ttx", # we end up with out of bounds line metrics with default ascender/descender # width 0 forces sizing entirely from the input box proportions {"color_format": "cbdt", "ascender": 90, "descender": -20, "width": 0}, ), # Generate simple sbix ( ("rect2.svg",), "rect_sbix.ttx", {"color_format": "sbix"}, ), # The cheeks on the similing face noto-emoji are two identical circles painted # with same radial gradients, translated some units apart; check that after we # re the same path for both cheeks, their gradients still looks ok. # https://github.com/googlefonts/nanoemoji/issues/324 ( ("emoji_u263a.svg",), "smiley_cheeks_gradient_svg.ttx", {"color_format": "picosvg", "pretty_print": True}, ), ], ) def test_write_font_binary(svgs, expected_ttx, config_overrides): config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) _, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) # sanity check the font # glyf should not have identical-except-name entries except .notdef and .space # SVG should not have identical paths or gradients # in both cases this should be true when normalized to start from 0,0 test_helper.assert_expected_ttx(svgs, ttfont, expected_ttx) @pytest.mark.parametrize( "svgs, config_overrides, expected_clip_boxes", [ ( ("one_rect.svg",), {}, # original rect's (xMin, yMin, xMax, yMax) with no user-transform [(20, 60, 80, 80)], ), ( ("one_rect.svg",), # rotate 90 degrees clockwise around (20, 60) {"transform": Affine2D.fromstring("rotate(-90, 20, 60)")}, [(20, 0, 40, 60)], ), ( ("one_rect.svg",), # flatten so that bounds area == 0 {"transform": Affine2D.fromstring("matrix(0 0 0 1 0 0)")}, None, ), ( # SVG contains no paths, UFO glyph empty: bounds are None ("empty.svg",), {}, None, ), ( # this SVG contains two triangles flipped vertically around the middle, # with each triangle's bbox half-viewbox wide; the union of their bboxes # encompasses the full extent of the upem/viewbox. # Reflection may play tricks if the bbox doesn't get normalized, cf. # https://github.com/googlefonts/nanoemoji/issues/335 ("flipped_reused_shape.svg",), {}, [(0, 0, 100, 100)], ), ( # Check that we correctly compute the bounds of a rectangle that # is reused in a following glyph with a 45 degree rotation: # https://github.com/googlefonts/nanoemoji/issues/341 ("rotated_bounds_1.svg", "rotated_bounds_2.svg"), {}, [(10, 10, 90, 90), (22, 22, 78, 78)], ), ], ) def test_ufo_color_base_glyph_bounds(svgs, config_overrides, expected_clip_boxes): config_overrides = {"output_file": "font.ufo", **config_overrides} config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) ufo, _ = write_font._generate_color_font(config, glyph_inputs) base_glyph_names = [f"e{str(i).zfill(3)}" for i in range(len(svgs))] for base_glyph_name in base_glyph_names: assert len(ufo[base_glyph_name]) == 0 if expected_clip_boxes is not None: clip_boxes = ufo.lib[COLR_CLIP_BOXES_KEY] assert len(clip_boxes) == len(svgs) == len(expected_clip_boxes) for base_glyph_name, (glyphs, bounds), expected_bounds in zip( base_glyph_names, clip_boxes, expected_clip_boxes ): assert glyphs == [base_glyph_name] assert bounds == pytest.approx(expected_bounds) else: assert COLR_CLIP_BOXES_KEY not in ufo.lib class TestCurrentColor: # Chec that we use foreground color palette index 0xFFFF for SVG fill='currentColor' # https://github.com/googlefonts/nanoemoji/issues/380 @staticmethod def generate_color_font(svgs, config_overrides): config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) _, ttfont = write_font._generate_color_font(config, glyph_inputs) return test_helper.reload_font(ttfont) @pytest.mark.parametrize( "svgs, expected_alpha", [ (("currentColor.svg",), 1.0), (("currentColor_with_opacity.svg",), 0.5), ], ) def test_colr_1(self, svgs, expected_alpha): config_overrides = {"color_format": "glyf_colr_1"} ttfont = self.generate_color_font(svgs, config_overrides) colr = ttfont["COLR"].table assert len(colr.BaseGlyphList.BaseGlyphPaintRecord) == len(svgs) color_glyph = colr.BaseGlyphList.BaseGlyphPaintRecord[0] assert color_glyph.Paint.Format == ot.PaintFormat.PaintGlyph assert color_glyph.Paint.Paint.Format == ot.PaintFormat.PaintSolid assert color_glyph.Paint.Paint.PaletteIndex == 0xFFFF assert color_glyph.Paint.Paint.Alpha == expected_alpha cpal = ttfont["CPAL"] assert len(cpal.palettes) == 1 # Chrome expects non-empty palettes so we always add a dummy color as workaround # assert len(cpal.palettes[0]) == 0 assert len(cpal.palettes[0]) == 1 assert cpal.palettes[0][0] == (0, 0, 0, 0xFF) @pytest.mark.parametrize( # COLRv0 can only encode alpha in CPAL's RGBA colors; since the foreground # color palette 0xFFFF is not actually in CPAL, we can't encode a transparent # fill="currentColor" with COLRv0, so both these input produce the same # result, i.e. an opaque foreground color. "svgs", [("currentColor.svg",), ("currentColor_with_opacity.svg",)], ) def test_colr_0(self, svgs): config_overrides = {"color_format": "glyf_colr_0"} ttfont = self.generate_color_font(svgs, config_overrides) colr = ttfont["COLR"] assert len(colr.ColorLayers) == len(svgs) glyph_layers = next(iter(colr.ColorLayers.values())) assert len(glyph_layers) == 1 assert glyph_layers[0].colorID == 0xFFFF cpal = ttfont["CPAL"] assert len(cpal.palettes) == 1 # Chrome expects non-empty palettes so we always add a dummy color as workaround # assert len(cpal.palettes[0]) == 0 assert len(cpal.palettes[0]) == 1 assert cpal.palettes[0][0] == (0, 0, 0, 0xFF) @pytest.mark.parametrize( "svgs, expected_opacity", [ (("currentColor.svg",), 1.0), (("currentColor_with_opacity.svg",), 0.5), ], ) @pytest.mark.parametrize("color_format", ["picosvg", "untouchedsvg"]) def test_picosvg(self, color_format, svgs, expected_opacity): config_overrides = {"color_format": color_format} ttfont = self.generate_color_font(svgs, config_overrides) svg_table = ttfont["SVG "] assert len(svg_table.docList) == len(svgs) svg = SVG.fromstring(svg_table.docList[0][0]) shapes = svg.shapes() assert len(shapes) == 1 assert shapes[0].fill == "currentColor" print(svg.tostring(pretty_print=True)) assert shapes[0].opacity == expected_opacity class InputFormat(enum.Flag): SVG = enum.auto() PNG = enum.auto() @pytest.mark.parametrize( "color_format, expected_input_format", [ ("sbix", InputFormat.PNG), ("cbdt", InputFormat.PNG), ("glyf_colr_0", InputFormat.SVG), ("glyf_colr_1", InputFormat.SVG), ("cff_colr_0", InputFormat.SVG), ("cff_colr_1", InputFormat.SVG), ("cff2_colr_0", InputFormat.SVG), ("cff2_colr_1", InputFormat.SVG), ("picosvg", InputFormat.SVG), ("picosvgz", InputFormat.SVG), ("untouchedsvg", InputFormat.SVG), ("untouchedsvgz", InputFormat.SVG), ], ) def test_inputs_have_svg_and_or_bitmap(tmp_path, color_format, expected_input_format): # Check that inputs have their 'svg' attribute set to a parsed picosvg.SVG object # for all the color formats that use that, including 'untouchedsvg'; only bitmap # formats don't use that so their InputGlyph.svg attribute is None. # https://github.com/googlefonts/nanoemoji/issues/378 # Also check that inputs have their 'bitmap' attribute set to the PNG bytes for # all the color formats that include that. expected_has_svgs = bool(expected_input_format & InputFormat.SVG) expected_has_bitmaps = bool(expected_input_format & InputFormat.PNG) config = _DEFAULT_CONFIG._replace(color_format=color_format) assert config.has_svgs is expected_has_svgs assert config.has_bitmaps is expected_has_bitmaps cp = 0xE001 glyph_mappings = [] for i, svg_file in enumerate(("rect.svg", "rect2.svg")): svg_file = Path(shutil.copy(test_helper.locate_test_file(svg_file), tmp_path)) bitmap_file = None if expected_input_format & InputFormat.PNG: for resolution in config.bitmap_resolutions: bitmap_file = svg_file.with_suffix(f".{resolution}.png") test_helper.rasterize_svg(svg_file, bitmap_file, resolution) if not expected_input_format & InputFormat.SVG: svg_file = None glyph_mappings.append( GlyphMapping(svg_file, bitmap_file, (cp + i,), f"uni{i:04X}") ) inputs = list(write_font._inputs(config, glyph_mappings)) for ginp, gmap in zip(inputs, glyph_mappings): assert ginp[:4] == dataclasses.astuple(gmap) assert ginp.glyph_name == gmap.glyph_name assert ginp.codepoints == gmap.codepoints if expected_has_svgs: assert all(isinstance(g.svg_file, Path) for g in inputs) assert all(isinstance(g.svg, SVG) for g in inputs) else: assert all(g.svg_file is None for g in inputs) assert all(g.svg is None for g in inputs) if expected_has_bitmaps: assert all(isinstance(g.bitmap_file, Path) for g in inputs) assert all(isinstance(g.bitmap, bytes) for g in inputs) else: assert all(g.bitmap_file is None for g in inputs) assert all(g.bitmap is None for g in inputs) def test_square_varied_hmetrics(): # square in varied width vbox # https://codepen.io/rs42/pen/xxPBrRJ?editors=1100 svgs = ( "square_vbox_narrow.svg", "square_vbox_square.svg", "square_vbox_wide.svg", ) config, glyph_inputs = test_helper.color_font_config({"width": 0}, svgs) _, font = write_font._generate_color_font(config, glyph_inputs) colr = font["COLR"] glyph_names = {r.BaseGlyph for r in colr.table.BaseGlyphList.BaseGlyphPaintRecord} assert ( len(glyph_names) == 3 ), f"Should have 3 color glyphs, got {names_of_colr_glyphs}" glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} assert ( len(glyphs) == 1 ), f"Should only be one glyph referenced from COLR, got {glyphs}" glyph_widths = sorted(font["hmtx"][gn][0] for gn in glyph_names) for i in range(len(glyph_widths) - 1): assert ( glyph_widths[i] * 2 == glyph_widths[i + 1] ), f"n+1 should double, fails at {i}; {glyph_widths}" def test_picosvg_colored_notdef(): # https://github.com/googlefonts/nanoemoji/issues/427 svgs = [ ("colored_notdef.svg", ".notdef", []), ("emoji_u42.svg", "B", [0x42]), # adding the same .notdef color glyph under a different name/codepoint, # simply to test that it does NOT get reused despite having the same SVG ("colored_notdef.svg", "C", [0x43]), ] svg_files = [s[0] for s in svgs] config, glyph_inputs = test_helper.color_font_config( {"color_format": "picosvg", "pretty_print": True}, svg_files, glyphname_fn=lambda svg_file, idx: svgs[idx][1], codepoint_fn=lambda svg_file, idx: svgs[idx][2], ) _, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) # check .notdef glyph is still the first glyph and that space character # follows it and has its codepoint assigned in cmap assert ttfont.getGlyphOrder() == [".notdef", ".space", "B", "C"] assert ttfont["cmap"].getBestCmap() == {0x20: ".space", 0x42: "B", 0x43: "C"} # check that SVG table contains a .notdef glyph as first GID assert len(ttfont["SVG "].docList) == 3 assert ttfont["SVG "].docList[0].startGlyphID == 0 assert ttfont["SVG "].docList[0].startGlyphID == 0 assert ttfont["SVG "].docList[1].endGlyphID == 2 assert ttfont["SVG "].docList[1].endGlyphID == 2 assert ttfont["SVG "].docList[2].endGlyphID == 3 assert ttfont["SVG "].docList[2].endGlyphID == 3 def test_colr_keep_cpal_palette_entry_indices(): # if input svg contains colors using var(--color{index}) referencing CPAL # palette entries, we keep the original indices unchanged when building CPAL # (so we can glue together COLR + OT-SVG both using the same CPAL palettes) config, glyph_inputs = test_helper.color_font_config( {"color_format": "glyf_colr_1"}, ("cpal_color_indices.svg",) ) _, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert len(ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord) == 1 assert len(ttfont["CPAL"].palettes) == 1 # check that color entries are sorted by their original palette entry index # instead of by numerical values of RGBA tuples assert [c.hex() for c in ttfont["CPAL"].palettes[0]] == [ "#FF0000FF", # red was var(--color0) "#00FF00FF", # 'lime' had no var(--color), implicitly placed in empty slot "#0000FFFF", # blue was var(--color2) "#FF0000FF", # same as #0 but no explicit index; placed in unindexed slot "#FFFFFFFF", # white had no var(--color), no empty slots, appended at the end ] nanoemoji-0.16.0/tests/write_test_glyphmap.py000066400000000000000000000032771523660054300214000ustar00rootroot00000000000000# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Test glyphmap writer. Writes a glyphmap where every second glyph gets no codepoints.""" from absl import app from absl import flags from nanoemoji.glyphmap import GlyphMapping from nanoemoji import codepoints from nanoemoji import util from pathlib import Path from typing import Iterator, Sequence, Tuple FLAGS = flags.FLAGS flags.DEFINE_string("output_file", "-", "Output filename ('-' means stdout)") def _glyphmappings(input_files: Sequence[str]) -> Iterator[GlyphMapping]: yield from ( GlyphMapping( svg_file=Path(input_file), bitmap_file=None, codepoints=( () if idx % 2 == 1 else codepoints.from_filename(Path(input_file).name) ), glyph_name=f"custom_name_{idx}", ) for idx, input_file in enumerate(input_files) ) def main(argv): input_files = util.expand_ninja_response_files(argv[1:]) with util.file_printer(FLAGS.output_file) as print: for gm in _glyphmappings(input_files): # filename(s), glyph_name, codepoint(s) print(gm.csv_line()) if __name__ == "__main__": app.run(main) nanoemoji-0.16.0/tools/000077500000000000000000000000001523660054300147215ustar00rootroot00000000000000nanoemoji-0.16.0/tools/about_paint.py000066400000000000000000000111761523660054300176060ustar00rootroot00000000000000from collections import Counter import copy from fontTools.colorLib.builder import LayerListBuilder from fontTools.ttLib import TTFont from fontTools.ttLib.tables import otTables as ot from collections import defaultdict from itertools import groupby import logging from math import acos, asin, atan, degrees from nanoemoji.fixed import * from picosvg.svg_transform import Affine2D import os import sys def traverse_paint(colr, callback): """Traverse and optionally rewrite Paint in a COLRv1 graph.""" if not callable(callback): raise TypeError("callback must be callable") visited = set() frontier = [] # Glyph graphs start from either base glyphs or the layerlist for base_glyph in colr.BaseGlyphList.BaseGlyphPaintRecord: frontier.append((base_glyph, base_glyph.Paint)) for paint in colr.LayerList.Paint: frontier.append((colr.LayerList.Paint, paint)) while frontier: container, paint = frontier.pop() key = (id(container), id(paint)) if key in visited: continue visited.add(key) new_paint = callback(paint) if new_paint is not None and new_paint is not paint: # replace existing paint did_replace = False if isinstance(container, list): idx = container.index(paint) container[idx] = new_paint did_replace = True else: for conv in container.getConverters(): value = getattr(container, conv.name, None) if value is paint: setattr(container, new_paint) did_replace = True if not did_replace: raise ValueError("Unable to replace, no current field found") paint = new_paint frontier.extend((paint, c) for c in reversed(paint.getChildren(colr))) def _angle(arcfn, v): return round(degrees(arcfn(v)), 4) def _common_angle(a, b, c, d): return ( all(-1 <= v <= 1 for v in (a, b, c, d)) and len({ _angle(acos, a), _angle(asin, b), _angle(lambda v: -asin(v), c), _angle(acos, d) }) == 1 ) colr_builder = LayerListBuilder() assert len(sys.argv) == 2 font = TTFont(sys.argv[1], lazy=False) colr = font["COLR"].table colr_data = font.reader["COLR"] opportunities = [] count_by_type = Counter() unique_dropped_affines = set() visited = set() def _about_paint(paint): hashable = colr_builder._paint_tuple(paint) if hashable in visited: return visited.add(hashable) count_by_type[paint.getFormatName()] += 1 if paint.getFormatName() == 'PaintTransform': tr = paint.Transform transform = (tr.xx, tr.yx, tr.xy, tr.yy, tr.dx, tr.dy) # just scale and/or translate? if tr.xy == 0 and tr.yx == 0: # Relationship between scale and translate leads to div 0? if ((tr.xx == 1) != (tr.dx == 0)) or ((tr.yy == 1) != (tr.dy == 0)): count_by_type[paint.getFormatName() + "::weird_scale"] += 1 elif f2dot14_safe(tr.xx, tr.yy): cx = cy = 0 if tr.dx != 0: cx = tr.dx / (1 - tr.xx) if tr.dy != 0: cy = tr.dy / (1 - tr.yy) if int16_safe(cx, cy): if tr.dx == 0 and tr.dy == 0: count_by_type[paint.getFormatName() + "::scale_origin"] += 1 else: count_by_type[paint.getFormatName() + "::scale_around"] += 1 unique_dropped_affines.add(transform) else: count_by_type[paint.getFormatName() + "::scale_around_non_int"] += 1 else: count_by_type[paint.getFormatName() + "::large_scale"] += 1 else: translate, other = Affine2D(*transform).decompose_translation() if _common_angle(*other[:4]): if translate.almost_equals(Affine2D.identity()): count_by_type[paint.getFormatName() + "::pure_rotate"] += 1 else: count_by_type[paint.getFormatName() + "::move_rotate"] += 1 elif (tr.dx, tr.dy) == (0, 0): count_by_type[paint.getFormatName() + "::inexplicable_2x2"] += 1 else: count_by_type[paint.getFormatName() + "::inexplicable_2x3"] += 1 traverse_paint(colr, _about_paint) for format_name, count in sorted(count_by_type.items()): print(format_name, count) print(f"PaintTransforms that should be upgraded have {len(unique_dropped_affines)} unique Affine2x3.") nanoemoji-0.16.0/tools/colr2svg.py000077500000000000000000000025711523660054300170440ustar00rootroot00000000000000#!/usr/bin/env python3 """Convert COLRv1 font to a set of SVG files, one per base color glyph.""" import os import sys import argparse # extend PYTHONPATH to include ../tests dir where colr_to_svg module is located sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests")) from nanoemoji.colr_to_svg import colr_to_svg from fontTools.ttLib import TTFont from picosvg.geometric_types import Rect ROUNDING_NDIGITS = 3 VIEW_BOX_SIZE = 128 parser = argparse.ArgumentParser() parser.add_argument("fontfile") parser.add_argument("destdir") parser.add_argument( "--round", dest="rounding_ndigits", metavar="NDIGITS", type=int, default=ROUNDING_NDIGITS, help="default: %(default)s", ) parser.add_argument( "--viewbox-size", metavar="SIZE", type=int, default=VIEW_BOX_SIZE, help="default: %(default)s", ) options = parser.parse_args(sys.argv[1:]) font = TTFont(options.fontfile) os.makedirs(options.destdir, exist_ok=True) def viewbox_callback(_: str) -> Rect: return Rect(0, 0, options.viewbox_size, options.viewbox_size) for glyph_name, svg in colr_to_svg( viewbox_callback, font, rounding_ndigits=options.rounding_ndigits ).items(): output_file = os.path.join(options.destdir, f"{glyph_name}.svg") with open(output_file, "w") as f: f.write(svg.tostring(pretty_print=True)) print(f"{output_file}") nanoemoji-0.16.0/tox.ini000066400000000000000000000023141523660054300150740ustar00rootroot00000000000000[tox] ; Examples: ; $ tox ; # Runs static checks, then pytest for all the pythons listed in 'envlist' ; $ tox -e lint ; # Only runs the linter checks ; $ tox -e py38 ; # Runs all tests against python3.8 only ; $ tox -e py ; # Runs tests against the current python in which tox itself was installed ; $ export TOXENV=py39 ; $ tox ; # If present use $TOXENV environment variable envlist = lint, py3{8,9,10,11} ; if any of the requested python interpreters is unavailable (e.g. on the local dev ; workstation), the tests are skipped and tox won't return an error skip_missing_interpreters = true [testenv] description = Run pytest against the specified python version ; cf. setup.py's extras_require extras = test ; downloads the latest pip, setuptools and wheel when creating the venv download = true ; any arguments passed to tox command line after the '--' separator are passed through ; to pytest: e.g. `tox -e py39 -- -vv --lf -x` commands = pytest {posargs} [testenv:lint] description = Check python style and typing annotation extras = lint usedevelop = true ; use the latest python that pytype supports basepython = python3.12 commands = black --check --diff src tests pytype