pax_global_header 0000666 0000000 0000000 00000000064 15236600543 0014516 g ustar 00root root 0000000 0000000 52 comment=4e2ade0ab833eab1cfb6006edbfc96af5aa1d61e
nanoemoji-0.16.0/ 0000775 0000000 0000000 00000000000 15236600543 0013561 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/.editorconfig 0000664 0000000 0000000 00000000251 15236600543 0016234 0 ustar 00root root 0000000 0000000 # 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.txt 0000664 0000000 0000000 00000000203 15236600543 0017027 0 ustar 00root root 0000000 0000000 node: 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/.gitattributes 0000664 0000000 0000000 00000000040 15236600543 0016446 0 ustar 00root root 0000000 0000000 .git_archival.txt export-subst
nanoemoji-0.16.0/.github/ 0000775 0000000 0000000 00000000000 15236600543 0015121 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/.github/dependabot.yml 0000664 0000000 0000000 00000000463 15236600543 0017754 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 15236600543 0017156 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/.github/workflows/ci.yml 0000664 0000000 0000000 00000010230 15236600543 0020270 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000243 15236600543 0015550 0 ustar 00root root 0000000 0000000 *.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/LICENSE 0000664 0000000 0000000 00000026136 15236600543 0014576 0 ustar 00root root 0000000 0000000
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.md 0000664 0000000 0000000 00000007555 15236600543 0015054 0 ustar 00root root 0000000 0000000 [](https://github.com/googlefonts/nanoemoji/actions/workflows/ci.yml?query=workflow%3ATest+branch%3Amain)
[](https://pypi.org/project/nanoemoji/)
[](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/ 0000775 0000000 0000000 00000000000 15236600543 0014511 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/docs/contributing.md 0000664 0000000 0000000 00000002111 15236600543 0017535 0 ustar 00root root 0000000 0000000 # 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.sh 0000775 0000000 0000000 00000001356 15236600543 0016124 0 ustar 00root root 0000000 0000000 #!/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.toml 0000664 0000000 0000000 00000000455 15236600543 0016501 0 ustar 00root root 0000000 0000000 [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.py 0000664 0000000 0000000 00000003743 15236600543 0015302 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 15236600543 0014350 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/src/nanoemoji/ 0000775 0000000 0000000 00000000000 15236600543 0016327 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/src/nanoemoji/__init__.py 0000664 0000000 0000000 00000001274 15236600543 0020444 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000027473 15236600543 0021524 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002505 15236600543 0021052 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000041232 15236600543 0021224 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000034412 15236600543 0020206 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002136 15236600543 0017642 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000034127 15236600543 0021230 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000040327 15236600543 0020154 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000001741 15236600543 0017656 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 15236600543 0017240 5 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/src/nanoemoji/data/__init__.py 0000664 0000000 0000000 00000000000 15236600543 0021337 0 ustar 00root root 0000000 0000000 nanoemoji-0.16.0/src/nanoemoji/data/_default.toml 0000664 0000000 0000000 00000000260 15236600543 0021716 0 ustar 00root root 0000000 0000000 output_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.py 0000664 0000000 0000000 00000002641 15236600543 0021402 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003225 15236600543 0021417 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000005626 15236600543 0023673 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002631 15236600543 0020521 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002364 15236600543 0020005 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004075 15236600543 0023605 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000020460 15236600543 0021540 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002756 15236600543 0020036 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000006265 15236600543 0021240 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004510 15236600543 0020522 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003261 15236600543 0022215 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000033256 15236600543 0021565 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000055714 15236600543 0020674 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000007113 15236600543 0020002 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000057515 15236600543 0020031 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000025373 15236600543 0020044 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002760 15236600543 0017472 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000004002 15236600543 0020532 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000022407 15236600543 0021736 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002770 15236600543 0022436 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000073144 15236600543 0017511 0 ustar 00root root 0000000 0000000 # 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