././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3361316 ufomerge-1.9.6/0000755000175100001660000000000015045447316012754 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1754681038.330131 ufomerge-1.9.6/.github/0000755000175100001660000000000015045447316014314 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3321311 ufomerge-1.9.6/.github/workflows/0000755000175100001660000000000015045447316016351 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/.github/workflows/ci.yml0000644000175100001660000000507015045447306017470 0ustar00runnerdockeron: push: tags: - "v*" # Push events to matching `v*` version srings. e.g. v1.0, v20.15.10 name: Create and Publish Release jobs: build: name: Build distribution runs-on: ubuntu-latest permissions: contents: write # needed to create a release steps: - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install release dependencies run: | python -m pip install --upgrade pip pip install --upgrade setuptools wheel build - name: Get release notes id: release_notes run: | # By default, GH Actions checkout will only fetch a single commit. # For us to extract the release notes, we need to fetch the tags # and tag annotations as well. # https://github.com/actions/checkout/issues/290 git fetch --tags --force TAG_NAME=${GITHUB_REF/refs\/tags\//} echo "$(git tag -l --format='%(contents)' $TAG_NAME)" > "${{ runner.temp }}/CHANGELOG.md" - name: Create GitHub release id: create_release uses: ncipollo/release-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref }} name: ${{ github.ref }} bodyFile: "${{ runner.temp }}/CHANGELOG.md" draft: false prerelease: false - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/ufomerge permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@v1.12.4 with: # repository-url: https://test.pypi.org/legacy/ # for testing purposes verify-metadata: false # twine previously didn't verify metadata when uploading ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/.github/workflows/test.yml0000644000175100001660000000140315045447306020050 0ustar00runnerdockername: Test on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install packages run: | pip install '.' pip install '.[dev]' - name: lint run: | black . --check --diff --color - name: Run Tests run: | pytest tests/*.py shell: bash ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/.gitignore0000644000175100001660000000625515045447306014753 0ustar00runnerdocker# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST _version.py # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/AUTHORS.txt0000644000175100001660000000045615045447306014646 0ustar00runnerdocker# This is the official list of authors for copyright purposes. # This file is distinct from the CONTRIBUTORS files. # See the latter for an explanation. # # Names should be added to this file as: # Name or Organization # The email address is not required for organizations. Google LLC ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/CONTRIBUTORS.txt0000644000175100001660000000204415045447306015451 0ustar00runnerdocker# This is the official list of people who can contribute # (and typically have contributed) code to this repository. # The AUTHORS file lists the copyright holders; this file # lists people. For example, Google employees are listed here # but not in AUTHORS, because Google holds the copyright. # # Names should be added to this file only after verifying that # the individual or the individual's organization has agreed to # the appropriate Contributor License Agreement, found here: # # http://code.google.com/legal/individual-cla-v1.0.html # http://code.google.com/legal/corporate-cla-v1.0.html # # The agreement for individuals can be filled out on the web. # # When adding J Random Contributor's name to this file, # either J's name or J's organization's name should be # added to the AUTHORS file, depending on whether the # individual or corporate CLA was used. # # Names should be added to this file like so: # Name # # Please keep the list sorted. # (first name; alphabetical order) Simon Cozens ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/LICENSE0000644000175100001660000002613615045447306013770 0ustar00runnerdocker 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. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3311312 ufomerge-1.9.6/Lib/0000755000175100001660000000000015045447316013462 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3341315 ufomerge-1.9.6/Lib/ufomerge/0000755000175100001660000000000015045447316015273 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/Lib/ufomerge/__init__.py0000644000175100001660000006440315045447306017412 0ustar00runnerdockerfrom __future__ import annotations import copy from io import StringIO import logging from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Mapping, OrderedDict, Set, Tuple, Optional, Union import re from fontTools.feaLib.parser import Parser import fontTools.feaLib.ast as ast from ufoLib2 import Font from ufoLib2.objects import LayerSet, Layer, Glyph, Anchor from ufomerge.layout import LayoutClosureVisitor, LayoutSubsetter, LookupBlockGatherer from ufomerge.scaler import scale_ufo logger = logging.getLogger("ufomerge") logging.basicConfig(level=logging.INFO) OBJECT_LIBS_KEY = "public.objectLibs" @dataclass class UFOMerger: ufo1: Font ufo2: Font glyphs: Iterable[str] = field(default_factory=list) exclude_glyphs: Iterable[str] = field(default_factory=list) codepoints: Iterable[int] = field(default_factory=list) layout_handling: str = "subset" existing_handling: Union[str, dict[str, str]] = "replace" kern_handling: str = "conservative" duplicate_lookup_handling: str = "first" include_dir: Path | None = None merge_dotted_circle_anchors: bool = True original_glyphlist: Iterable[str] | None = None # We would like to use a set here, but we need order preservation incoming_glyphset: dict[str, bool] = field(init=False) final_glyphset: Set[str] = field(init=False) blacklisted: Set[str] = field(init=False) ufo2_features: ast.FeatureFile = field(init=False) ufo2_languagesystems: list[Tuple[str, str]] = field(init=False) dotted_circle_anchors: list[Anchor] = field(init=False) def __post_init__(self): if self.glyphs is None: self.glyphs = [] if self.exclude_glyphs is None: self.exclude_glyphs = [] if self.codepoints is None: self.codepoints = [] # Set up the glyphset if not self.glyphs and not self.codepoints: self.glyphs = self.ufo2.keys() self.incoming_glyphset = dict.fromkeys(self.glyphs, True) self.blacklisted = set(self.exclude_glyphs) self.dotted_circle_anchors = self.merged_dotted_circle_anchors() # Now add codepoints if self.codepoints: existing_map = {} to_delete = defaultdict(list) for glyph in self.ufo1: for cp in glyph.unicodes: existing_map[cp] = glyph.name for glyph in self.ufo2: for cp in glyph.unicodes: if cp in self.codepoints: if glyph.name in self.exclude_glyphs: # Seriously? continue # But see if we have a corresponding glyph already if cp in existing_map: if self.policy(existing_map[cp]) == "skip": logger.info( "Skipping codepoint U+%04X already present as '%s' in target file", cp, existing_map[cp], ) # Blacklist this glyph (it may come back # because of layout/component closure.) self.blacklisted.add(glyph.name) elif self.policy(existing_map[cp]) == "replace": to_delete[existing_map[cp]].append(cp) if glyph.name is not None: self.incoming_glyphset[glyph.name] = True for glyph in self.blacklisted: if glyph in self.incoming_glyphset: del self.incoming_glyphset[glyph] # Clear up any glyphs for UFO1 we don't want any more for glyphname, codepoints in to_delete.items(): self.ufo1[glyphname].unicodes = list( set(self.ufo1[glyphname].unicodes) - set(codepoints) ) codepoints_string = ", ".join("U+%04X" % cp for cp in codepoints) logger.info( "Removing mappings %s from glyph '%s' due to incoming codepoints", codepoints_string, glyphname, ) # We *could* delete it from the target glyphset, but there # is a problem here - what if it's actually mentioned in the # feature file?! So we don't. for glyph in self.exclude_glyphs: if glyph in self.incoming_glyphset: del self.incoming_glyphset[glyph] # Check those glyphs actually are in UFO 2 not_there = set(self.incoming_glyphset) - set(self.ufo2.keys()) if len(not_there): logger.warning( "The following glyphs were not in UFO 2: %s", ", ".join(not_there) ) for glyph in not_there: del self.incoming_glyphset[glyph] self.final_glyphset = set(self.ufo1.keys()) | set(self.incoming_glyphset) # Set up UFO2 features if self.layout_handling != "ignore": ufo2path = getattr(self.ufo2, "_path", None) includeDir = ( self.include_dir if self.include_dir is not None else Path(ufo2path).parent if ufo2path else None ) self.ufo2_features = Parser( StringIO(self.ufo2.features.text), includeDir=includeDir, glyphNames=self.original_glyphlist or list(self.ufo2.keys()), ).parse() else: self.ufo2_features = ast.FeatureFile() def policy(self, glyph: str) -> str: """Return the policy for a given glyph""" if isinstance(self.existing_handling, dict): return self.existing_handling.get( glyph, self.existing_handling.get("DEFAULT", "replace") ) return self.existing_handling def merge(self): if not self.incoming_glyphset: logger.info("No glyphs selected, nothing to do") return if self.ufo1.info.unitsPerEm != self.ufo2.info.unitsPerEm: scale = self.ufo1.info.unitsPerEm / self.ufo2.info.unitsPerEm logger.info("Scaling UFO2 by %f", scale) scale_ufo(self.ufo2, scale) if self.layout_handling == "closure": # There is a hard sequencing problem here. Glyphs which # get substituted later in the file but earlier in the # shaping process may get missed. ie. # lookup foo { sub B by C; } foo; # feature bar1 { # sub A by B; # } bar1; # feature bar2 { sub B' lookup foo; } bar2; # If A is in the glyphset, B will get included when # processing bar1 but by this time it's too late to see # that this impacts upon C. I'm just going to keep running # until the output is stable count = len(self.final_glyphset) rounds = 0 while True: LayoutClosureVisitor( incoming_glyphset=self.incoming_glyphset, glyphset=self.final_glyphset, ).visit(self.ufo2_features) rounds += 1 if len(self.final_glyphset) == count: break if rounds > 10: raise ValueError( "Layout closure failure; glyphset grew unreasonably" ) count = len(self.final_glyphset) if self.layout_handling != "ignore": subsetter = LayoutSubsetter(glyphset=self.final_glyphset) if self.duplicate_lookup_handling == "first": ufo1path = getattr(self.ufo1, "_path", None) includeDir = ( self.include_dir if self.include_dir is not None else Path(ufo1path).parent if ufo1path else None ) self.ufo1_features = Parser( StringIO(self.ufo1.features.text), includeDir=includeDir, glyphNames=self.original_glyphlist or list(self.ufo1.keys()), ).parse() # Preseed the subsetter with all our lookup name visitor = LookupBlockGatherer() visitor.visit(self.ufo1_features) subsetter.dropped_lookups = visitor.lookup_names subsetter.subset( self.ufo2_features, hidden_classes=self.discover_hidden_classes() ) self.ufo1.features.text += "\n" + self.ufo2_features.asFea() self.add_language_systems(subsetter.incoming_language_systems) # list() avoids "Set changed size during iteration" error for glyph in list(self.incoming_glyphset.keys()): self.close_components(glyph) for glyph in self.blacklisted: if glyph in self.incoming_glyphset: self.ufo2[glyph].unicodes = [] self.merge_kerning() # Now do the add, first deal with the default layer. # If ufo2 has a glyph order, we enumerate the glyphs in that order. incoming_glyphset = list(self.incoming_glyphset.keys()) if "public.glyphOrder" in self.ufo2.lib: order = self.ufo2.lib["public.glyphOrder"] incoming_glyphset = sorted( incoming_glyphset, key=lambda x: order.index(x) if x in order else -1 ) for glyph in incoming_glyphset: if self.policy(glyph) == "skip" and glyph in self.ufo1: logger.info("Skipping glyph '%s' already present in target file", glyph) continue self.merge_set("public.glyphOrder", glyph, create_if_not_in_ufo1=False) self.merge_set("public.skipExportGlyphs", glyph, create_if_not_in_ufo1=True) self.merge_dict("public.postscriptNames", glyph, create_if_not_in_ufo1=True) self.merge_dict( "public.openTypeCategories", glyph, create_if_not_in_ufo1=True ) if glyph in self.ufo1: self.ufo1[glyph] = self.ufo2[glyph] else: self.ufo1.addGlyph(self.ufo2[glyph]) # ... and then the other layers. for ufo2_layer in self.ufo2.layers: if ufo2_layer.name == self.ufo2.layers.defaultLayer.name: continue ufo1_layer = self.ufo1.layers.get(ufo2_layer.name) if ufo1_layer is None: logger.info( "Skipping merging layer '%s' because it is not present in ufo1", ufo2_layer.name, ) continue for glyph in self.incoming_glyphset.keys(): if glyph not in ufo2_layer: continue if self.policy(glyph) == "skip" and glyph in ufo1_layer: logger.info( "Skipping glyph '%s' already present in target file", glyph ) continue if glyph in ufo1_layer: ufo1_layer[glyph] = ufo2_layer[glyph] else: ufo1_layer.addGlyph(ufo2_layer[glyph]) # Fixups self.handle_dotted_circle() def close_components(self, glyph: str): """Add any needed components, recursively""" components = self.ufo2[glyph].components if not components: return for comp in components: base_glyph = comp.baseGlyph if base_glyph not in self.final_glyphset: # Well, this is the easy case self.final_glyphset.add(base_glyph) logger.debug("Adding %s used as a component in %s", base_glyph, glyph) self.incoming_glyphset[base_glyph] = True self.close_components(base_glyph) elif self.policy(base_glyph) == "replace": # Also not a problem self.incoming_glyphset[base_glyph] = True self.close_components(base_glyph) elif base_glyph in self.ufo1: # Oh bother. logger.warning( "New glyph %s used component %s which already exists in font;" " not replacing it, as you have not specified --replace-existing", glyph, base_glyph, ) def filter_glyphs_incoming(self, glyphs: Iterable[str]) -> list[str]: return [glyph for glyph in glyphs if glyph in self.incoming_glyphset] def add_language_systems(self, incoming_languagesystems): if not incoming_languagesystems: return featurefile = Parser( StringIO(self.ufo1.features.text), glyphNames=list(self.final_glyphset), includeDir=self.include_dir if self.include_dir else None, ).parse() new_lss = [] first_lss_index = None last_lss_index = None # Add existing ones for ix, lss in enumerate(featurefile.statements): if isinstance(lss, ast.LanguageSystemStatement): new_lss.append((lss.script, lss.language)) if first_lss_index is None: first_lss_index = ix last_lss_index = ix # If all new LSS are included in current, we're done. needs_adding = False for pair in incoming_languagesystems: if pair not in new_lss: new_lss.append(pair) needs_adding = True if not needs_adding: return if first_lss_index is None: first_lss_index = 0 last_lss_index = -1 # Hoist DFLT,dflt to first if ("DFLT", "dflt") in new_lss: new_lss.insert(0, new_lss.pop(new_lss.index(("DFLT", "dflt")))) featurefile.statements[first_lss_index : last_lss_index + 1] = [ ast.LanguageSystemStatement(*pair) for pair in new_lss ] self.ufo1.features.text = featurefile.asFea() def discover_hidden_classes(self) -> set: # Dear God, you know what the worst thing about font engineering is? # Everything is hacks, everything is a special case. There are corner # cases on top of corner cases. We examine the feature code to see what # glyph class definitions are used, and we drop those which are unused. # Fine. # # But then there are glyph class definitions which are actually used - # but not in the feature code. Contextual anchors stash some feature # code snippets inside the glyphs themselves, and we have to make sure # that any glyph classes used in those snippets are also included. classes = set() for glyph in self.ufo2: for anchor in glyph.anchors: if OBJECT_LIBS_KEY not in glyph.lib: continue if lib := glyph.objectLib(anchor).get("GPOS_Context"): classes |= set(re.findall(r"@([a-zA-Z0-9_]+)", lib)) return classes def merge_kerning(self): groups1 = self.ufo1.groups groups2 = self.ufo2.groups # Slim down the groups to only those in the glyph set if self.kern_handling == "conservative": for glyph in groups2.keys(): groups2[glyph] = self.filter_glyphs_incoming(groups2[glyph]) # Clean glyphs to be imported from the target UFO kerning groups, so # importing the source kerning then does not lead to duplicate group # membership if their memebership changed. kerning_groups_to_be_cleaned = [] for group_name in list(groups1.keys()): # If it's literally the same group, go for it if group_name in groups2 and set(groups1[group_name]) == set( groups2[group_name] ): continue members = groups1[group_name] new_members = [ member for member in members if member not in self.incoming_glyphset ] if new_members: groups1[group_name] = new_members else: del groups1[group_name] kerning_groups_to_be_cleaned.append(group_name) self.ufo1.kerning = { (first, second): value for (first, second), value in self.ufo1.kerning.items() if first not in kerning_groups_to_be_cleaned and second not in kerning_groups_to_be_cleaned } for (first, second), value in self.ufo2.kerning.items(): left_glyphs = [ glyph for glyph in groups2.get(first, [first]) if glyph in self.final_glyphset ] right_glyphs = [ glyph for glyph in groups2.get(second, [second]) if glyph in self.final_glyphset ] if not any( glyph in self.incoming_glyphset for glyph in left_glyphs + right_glyphs ): # No glyphs in the incoming set, so skip continue if not left_glyphs or not right_glyphs: continue # Just add for now. We should get fancy later self.ufo1.kerning[(first, second)] = value if first.startswith("public.kern"): if first not in groups1: groups1[first] = groups2[first] else: groups1[first] = [ glyph for glyph in set(groups1[first] + groups2[first]) if glyph in self.final_glyphset ] if second.startswith("public.kern"): if second not in groups1: groups1[second] = groups2[second] else: groups1[second] = [ glyph for glyph in set(groups1[second] + groups2[second]) if glyph in self.final_glyphset ] def merged_dotted_circle_anchors(self): if not self.merge_dotted_circle_anchors: return [] # Find both glyphs ds2 = self.find_dotted_circle(self.ufo2) ds1 = self.find_dotted_circle(self.ufo1) if ds1 is None or ds2 is None: return [] anchors = list(ds1.anchors) # The accessor is weird names = [anchor.name for anchor in anchors] for anchor in ds2.anchors: if anchor.name not in names: anchors.append(anchor) return anchors def handle_dotted_circle(self): if self.dotted_circle_anchors: ds1 = self.find_dotted_circle(self.ufo1) if ds1 is None: return ds1.anchors = self.dotted_circle_anchors # Utility routines # Routines for merging font lib keys def merge_set(self, name, glyph, create_if_not_in_ufo1=False): lib1 = self.ufo1.lib lib2 = self.ufo2.lib if name not in lib2 or glyph not in lib2[name]: return if name not in lib1: if create_if_not_in_ufo1: lib1[name] = [] else: return if glyph not in lib1[name]: lib1[name].append(glyph) def merge_dict(self, name, glyph, create_if_not_in_ufo1=False): lib1 = self.ufo1.lib lib2 = self.ufo2.lib if name not in lib2 or glyph not in lib2[name]: return if name not in lib1: if create_if_not_in_ufo1: lib1[name] = {} else: return lib1[name][glyph] = lib2[name][glyph] def find_dotted_circle(self, ufo) -> Optional[Glyph]: if "dottedCircle" in ufo: return ufo["dottedCircle"] if "uni25CC" in ufo: return ufo["uni25CC"] for glyph in ufo: if 0x25CC in glyph.unicodes: return glyph return None def merge_ufos( ufo1: Font, ufo2: Font, glyphs: Iterable[str] = None, exclude_glyphs: Iterable[str] = None, codepoints: Iterable[int] = None, layout_handling: str = "subset", existing_handling: str = "replace", duplicate_lookup_handling: str = "first", kern_handling: str = "conservative", include_dir: Path | None = None, original_glyphlist: Iterable[str] | None = None, merge_dotted_circle_anchors: bool = True, ) -> None: """Merge two UFO files together Returns nothing but modifies ufo1. Args: ufo1: The destination UFO which will receive the new glyphs. ufo2: The "donor" UFO which will provide the new glyphs. glyphs: Optionally, a list of glyph names to be added. If not present and codepoints is also not present, all glyphs from the donor UFO will be added. exclude_glyphs: Optionally, a list of glyph names which should not be added. codepoints: A list of Unicode codepoints as integers. If present, the glyphs with these codepoints will be selected for merging. layout_handling: One of either "subset", "closure" or "ignore". "ignore" means that no layout rules are added from UFO2. "closure" means that the list of donor glyphs will be expanded such that any substitutions in UFO2 involving the selected glyphs will continue to work. "subset" means that the rules are slimmed down to only include the given glyphs. For example, if there is a rule "sub A B by C;", and glyphs==["A", "B"], then when layout_handling=="subset", this rule will be dropped; but if layout_handling=="closure", glyph C will also be merged so that the ligature still works. The default is "subset". duplicate_lookup_handling: One of either "first", "second", or "both". What to do if lookups in the donor font are already present in the target font. "first" will take the lookup from the target font. "second" will take the lookup from the donor font (this is not currently implemented). "both" will add the lookup regardless (this will probably not compile). The default is "first". existing_handling: One of either "replace" or "skip". What to do if the donor glyph already exists in UFO1: "replace" replaces it with the version in UFO2; "skip" keeps the existing glyph. The default is "replace". Alternatively, a dictionary mapping glyph names to "replace" or "skip" can be provided; the name "DEFAULT" can be used to set the default for any glyphs not in the dictionary. kern_handling: One of either "conservative" or "aggressive". How to handle kerning groups. "conservative" will remove any glyphs which are not being imported from the donor's kerning groups before merging. "aggressive" will merge the groups regardless of whether the glyphs are being imported or not. The default is "conservative". include_dir: The directory to look for include files in. If not present, probes the UFO2 object for directory information. original_glyphlist: The original glyph list for UFO2, for when you already have a UFO with subset glyphs, but still need to subset the features. """ if layout_handling not in ["subset", "closure", "ignore"]: raise ValueError(f"Unknown layout handling mode '{layout_handling}'") UFOMerger( ufo1, ufo2, glyphs, exclude_glyphs, codepoints, layout_handling, existing_handling, kern_handling=kern_handling, include_dir=include_dir, original_glyphlist=original_glyphlist, merge_dotted_circle_anchors=merge_dotted_circle_anchors, duplicate_lookup_handling=duplicate_lookup_handling, ).merge() def subset_ufo( ufo: Font, glyphs: Iterable[str] = None, exclude_glyphs: Iterable[str] = None, codepoints: Iterable[int] = None, layout_handling: str = "subset", include_dir: Path | None = None, original_glyphlist: Iterable[str] | None = None, ) -> Font: """Creates a new UFO with only the provided glyphs. Returns a new UFO object. Args: ufo: The UFO to subset. glyphs: A list of glyph names to be added. If not present and codepoints is also not present, all glyphs UFO will be added. exclude_glyphs: Optionally, a list of glyph names which should not be added. codepoints: A list of Unicode codepoints as integers. If present, the glyphs with these codepoints will be selected for merging. layout_handling: One of either "subset", "closure" or "ignore". "ignore" means that no layout rules are added from the font. "closure" means that the list of donor glyphs will be expanded such that any substitutions in the font involving the selected glyphs will continue to work. "subset" means that the rules are slimmed down to only include the given glyphs. For example, if there is a rule "sub A B by C;", and glyphs==["A", "B"], then when layout_handling=="subset", this rule will be dropped; but if layout_handling=="closure", glyph C will also be merged so that the ligature still works. The default is "subset". include_dir: The directory to look for include files in. If not present, probes the UFO2 object for directory information. original_glyphlist: The original glyph list for UFO, for when you already have a UFO with subset glyphs, but still need to subset the features. """ new_ufo = Font( info=copy.deepcopy(ufo.info), layers=LayerSet.from_iterable( [Layer(name=layer.name) for layer in ufo.layers], defaultLayerName=ufo.layers.defaultLayer.name, ), ) merge_ufos( new_ufo, ufo, glyphs, exclude_glyphs, codepoints, layout_handling=layout_handling, include_dir=include_dir, original_glyphlist=original_glyphlist, ) return new_ufo ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/Lib/ufomerge/__main__.py0000644000175100001660000000012715045447306017364 0ustar00runnerdockerfrom ufomerge.cli import main as real_main if __name__ == "__main__": real_main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge/_version.py0000644000175100001660000000077715045447316017504 0ustar00runnerdocker# file generated by setuptools-scm # don't change, don't track in version control __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] TYPE_CHECKING = False if TYPE_CHECKING: from typing import Tuple from typing import Union VERSION_TUPLE = Tuple[Union[int, str], ...] else: VERSION_TUPLE = object version: str __version__: str __version_tuple__: VERSION_TUPLE version_tuple: VERSION_TUPLE __version__ = version = '1.9.6' __version_tuple__ = version_tuple = (1, 9, 6) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/Lib/ufomerge/cli.py0000644000175100001660000001167515045447306016425 0ustar00runnerdocker#!/usr/bin/env python3 """Merge together two source fonts in UFO format""" from __future__ import annotations import logging from argparse import ArgumentParser, BooleanOptionalAction import ufoLib2 from ufomerge import merge_ufos logger = logging.getLogger("ufomerge") # I don't care about ambiguous glyph names that look like ranges logging.getLogger("fontTools.feaLib.parser").setLevel(logging.ERROR) parser = ArgumentParser(description=__doc__) gs = parser.add_argument_group("glyph selection") gs.add_argument("-g", "--glyphs", help="Glyphs to add from UFO 2", default="") gs.add_argument("-G", "--glyphs-file", help="File containing glyphs to add from UFO 2") gs.add_argument( "-u", "--codepoints", help="Unicode codepoints to add from UFO 2", ) gs.add_argument( "-U", "--codepoints-file", help="File containing Unicode codepoints to add from UFO 2", ) gs.add_argument("-x", "--exclude-glyphs", help="Glyphs to exclude from UFO 2") gs.add_argument( "-X", "--exclude-glyphs-file", help="File containing glyphs to exclude from UFO 2" ) existing = parser.add_argument_group("Existing glyph handling") existing = existing.add_mutually_exclusive_group(required=False) existing.add_argument( "--skip-existing", action="store_true", default=True, help="Skip glyphs already present in UFO 1", ) existing.add_argument( "--replace-existing", action="store_true", default=False, help="Replace glyphs already present in UFO 1", ) layout = parser.add_argument_group("Layout closure handling") layout2 = layout.add_mutually_exclusive_group(required=False) layout2.add_argument( "--subset-layout", action="store_true", default=True, help="Drop layout rules concerning glyphs not selected", ) layout2.add_argument( "--layout-closure", action="store_true", default=False, help="Add glyphs from UFO 2 contained in layout rules, even if not in glyph set", ) layout2.add_argument( "--ignore-layout", action="store_true", default=False, help="Don't try to parse the layout rules", ) layout.add_argument( "--duplicate-lookups", choices=["first", "both"], default="first", help="How to handle duplicate lookups in the merged font", ) fixups = parser.add_argument_group("Specialist fixups") parser.add_argument( "--dotted-circle", action=BooleanOptionalAction, default=True, help="Merge anchors if both fonts contain a dotted circle glyph", ) parser.add_argument("ufo1", help="UFO font file to merge into") parser.add_argument("ufo2", help="UFO font file to merge") parser.add_argument("--output", "-o", help="Output UFO font file") parser.add_argument("--fea-include-dir", help="Include directory for feature files") parser.add_argument( "--verbose", "-v", action="store_true", default=False, help="Increase logging verbosity", ) def main(args=None): args = parser.parse_args(args) if args.replace_existing: existing_handling = "replace" else: existing_handling = "skip" # One day we'll have "rename" as well if args.layout_closure: layout_handling = "closure" else: layout_handling = "subset" if args.verbose: logging.basicConfig(level=logging.DEBUG) logging.getLogger("ufomerge").setLevel(logging.DEBUG) if not args.output: args.output = args.ufo1 ufo1 = ufoLib2.Font.open(args.ufo1) ufo2 = ufoLib2.Font.open(args.ufo2) # Determine glyph set to merge def parse_cp(cp): if ( cp.startswith("U+") or cp.startswith("u+") or cp.startswith("0x") or cp.startswith("0X") ): return int(cp[2:], 16) return int(cp) glyphs = set() if args.glyphs == "*": glyphs = ufo2.keys() elif args.glyphs_file: glyphs = set(open(args.glyphs_file, encoding="utf-8").read().splitlines()) elif args.glyphs: glyphs = set(args.glyphs.split(",")) if args.codepoints: codepoints = set(args.codepoints.split(",")) elif args.codepoints_file: codepoints = set( open(args.codepoints_file, encoding="utf-8").read().splitlines() ) else: codepoints = [] if codepoints: codepoints = [parse_cp(cp) for cp in codepoints] if args.exclude_glyphs: exclude_glyphs = set(args.exclude_glyphs.split(",")) elif args.exclude_glyphs_file: exclude_glyphs = set( open(args.exclude_glyphs_file, encoding="utf-8").read().splitlines() ) else: exclude_glyphs = set() merge_ufos( ufo1, ufo2, glyphs=glyphs, exclude_glyphs=exclude_glyphs, codepoints=codepoints, layout_handling=layout_handling, duplicate_lookup_handling=args.duplicate_lookups, existing_handling=existing_handling, merge_dotted_circle_anchors=args.dotted_circle, include_dir=args.fea_include_dir, ) ufo1.save(args.output, overwrite=True) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/Lib/ufomerge/layout.py0000644000175100001660000004234315045447306017167 0ustar00runnerdockerimport logging from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, OrderedDict, Set from fontTools.feaLib import ast from fontTools.misc.visitor import Visitor from ufomerge.utils import ( filter_glyph_container, filter_glyphs, filter_sequence, has_any_empty_slots, ) logger = logging.getLogger("ufomerge.layout") def _deduplicate_class_defs( class_name_references: dict[str, list[ast.GlyphClassName]], ) -> list[ast.GlyphClassDefinition]: """Deduplicate class definitions with the same glyph set. We let each statement do its own filtering of class definitions to preserve semantics going in, but then need to deduplicate the resulting class definitions. """ fresh_class_defs = [] for class_name, class_defs in class_name_references.items(): by_glyph_set: dict[tuple[str, ...], list[ast.GlyphClassDefinition]] by_glyph_set = defaultdict(list) for class_def in class_defs: glyph_set = tuple(sorted(class_def.glyphclass.glyphs.glyphSet())) by_glyph_set[glyph_set].append(class_def.glyphclass) for index, (glyph_set, class_defs) in enumerate(by_glyph_set.items(), start=1): # No need to deduplicate. if len(by_glyph_set) == 1: new_class_def = ast.GlyphClassDefinition( class_name, ast.GlyphClass([ast.GlyphName(g) for g in glyph_set]) ) fresh_class_defs.append(new_class_def) # Update references for class_def in class_defs: class_def.name = class_name continue # Deduplicate new_class_name = f"{class_name}_{index}" new_class_def = ast.GlyphClassDefinition( new_class_name, ast.GlyphClass([ast.GlyphName(g) for g in glyph_set]) ) fresh_class_defs.append(new_class_def) # Update references for class_def in class_defs: class_def.name = new_class_name return fresh_class_defs @dataclass class LayoutSubsetter: glyphset: Set[str] incoming_language_systems: list[tuple[str, str]] = field(init=False) dropped_lookups: list[str] = field(default=list) def subset(self, fea: ast.FeatureFile, hidden_classes: list[str] = []): self.incoming_language_systems = [ (st.script, st.language) for st in fea.statements if isinstance(st, ast.LanguageSystemStatement) ] visitor = LayoutSubsetVisitor(self.glyphset, hidden_classes) visitor.dropped_lookups = set(self.dropped_lookups) visitor.visit(fea) # At this point, all previous class definitions should have been # dropped from the AST, and we can insert new deduplicated ones. fresh_class_defs = _deduplicate_class_defs(visitor.class_name_references) for class_def in fresh_class_defs: fea.statements.insert(0, class_def) class LayoutSubsetVisitor(Visitor): def __init__(self, glyphset, hidden_classes=None): self.glyphset = glyphset self.class_name_references = defaultdict(list) self.dropped_lookups = set() self.dropped_features = set() self.referenced_mark_classes = set() self.hidden_classes = set(hidden_classes or []) @LayoutSubsetVisitor.register(ast.MarkClassDefinition) def visit(visitor, mcd, *args, **kwargs): mcd.glyphs = filter_glyph_container( mcd.glyphs, visitor.glyphset, visitor.class_name_references ) mcd._keep = bool(mcd.glyphs.glyphSet()) if mcd._keep: visitor.referenced_mark_classes.add(mcd.markClass.name) return False # Needed to prevent recursion @LayoutSubsetVisitor.register(ast.SinglePosStatement) def visit(visitor, st, *args, **kwargs): st.prefix = filter_sequence( st.prefix, visitor.glyphset, visitor.class_name_references ) st.suffix = filter_sequence( st.suffix, visitor.glyphset, visitor.class_name_references ) container, vr = st.pos[0] st.pos = [ ( filter_glyph_container( container, visitor.glyphset, visitor.class_name_references ), vr, ) ] st._keep = not ( any(not sequence.glyphSet() for sequence in st.prefix) or any(not sequence.glyphSet() for sequence in st.suffix) or not st.pos[0][0].glyphSet() ) @LayoutSubsetVisitor.register(ast.GlyphClassDefinition) def visit(visitor, st, *args, **kwargs): st._keep = st.name in visitor.hidden_classes return False @LayoutSubsetVisitor.register(ast.Statement) def visit(visitor, st, *args, **kwargs): keep = True for method in ["prefix", "suffix", "glyphs"]: if hasattr(st, method): before = getattr(st, method) after = filter_sequence( before, visitor.glyphset, visitor.class_name_references ) if has_any_empty_slots(after): keep = False setattr(st, method, after) for method in [ # Mark*PosStatement "base", "ligatures", "baseMarks", # PairPosStatement "glyphs1", "glyphs2", ]: if not hasattr(st, method): continue result = filter_glyph_container( getattr(st, method), visitor.glyphset, visitor.class_name_references ) if not result.glyphSet(): keep = False setattr(st, method, result) st._keep = keep return False @LayoutSubsetVisitor.register(ast.SingleSubstStatement) def visit(visitor, st, *args, **kwargs): st.prefix = filter_sequence( st.prefix, visitor.glyphset, visitor.class_name_references ) st.suffix = filter_sequence( st.suffix, visitor.glyphset, visitor.class_name_references ) if has_any_empty_slots(st.prefix) or has_any_empty_slots(st.suffix): st._keep = False return originals = st.glyphs[0].glyphSet() replaces = st.replacements[0].glyphSet() if len(replaces) == 1: replaces = replaces * len(originals) newmapping = OrderedDict() for inglyph, outglyph in zip(originals, replaces): if inglyph in visitor.glyphset and outglyph in visitor.glyphset: newmapping[inglyph] = outglyph if not newmapping: st._keep = False return if len(newmapping) == 1: st.glyphs = [ast.GlyphName(list(newmapping.keys())[0])] st.replacements = [ast.GlyphName(list(newmapping.values())[0])] else: st.glyphs = [ast.GlyphClass(list(newmapping.keys()))] st.replacements = [ast.GlyphClass(list(newmapping.values()))] st._keep = True return False @LayoutSubsetVisitor.register(ast.MultipleSubstStatement) def visit(visitor, st, *args, **kwargs): st.glyph = filter_glyph_container( st.glyph, visitor.glyphset, visitor.class_name_references ) st.replacement = filter_sequence( st.replacement, visitor.glyphset, visitor.class_name_references ) st.prefix = filter_sequence( st.prefix, visitor.glyphset, visitor.class_name_references ) st.suffix = filter_sequence( st.suffix, visitor.glyphset, visitor.class_name_references ) st._keep = not ( has_any_empty_slots(st.prefix) or has_any_empty_slots(st.replacement) or has_any_empty_slots(st.suffix) or not st.glyph.glyphSet() ) return False @LayoutSubsetVisitor.register(ast.LigatureSubstStatement) def visit(visitor, st, *args, **kwargs): st.glyphs = filter_sequence( st.glyphs, visitor.glyphset, visitor.class_name_references ) st.replacement = filter_glyph_container( st.replacement, visitor.glyphset, visitor.class_name_references ) st.prefix = filter_sequence( st.prefix, visitor.glyphset, visitor.class_name_references ) st.suffix = filter_sequence( st.suffix, visitor.glyphset, visitor.class_name_references ) st._keep = not ( has_any_empty_slots(st.prefix) or has_any_empty_slots(st.glyphs) or has_any_empty_slots(st.suffix) or not st.replacement.glyphSet() ) return False @LayoutSubsetVisitor.register(ast.AlternateSubstStatement) def visit(visitor, st, *args, **kwargs): st.glyph = filter_glyph_container( st.glyph, visitor.glyphset, visitor.class_name_references ) st.replacement = filter_glyph_container( st.replacement, visitor.glyphset, visitor.class_name_references ) st.prefix = filter_sequence( st.prefix, visitor.glyphset, visitor.class_name_references ) st.suffix = filter_sequence( st.suffix, visitor.glyphset, visitor.class_name_references ) st._keep = not ( has_any_empty_slots(st.prefix) or has_any_empty_slots(st.suffix) or not st.replacement.glyphSet() or not st.glyph.glyphSet() ) return False @LayoutSubsetVisitor.register(ast.CursivePosStatement) def visit(visitor, st, *args, **kwargs): st.glyphclass = filter_glyph_container( st.glyphclass, visitor.glyphset, visitor.class_name_references ) st._keep = bool(st.glyphclass.glyphSet()) return False @LayoutSubsetVisitor.register(ast.ReverseChainSingleSubstStatement) def visit(visitor, st, *args, **kwargs): st.old_prefix = filter_sequence( st.old_prefix, visitor.glyphset, visitor.class_name_references ) st.old_suffix = filter_sequence( st.old_suffix, visitor.glyphset, visitor.class_name_references ) st.glyphs = filter_sequence( st.glyphs, visitor.glyphset, visitor.class_name_references ) st.replacements = filter_sequence( st.replacements, visitor.glyphset, visitor.class_name_references ) st._keep = not ( has_any_empty_slots(st.old_prefix) or has_any_empty_slots(st.replacements) or has_any_empty_slots(st.old_suffix) or has_any_empty_slots(st.glyphs) ) return False @LayoutSubsetVisitor.register(ast.MarkBasePosStatement) def visit(visitor, st, *args, **kwargs): st.marks = [ (anchor, mark_class) for anchor, mark_class in st.marks if mark_class.name in visitor.referenced_mark_classes ] st._keep = bool(st.marks) def _ignore_pos_sub(visitor, st, *args, **kwargs): newcontexts = [] keep = True for prefix, glyphs, suffix in st.chainContexts: prefix[:] = filter_sequence( prefix, visitor.glyphset, visitor.class_name_references ) glyphs[:] = filter_sequence( glyphs, visitor.glyphset, visitor.class_name_references ) suffix[:] = filter_sequence( suffix, visitor.glyphset, visitor.class_name_references ) if ( has_any_empty_slots(prefix) or has_any_empty_slots(suffix) or has_any_empty_slots(glyphs) ): keep = False newcontexts.append((prefix, glyphs, suffix)) if not newcontexts: keep = False st.chainContexts = newcontexts st._keep = keep @LayoutSubsetVisitor.register(ast.IgnorePosStatement) def visit(visitor, st, *args, **kwargs): _ignore_pos_sub(visitor, st, *args, **kwargs) return False @LayoutSubsetVisitor.register(ast.IgnoreSubstStatement) def visit(visitor, st, *args, **kwargs): _ignore_pos_sub(visitor, st, *args, **kwargs) return False @LayoutSubsetVisitor.register(ast.Block) def visit(visitor, block, *args, **kwargs): visitor.visitList(block.statements) block.statements = [ statement for statement in block.statements if getattr(statement, "_keep", True) ] setattr( block, "_keep", any( getattr(statement, "_keep", True) is True for statement in block.statements ), ) if isinstance(block, ast.LookupBlock): if block.name in visitor.dropped_lookups: # We may have pre-seeded it there already with another font's lookups block._keep = False if not block._keep: logger.warning("Removing ineffective lookup %s", block.name) visitor.dropped_lookups.add(block.name) elif isinstance(block, ast.FeatureBlock) and not block._keep: logger.warning("Removing ineffective feature %s", block.name) visitor.dropped_features.add(block.name) return False @LayoutSubsetVisitor.register(ast.LookupReferenceStatement) def visit(visitor, st, *args, **kwargs): st._keep = st.lookup.name not in visitor.dropped_lookups return False @LayoutSubsetVisitor.register(ast.FeatureReferenceStatement) def visit(visitor, st, *args, **kwargs): st._keep = st.featureName not in visitor.dropped_features return False @LayoutSubsetVisitor.register(ast.LookupFlagStatement) def visit(visitor, st, *args, **kwargs): if st.markAttachment: st.markAttachment = filter_glyph_container( st.markAttachment, visitor.glyphset, visitor.class_name_references ) if not st.markAttachment.glyphSet(): st._keep = False return if st.markFilteringSet: st.markFilteringSet = filter_glyph_container( st.markFilteringSet, visitor.glyphset, visitor.class_name_references ) if not st.markFilteringSet.glyphSet(): st._keep = False return st._keep = "maybe" return False @LayoutSubsetVisitor.register(ast.Comment) def visit(_visitor, st, *args, **kwargs): st._keep = "maybe" return False @LayoutSubsetVisitor.register(ast.FeatureNameStatement) def visit(_visitor, st, *args, **kwargs): st._keep = "maybe" return False @LayoutSubsetVisitor.register(ast.NestedBlock) def visit(_visitor, st, *args, **kwargs): st._keep = "maybe" return False @LayoutSubsetVisitor.register(ast.LanguageSystemStatement) def visit(_visitor, st, *args, **kwargs): st._keep = False return False @LayoutSubsetVisitor.register(ast.ScriptStatement) def visit(_visitor, st, *args, **kwargs): st._keep = "maybe" return False @LayoutSubsetVisitor.register(ast.LanguageStatement) def visit(_visitor, st, *args, **kwargs): st._keep = "maybe" return False class LayoutClosureVisitor(Visitor): """Make sure that anything that can be produced by substitution rules added to the new UFO will also be added to the glyphset. After running the visitor, any glyphs that need to also be included in the incoming set will be added to the incoming_glyphset dictionary. """ def __init__(self, incoming_glyphset: Dict[str, bool], glyphset: Set[str]): self.glyphset = glyphset self.incoming_glyphset = incoming_glyphset @LayoutClosureVisitor.register(ast.AlternateSubstStatement) def visit(visitor, st, *args, **kwargs): if not filter_glyphs(st.glyph.glyphSet(), visitor.glyphset): return False for glyph in st.replacement.glyphSet(): visitor.incoming_glyphset[glyph] = True visitor.glyphset.add(glyph) logger.debug( "Adding %s used in alternate substitution from %s", glyph, st.glyph.asFea(), ) @LayoutClosureVisitor.register(ast.MultipleSubstStatement) def visit(visitor, st, *args, **kwargs): # Fixup FontTools API breakage if isinstance(st.glyph, str): st.glyph = ast.GlyphName(st.glyph, st.location) if not filter_glyphs(st.glyph.glyphSet(), visitor.glyphset): return False for slot in st.replacement: if isinstance(slot, str): slot = ast.GlyphName(slot, st.location) for glyph in slot.glyphSet(): visitor.incoming_glyphset[glyph] = True visitor.glyphset.add(glyph) logger.debug( "Adding %s used in multiple substitution from %s", glyph, st.glyph.asFea(), ) @LayoutClosureVisitor.register(ast.LigatureSubstStatement) def visit(visitor, st, *args, **kwargs): if has_any_empty_slots(filter_sequence(st.glyphs, visitor.glyphset)): return False if isinstance(st.replacement, str): st.replacement = ast.GlyphName(st.replacement, st.location) for glyph in st.replacement.glyphSet(): visitor.incoming_glyphset[glyph] = True visitor.glyphset.add(glyph) logger.debug( "Adding %s used in ligature substitution from %s", glyph, " ".join([x.asFea() for x in st.glyphs]), ) @LayoutClosureVisitor.register(ast.SingleSubstStatement) def visit(visitor, st, *args, **kwargs): originals = st.glyphs[0].glyphSet() replaces = st.replacements[0].glyphSet() if len(replaces) == 1: replaces = replaces * len(originals) for inglyph, outglyph in zip(originals, replaces): if inglyph in visitor.glyphset: visitor.incoming_glyphset[outglyph] = True visitor.glyphset.add(outglyph) logger.debug( "Adding %s used in single substitution from %s", outglyph, inglyph, ) class LookupBlockGatherer(Visitor): def __init__(self): self.lookup_names = set() @LookupBlockGatherer.register(ast.LookupBlock) def visit(visitor, block, *args, **kwargs): visitor.lookup_names.add(block.name) @LookupBlockGatherer.register(ast.MarkClassDefinition) def visit(visitor, mcd, *args, **kwargs): # Avoid recursion return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/Lib/ufomerge/scaler.py0000644000175100001660000000242215045447306017115 0ustar00runnerdockerfrom ufoLib2.objects import Font, Glyph from fontTools.misc.transform import Transform def scale_ufo(ufo: Font, expansion: float): for glyph in ufo: scale_glyph(glyph, expansion) ufo.info.unitsPerEm *= expansion ufo.info.ascender *= expansion ufo.info.descender *= expansion ufo.info.capHeight *= expansion ufo.info.xHeight *= expansion scale_kerning(ufo.kerning, expansion) # XXX scale features def scale_glyph(glyph: Glyph, expansion: float): for contour in glyph: for point in contour: point.x *= expansion point.y *= expansion for anchor in glyph.anchors: anchor.x *= expansion anchor.y *= expansion glyph.width *= expansion glyph.height *= expansion for component in glyph.components: xx, xy, yx, yy, dx, dy = component.transformation component.transformation = Transform( xx, xy, yx, yy, dx * expansion, dy * expansion, ) for guideline in glyph.guidelines: if guideline.x: guideline.x *= expansion if guideline.y: guideline.y *= expansion def scale_kerning(kerning, expansion): for pair in kerning: kerning[pair] *= expansion ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/Lib/ufomerge/utils.py0000644000175100001660000000574115045447306017013 0ustar00runnerdockerfrom typing import Any, Iterable, Mapping, Optional, List, Dict, Set from fontTools.feaLib import ast import copy def filter_glyphs(glyphs: Iterable[str], glyphset: Set[str]) -> list[str]: return [glyph for glyph in glyphs if glyph in glyphset] def filter_glyph_mapping( glyphs: Mapping[str, Any], glyphset: Set[str] ) -> dict[str, Any]: return {name: data for name, data in glyphs.items() if name in glyphset} def filter_sequence( slots: Iterable, glyphset: Set[str], class_name_references: Optional[Dict[str, List[ast.GlyphClassName]]] = None, ) -> list[list[str]]: newslots = [] for slot in slots: if isinstance(slot, list): newslots.append(filter_glyphs(slot, glyphset)) else: newslots.append( filter_glyph_container(slot, glyphset, class_name_references) ) return newslots def filter_glyph_container( container: Any, glyphset: Set[str], class_name_references: Optional[Dict[str, List[ast.GlyphClassName]]] = None, ) -> Any: if isinstance(container, str): # Grr. container = ast.GlyphName(container) if isinstance(container, ast.GlyphName): # Single glyph if container.glyph not in glyphset: return ast.GlyphClass([]) return container if isinstance(container, ast.GlyphClass): container.glyphs = filter_glyphs(container.glyphs, glyphset) # I don't know what `original` is for, but it can undo subsetting # when calling asFea(): container.original = [] return container if isinstance(container, ast.GlyphClassName): # Make a copy of the container, we'll deduplicate and correct names # in a second pass later. container_copy = copy.deepcopy(container) if class_name_references is not None: copy_list = class_name_references[container_copy.glyphclass.name] container_copy.glyphclass.name = ( f"{container_copy.glyphclass.name}_{len(copy_list)}" ) copy_list.append(container_copy) # Filter the class, see if there's anything left classdef = container_copy.glyphclass.glyphs classdef.glyphs = filter_glyphs(classdef.glyphs, glyphset) if classdef.glyphs: return container_copy return ast.GlyphClass([]) if isinstance(container, ast.MarkClassName): markclass = container.markClass markclass.glyphs = filter_glyph_mapping(markclass.glyphs, glyphset) if markclass.glyphs: return container return ast.MarkClass([]) raise ValueError(f"Unknown glyph container {container}") def has_any_empty_slots(sequence: list) -> bool: for slot in sequence: if isinstance(slot, list): if len(slot) == 0: return True elif hasattr(slot, "glyphSet"): if len(slot.glyphSet()) == 0: return True else: raise ValueError return False ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3351314 ufomerge-1.9.6/Lib/ufomerge.egg-info/0000755000175100001660000000000015045447316016765 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge.egg-info/PKG-INFO0000644000175100001660000000507315045447316020067 0ustar00runnerdockerMetadata-Version: 2.4 Name: ufomerge Version: 1.9.6 Summary: Merge together two source fonts in UFO format Author-email: Simon Cozens Classifier: Environment :: Console Classifier: Topic :: Text Processing :: Fonts Description-Content-Type: text/markdown License-File: LICENSE License-File: AUTHORS.txt Requires-Dist: fonttools>=4.53.1 Requires-Dist: ufoLib2 Provides-Extra: dev Requires-Dist: pytest; extra == "dev" Requires-Dist: black; extra == "dev" Requires-Dist: fontFeatures; extra == "dev" Dynamic: license-file # ufomerge This command line utility and Python library merges together two UFO source format fonts into a single file. It can be used to include glyphs from one font into another font. It takes care of handling: * Glyph outlines and information * Kerning * `lib` entries * Including any needed components * "Subsetting" and merging layout rules ## Usage To merge the glyphs of `font-b.ufo` into `font-a.ufo` and save as `merged.ufo`: ``` $ ufomerge --output merged.ufo font-a.ufo font-b.ufo ``` To include particular glyphs: ``` $ ufomerge --output merged.ufo --glyphs alpha,beta,gamma font-a.ufo font-b.ufo ``` To include glyphs referencing particular Unicode codepoints: ``` $ ufomerge --output merged.ufo --unicodes 0x03B1,0x03B2,0x03B3 font-a.ufo font-b.ufo ``` Other useful command line parameters: * `-G`/`--glyphs-file`: Read the glyphs from a file containing one glyph per line. * `-U`/`--codepoints-file`: Read the Unicode codepoints from a file containing one codepoint per line. * `-x`/`--exclude-glyphs`: Stop the given glyphs from being included. * `-v`/`--verbose`: Be noisier. What to do about existing glyphs: * `--skip-existing` (the default): If a glyph from `font-b` already exists in `font-a`, nothing happens. * `--replace-existing`: If a glyph from `font-b` already exists in `font-a`, the new glyph replaces the old one. What do to about OpenType layout (`features.fea`). Suppose there is a rule `sub A B by C;`, and the incoming glyphs are `A` and `B`: * `--subset-layout` (the default): the rule is dropped, because `C` is not part of the target glyphset. The ligature stops working. * `--layout-closure`: `C` is added to the target glyphset and merged into `font-a` so the ligature continues to work. * `--ignore-layout`: No layout rules are copied from `font-b` at all. ## Usage as a Python library `ufomerge` provides two functions, `merge_ufos` and `subset_ufo`. Both take `ufoLib2.Font` objects, and are documented in their docstrings. ## License This software is licensed under the Apache license. See [LICENSE](LICENSE). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge.egg-info/SOURCES.txt0000644000175100001660000000106415045447316020652 0ustar00runnerdocker.gitignore AUTHORS.txt CONTRIBUTORS.txt LICENSE README.md pyproject.toml .github/workflows/ci.yml .github/workflows/test.yml Lib/ufomerge/__init__.py Lib/ufomerge/__main__.py Lib/ufomerge/_version.py Lib/ufomerge/cli.py Lib/ufomerge/layout.py Lib/ufomerge/scaler.py Lib/ufomerge/utils.py Lib/ufomerge.egg-info/PKG-INFO Lib/ufomerge.egg-info/SOURCES.txt Lib/ufomerge.egg-info/dependency_links.txt Lib/ufomerge.egg-info/entry_points.txt Lib/ufomerge.egg-info/requires.txt Lib/ufomerge.egg-info/top_level.txt tests/conftest.py tests/test_basic.py tests/test_layout.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge.egg-info/dependency_links.txt0000644000175100001660000000000115045447316023033 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge.egg-info/entry_points.txt0000644000175100001660000000005715045447316022265 0ustar00runnerdocker[console_scripts] ufomerge = ufomerge.cli:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge.egg-info/requires.txt0000644000175100001660000000007315045447316021365 0ustar00runnerdockerfonttools>=4.53.1 ufoLib2 [dev] pytest black fontFeatures ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681038.0 ufomerge-1.9.6/Lib/ufomerge.egg-info/top_level.txt0000644000175100001660000000001115045447316021507 0ustar00runnerdockerufomerge ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3361316 ufomerge-1.9.6/PKG-INFO0000644000175100001660000000507315045447316014056 0ustar00runnerdockerMetadata-Version: 2.4 Name: ufomerge Version: 1.9.6 Summary: Merge together two source fonts in UFO format Author-email: Simon Cozens Classifier: Environment :: Console Classifier: Topic :: Text Processing :: Fonts Description-Content-Type: text/markdown License-File: LICENSE License-File: AUTHORS.txt Requires-Dist: fonttools>=4.53.1 Requires-Dist: ufoLib2 Provides-Extra: dev Requires-Dist: pytest; extra == "dev" Requires-Dist: black; extra == "dev" Requires-Dist: fontFeatures; extra == "dev" Dynamic: license-file # ufomerge This command line utility and Python library merges together two UFO source format fonts into a single file. It can be used to include glyphs from one font into another font. It takes care of handling: * Glyph outlines and information * Kerning * `lib` entries * Including any needed components * "Subsetting" and merging layout rules ## Usage To merge the glyphs of `font-b.ufo` into `font-a.ufo` and save as `merged.ufo`: ``` $ ufomerge --output merged.ufo font-a.ufo font-b.ufo ``` To include particular glyphs: ``` $ ufomerge --output merged.ufo --glyphs alpha,beta,gamma font-a.ufo font-b.ufo ``` To include glyphs referencing particular Unicode codepoints: ``` $ ufomerge --output merged.ufo --unicodes 0x03B1,0x03B2,0x03B3 font-a.ufo font-b.ufo ``` Other useful command line parameters: * `-G`/`--glyphs-file`: Read the glyphs from a file containing one glyph per line. * `-U`/`--codepoints-file`: Read the Unicode codepoints from a file containing one codepoint per line. * `-x`/`--exclude-glyphs`: Stop the given glyphs from being included. * `-v`/`--verbose`: Be noisier. What to do about existing glyphs: * `--skip-existing` (the default): If a glyph from `font-b` already exists in `font-a`, nothing happens. * `--replace-existing`: If a glyph from `font-b` already exists in `font-a`, the new glyph replaces the old one. What do to about OpenType layout (`features.fea`). Suppose there is a rule `sub A B by C;`, and the incoming glyphs are `A` and `B`: * `--subset-layout` (the default): the rule is dropped, because `C` is not part of the target glyphset. The ligature stops working. * `--layout-closure`: `C` is added to the target glyphset and merged into `font-a` so the ligature continues to work. * `--ignore-layout`: No layout rules are copied from `font-b` at all. ## Usage as a Python library `ufomerge` provides two functions, `merge_ufos` and `subset_ufo`. Both take `ufoLib2.Font` objects, and are documented in their docstrings. ## License This software is licensed under the Apache license. See [LICENSE](LICENSE). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/README.md0000644000175100001660000000403115045447306014230 0ustar00runnerdocker# ufomerge This command line utility and Python library merges together two UFO source format fonts into a single file. It can be used to include glyphs from one font into another font. It takes care of handling: * Glyph outlines and information * Kerning * `lib` entries * Including any needed components * "Subsetting" and merging layout rules ## Usage To merge the glyphs of `font-b.ufo` into `font-a.ufo` and save as `merged.ufo`: ``` $ ufomerge --output merged.ufo font-a.ufo font-b.ufo ``` To include particular glyphs: ``` $ ufomerge --output merged.ufo --glyphs alpha,beta,gamma font-a.ufo font-b.ufo ``` To include glyphs referencing particular Unicode codepoints: ``` $ ufomerge --output merged.ufo --unicodes 0x03B1,0x03B2,0x03B3 font-a.ufo font-b.ufo ``` Other useful command line parameters: * `-G`/`--glyphs-file`: Read the glyphs from a file containing one glyph per line. * `-U`/`--codepoints-file`: Read the Unicode codepoints from a file containing one codepoint per line. * `-x`/`--exclude-glyphs`: Stop the given glyphs from being included. * `-v`/`--verbose`: Be noisier. What to do about existing glyphs: * `--skip-existing` (the default): If a glyph from `font-b` already exists in `font-a`, nothing happens. * `--replace-existing`: If a glyph from `font-b` already exists in `font-a`, the new glyph replaces the old one. What do to about OpenType layout (`features.fea`). Suppose there is a rule `sub A B by C;`, and the incoming glyphs are `A` and `B`: * `--subset-layout` (the default): the rule is dropped, because `C` is not part of the target glyphset. The ligature stops working. * `--layout-closure`: `C` is added to the target glyphset and merged into `font-a` so the ligature continues to work. * `--ignore-layout`: No layout rules are copied from `font-b` at all. ## Usage as a Python library `ufomerge` provides two functions, `merge_ufos` and `subset_ufo`. Both take `ufoLib2.Font` objects, and are documented in their docstrings. ## License This software is licensed under the Apache license. See [LICENSE](LICENSE). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/pyproject.toml0000644000175100001660000000157415045447306015676 0ustar00runnerdocker[build-system] requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [project] name = "ufomerge" description = "Merge together two source fonts in UFO format" readme = "README.md" dynamic = ["version"] authors = [ { name = "Simon Cozens", email = "simon@simon-cozens.org" } ] classifiers = [ 'Environment :: Console', 'Topic :: Text Processing :: Fonts', ] dependencies = [ 'fonttools>=4.53.1', # We need visitor inheritance, introduced in 4.53.1 'ufoLib2', ] [project.optional-dependencies] dev = [ "pytest", "black", "fontFeatures" ] [project.scripts] ufomerge = "ufomerge.cli:main" [tool.setuptools.packages.find] where = ["Lib"] [tool.setuptools_scm] write_to = "Lib/ufomerge/_version.py" git_describe_command = "git describe --match 'v*'" [tool.pytest.ini_options] filterwarnings = ["ignore::DeprecationWarning"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3361316 ufomerge-1.9.6/setup.cfg0000644000175100001660000000004615045447316014575 0ustar00runnerdocker[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1754681038.3351314 ufomerge-1.9.6/tests/0000755000175100001660000000000015045447316014116 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/tests/conftest.py0000644000175100001660000000251615045447306016320 0ustar00runnerdockerimport pytest import ufoLib2 from fontFeatures.feaLib import FeaParser import re class Helpers: @staticmethod def create_ufo(glyphs: list[str]) -> ufoLib2.Font: font = ufoLib2.Font() for glyph in glyphs: font.newGlyph(glyph) return font @staticmethod def create_ufo_from_features(features: str) -> ufoLib2.Font: ff = FeaParser(features).parse() glyphset = set() for routine in ff.routines: for rule in routine.rules: glyphset |= set(rule.involved_glyphs) font = ufoLib2.Font() for glyph in glyphset: font.newGlyph(glyph) font.features.text = features return font @staticmethod def assert_glyphset(ufo: ufoLib2.Font, glyphs: list[str]): ufo_glyphs = set(ufo.keys()) assert ufo_glyphs == set(glyphs) @staticmethod def assert_features_similar(ufo: ufoLib2.Font, features: str): def transform(t): t = re.sub(r"(?m)^\s+", "", t) t = re.sub(r"(?m)^.*lookupflag 0;", "", t) t = re.sub(r"(?m)#.*$", "", t) t = re.sub(r"(?m)^\s*;?\s*$", "", t) t = re.sub(r"\n\n", "\n", t) return t assert transform(ufo.features.text) == transform(features) @pytest.fixture def helpers(): return Helpers ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/tests/test_basic.py0000644000175100001660000001210415045447306016605 0ustar00runnerdockerfrom ufomerge import merge_ufos from ufoLib2.objects.component import Component from ufoLib2.objects.anchor import Anchor def test_glyphset(helpers): ufo1 = helpers.create_ufo(["A", "B"]) ufo2 = helpers.create_ufo(["C", "D"]) merge_ufos(ufo1, ufo2) helpers.assert_glyphset(ufo1, ["A", "B", "C", "D"]) def test_component_closure(helpers): ufo1 = helpers.create_ufo(["A", "B"]) ufo2 = helpers.create_ufo(["C", "D", "comp"]) ufo2["D"].components.append(Component("comp")) merge_ufos(ufo1, ufo2, glyphs=["D"]) helpers.assert_glyphset(ufo1, ["A", "B", "D", "comp"]) def test_kerning_flat(helpers): ufo1 = helpers.create_ufo(["A", "B"]) ufo2 = helpers.create_ufo(["C", "D", "E"]) ufo2.kerning = { ("C", "D"): 20, ("C", "E"): 15, ("C", "A"): -20, # I can foresee some dispute about what this should do } merge_ufos(ufo1, ufo2, glyphs=["C", "D"]) assert ufo1.kerning == { ("C", "D"): 20, ("C", "A"): -20, } def test_existing_handling(helpers): ufo1 = helpers.create_ufo(["A", "B"]) ufo1["B"].width = 100 ufo2 = helpers.create_ufo(["B", "C"]) ufo2["B"].width = 200 merge_ufos(ufo1, ufo2, existing_handling="skip") assert ufo1["B"].width == 100 merge_ufos(ufo1, ufo2, existing_handling="replace") assert ufo1["B"].width == 200 def test_existing_handling_dict(helpers): ufo1 = helpers.create_ufo(["A", "B", "C", "D"]) ufo1["B"].width = 100 ufo1["C"].width = 100 ufo1["D"].width = 100 ufo2 = helpers.create_ufo(["B", "C", "D"]) ufo2["B"].width = 200 ufo2["C"].width = 200 ufo2["D"].width = 200 merge_ufos( ufo1, ufo2, existing_handling={"B": "skip", "C": "replace", "DEFAULT": "replace"}, ) assert ufo1["B"].width == 100 assert ufo1["C"].width == 200 assert ufo1["D"].width == 200 def test_kerning_groups(helpers): """Test that groups and kerning pairs of ufo1 are dropped if they reference any imported glyphs. This avoids stray kerning and glyphs being memebers of more than one group. """ ufo1 = helpers.create_ufo(["A", "B"]) ufo1.groups["public.kern1.foo"] = ["A"] ufo1.groups["public.kern2.foo"] = ["A"] ufo1.kerning[("public.kern1.foo", "public.kern2.foo")] = 10 ufo1.kerning[("public.kern1.foo", "B")] = 20 ufo1.kerning[("A", "public.kern2.foo")] = 30 ufo1.kerning[("A", "A")] = 40 ufo2 = helpers.create_ufo(["A", "B"]) ufo2.groups["public.kern1.bar"] = ["A"] ufo2.groups["public.kern2.bar"] = ["A"] ufo2.kerning[("public.kern1.bar", "public.kern2.bar")] = 50 ufo2.kerning[("public.kern1.bar", "B")] = 60 ufo2.kerning[("A", "public.kern2.bar")] = 70 ufo2.kerning[("A", "A")] = 80 merge_ufos(ufo1, ufo2) assert ufo1.groups == { "public.kern1.bar": ["A"], "public.kern2.bar": ["A"], } assert ufo1.kerning == { ("public.kern1.bar", "public.kern2.bar"): 50, ("public.kern1.bar", "B"): 60, ("A", "public.kern2.bar"): 70, ("A", "A"): 80, } def test_dotted_circle(helpers): """Test that anchors are merged if both fonts contain a dotted circle glyph.""" ufo1 = helpers.create_ufo(["A", "B", "dottedCircle"]) ufo1["dottedCircle"].appendAnchor(Anchor(0, 100, "top")) ufo2 = helpers.create_ufo(["A", "B", "dottedCircle"]) ufo2["dottedCircle"].appendAnchor(Anchor(0, -100, "bottom")) merge_ufos(ufo1, ufo2, merge_dotted_circle_anchors=False) assert set(a.name for a in ufo1["dottedCircle"].anchors) == { "bottom" } # We replaced. ufo1 = helpers.create_ufo(["A", "B", "dottedCircle"]) ufo1["dottedCircle"].appendAnchor(Anchor(0, 100, "top")) ufo2 = helpers.create_ufo(["A", "B", "dottedCircle"]) ufo2["dottedCircle"].appendAnchor(Anchor(0, -100, "bottom")) merge_ufos(ufo1, ufo2, merge_dotted_circle_anchors=True) assert set(a.name for a in ufo1["dottedCircle"].anchors) == {"top", "bottom"} def test_28(helpers): ufo1 = helpers.create_ufo(["A", "B"]) b1 = ufo1["B"] b1.height = 100 b1.unicodes = [0x42] ufo2 = helpers.create_ufo(["B", "C"]) b2 = ufo2["B"] b2.height = 200 b2.unicodes = [0x42] merge_ufos( ufo1, ufo2, exclude_glyphs=["B"], codepoints=[0x42], ) assert "B" in ufo1 assert ufo1["B"].height == 100 assert ufo1["B"].unicode == 0x42 # fails def test_glyphorder(helpers): ufo1 = helpers.create_ufo(["A", "B", "C"]) ufo1.lib["public.glyphOrder"] = ["A", "B", "C"] ufo2 = helpers.create_ufo(["D", "E"]) ufo2.lib["public.glyphOrder"] = ["D", "E"] merge_ufos(ufo1, ufo2) assert ufo1.lib.get("public.glyphOrder") == ["A", "B", "C", "D", "E"] # But what if the glyphOrder is not the ORDER OF THE GLYPHS?! ufo3 = helpers.create_ufo(["A", "B", "C"]) ufo3.lib["public.glyphOrder"] = ["A", "B", "C"] ufo4 = helpers.create_ufo(["C", "D", "E"]) ufo4.lib["public.glyphOrder"] = ["C", "E", "D"] merge_ufos(ufo3, ufo4, existing_handling="replace") assert ufo3.lib.get("public.glyphOrder") == ["A", "B", "C", "E", "D"] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1754681030.0 ufomerge-1.9.6/tests/test_layout.py0000644000175100001660000001575115045447306017054 0ustar00runnerdockerfrom ufomerge import merge_ufos, subset_ufo import pytest def test_layout_closure(helpers): ufo2 = helpers.create_ufo_from_features("feature ccmp { sub A A' B' by C; } ccmp;") ufo1 = subset_ufo(ufo2, glyphs=["A"], layout_handling="ignore") helpers.assert_glyphset(ufo1, ["A"]) assert ufo1.features.text == "" ufo1 = subset_ufo(ufo2, glyphs=["A", "B"], layout_handling="closure") helpers.assert_glyphset(ufo1, ["A", "B", "C"]) def test_ignorable_rule(helpers): ufo2 = helpers.create_ufo_from_features( "lookup ccmp1 { sub A B by C; sub A D by E; } ccmp1; feature ccmp { lookup ccmp1; } ccmp;" ) ufo1 = subset_ufo(ufo2, glyphs=["A", "B"]) helpers.assert_glyphset(ufo1, ["A", "B"]) ufo1 = subset_ufo(ufo2, glyphs=["A", "B"], layout_handling="closure") helpers.assert_glyphset(ufo1, ["A", "B", "C"]) helpers.assert_features_similar( ufo1, """ lookup ccmp1 { sub A B by C; } ccmp1; feature ccmp { lookup ccmp1; } ccmp; """, ) def test_pos(helpers): ufo2 = helpers.create_ufo_from_features( "lookup kern1 { pos [A B] 120; } kern1; feature kern { lookup kern1; } kern;" ) ufo1 = subset_ufo(ufo2, glyphs=["A"]) helpers.assert_features_similar( ufo1, """ lookup kern1 { pos [A] 120; } kern1; feature kern { lookup kern1; } kern; """, ) def test_chain(helpers): ufo2 = helpers.create_ufo_from_features( """ lookup chained { pos A 120; pos B 200; } chained; lookup chain { pos [A B]' lookup chained [A B C]; } chain; feature kern { lookup chain; } kern; """ ) ufo1 = subset_ufo(ufo2, glyphs=["A", "C"]) helpers.assert_features_similar( ufo1, """ lookup chained { pos A 120; } chained; lookup chain { pos [A]' lookup chained [A C]; } chain; feature kern { lookup chain; } kern; """, ) def test_languagesystems(helpers): ufo1 = helpers.create_ufo_from_features( """ languagesystem latn dflt; feature ccmp { sub A by B; } ccmp; """ ) ufo2 = helpers.create_ufo_from_features( """ languagesystem DFLT dflt; languagesystem dev2 dflt; languagesystem dev2 NEP; feature ccmp { sub ka-deva by sa-deva; script dev2; language NEP; sub ta-deva by kssa-deva; sub la-deva by kssa-deva; } ccmp; """ ) merge_ufos(ufo1, ufo2, glyphs=["ka-deva", "sa-deva", "kssa-deva", "ta-deva"]) helpers.assert_features_similar( ufo1, """ languagesystem DFLT dflt; languagesystem latn dflt; languagesystem dev2 dflt; languagesystem dev2 NEP; feature ccmp { sub A by B; } ccmp; feature ccmp { sub ka-deva by sa-deva; script dev2; language NEP; sub ta-deva by kssa-deva; } ccmp; """, ) def test_drop_contextual_empty_class(helpers): ufo2 = helpers.create_ufo_from_features( """ @DAGESH = [dagesh-hb]; @OFFENDING_PUNCTUATION = [period]; lookup hebrew_mark_resolve_clashing_punctuation { lookupflag RightToLeft; pos [vav-hb zayin-hb] @DAGESH @OFFENDING_PUNCTUATION' 60; } hebrew_mark_resolve_clashing_punctuation; feature kern { lookup hebrew_mark_resolve_clashing_punctuation; } kern; """ ) ufo1 = subset_ufo(ufo2, glyphs=["period"]) helpers.assert_features_similar( ufo1, """ @OFFENDING_PUNCTUATION = [period]; @DAGESH = [];""", ) def test_drop_mark_class(helpers): ufo2 = helpers.create_ufo_from_features( """ @something = [ a c ]; markClass @something @MC_above; feature mark { lookup MARK_BASE_above { @bGC_A_above = [A]; pos base @bGC_A_above mark @MC_above; } MARK_BASE_above; } mark; """ ) ufo1 = subset_ufo(ufo2, glyphs=["A"]) helpers.assert_features_similar( ufo1, """ @something = [];""", ) def test_deduplicate_classes(helpers): ufo2 = helpers.create_ufo_from_features( """ @SOMETHING = [a b c]; @SOMETHING_ALT = [a.alt b.alt c.alt]; feature kern { pos @SOMETHING @SOMETHING_ALT 60; pos @SOMETHING_ALT @SOMETHING 30; } kern; feature locl { sub @SOMETHING by @SOMETHING_ALT; } locl; feature rlig { lookup bla { @FOO = [b c]; sub @FOO a' @SOMETHING by b.alt; } bla; } rlig; """ ) ufo1 = subset_ufo(ufo2, glyphs=["a", "a.alt", "b.alt", "c", "c.alt"]) helpers.assert_features_similar( ufo1, """ @FOO = [c]; @SOMETHING_ALT = [a.alt b.alt c.alt]; @SOMETHING = [a c]; feature kern { pos @SOMETHING @SOMETHING_ALT 60; pos @SOMETHING_ALT @SOMETHING 30; } kern; feature locl { sub [a c] by [a.alt c.alt]; } locl; feature rlig { lookup bla { sub @FOO a' @SOMETHING by b.alt; } bla; } rlig; """, ) def test_cull_unwanted_named_features(helpers) -> None: ufo1 = helpers.create_ufo([]) ufo2 = helpers.create_ufo(["a", "a.alt", "b"]) ufo2.features.text = """ feature ss01 { featureNames { name "Single story a"; }; sub a by a.alt; } ss01; """ merge_ufos(ufo1, ufo2, ["b"]) assert "ss01" not in ufo1.features.text def test_cull_unwanted_aalt(helpers) -> None: ufo1 = helpers.create_ufo([]) ufo2 = helpers.create_ufo(["a", "a.alt", "b", "b.alt"]) ufo2.features.text = """ feature ss01 { featureNames { name "Single story a"; }; sub a by a.alt; } ss01; feature ss02 { sub b by b.alt; } ss02; feature aalt { feature ss01; feature ss02; } aalt; """ merge_ufos(ufo1, ufo2, ["b", "b.alt"]) assert "aalt" in ufo1.features.text assert "ss01" not in ufo1.features.text assert "ss02" in ufo1.features.text def test_both_ss_names(helpers) -> None: ufo1 = helpers.create_ufo(["a", "a.alt", "b"]) ufo1.features.text = """ feature ss01 { featureNames { name "Single story a"; }; sub a by a.alt; } ss01; """ ufo2 = helpers.create_ufo(["g", "g.alt"]) ufo2.features.text = """ feature ss02 { featureNames { name "Single story g"; }; sub g by g.alt; } ss02; """ merge_ufos(ufo1, ufo2) assert "ss01" in ufo1.features.text assert "ss02" in ufo1.features.text assert "Single story a" in ufo1.features.text assert "Single story g" in ufo1.features.text