├── .gitattributes ├── .github └── workflows │ ├── deploy.yml │ ├── main.yml │ └── publish-release.yml ├── .gitignore ├── .pydocstyle.ini ├── .pylintrc ├── .readthedocs.yaml ├── AUTHORS.txt ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.txt ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── renovate.json ├── shaperglot-cli ├── Cargo.toml └── src │ ├── check.rs │ ├── describe.rs │ ├── main.rs │ └── report.rs ├── shaperglot-lib ├── Cargo.toml ├── manual_checks.toml └── src │ ├── checker.rs │ ├── checks │ ├── codepoint_coverage.rs │ ├── mod.rs │ ├── no_orphaned_marks.rs │ └── shaping_differs.rs │ ├── font.rs │ ├── language.rs │ ├── lib.rs │ ├── providers │ ├── mod.rs │ ├── orthographies.rs │ ├── positional.rs │ ├── small_caps.rs │ └── toml.rs │ ├── reporter.rs │ └── shaping.rs ├── shaperglot-py ├── Cargo.toml ├── docs │ ├── Makefile │ ├── _build │ │ └── .gitignore │ ├── conf.py │ └── index.rst ├── pyproject.toml ├── python │ └── shaperglot │ │ ├── __init__.py │ │ ├── __main__.py │ │ └── cli │ │ ├── __init__.py │ │ ├── check.py │ │ ├── describe.py │ │ ├── report.py │ │ └── whatuses.py └── src │ ├── check.rs │ ├── checker.rs │ ├── checkresult.rs │ ├── language.rs │ ├── lib.rs │ └── reporter.rs └── shaperglot-web ├── .gitignore ├── Cargo.toml ├── src └── lib.rs └── www ├── bootstrap.js ├── index.html ├── index.js ├── logo.png ├── package-lock.json ├── package.json ├── style.css ├── webpack.config.js └── webworker.js /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | CHANGELOG.md merge=union 3 | poetry.lock merge=binary 4 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build GH Pages 2 | on: 3 | push: 4 | branches: ["main"] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions-rust-lang/setup-rust-toolchain@v1 13 | # - name: Install protoc 14 | # run: sudo apt-get install protobuf-compiler 15 | - name: Install wasm-pack 16 | run: cargo install wasm-pack 17 | - name: Build 18 | run: cd shaperglot-web; wasm-pack build 19 | - name: Build web site 20 | run: | 21 | cd shaperglot-web/www 22 | npm install 23 | npm run build 24 | - name: Upload 25 | uses: actions/upload-pages-artifact@v3.0.1 26 | with: 27 | path: docs 28 | deploy: 29 | needs: build 30 | permissions: 31 | pages: write # to deploy to Pages 32 | id-token: write # to verify the deployment originates from an appropriate source 33 | environment: 34 | name: github-pages 35 | url: ${{ steps.deployment.outputs.page_url }} 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Deploy to GitHub Pages 39 | id: deployment 40 | uses: actions/deploy-pages@v4 41 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This file is autogenerated by maturin v1.3.2 2 | # To update, run 3 | # 4 | # maturin generate-ci github 5 | # 6 | name: CI 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | - master 13 | tags: 14 | - "*" 15 | pull_request: 16 | workflow_dispatch: 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | linux: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] 27 | steps: 28 | - uses: actions/checkout@v4 29 | #- name: Install Protoc 30 | # uses: arduino/setup-protoc@v3 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: "3.10" 34 | - name: Build wheels 35 | uses: PyO3/maturin-action@v1 36 | with: 37 | working-directory: shaperglot-py 38 | target: ${{ matrix.target }} 39 | before-script-linux: | 40 | case "${{ matrix.target }}" in 41 | "aarch64" | "armv7" | "s390x" | "ppc64le") 42 | sudo apt-get update 43 | sudo apt-get install -y protobuf-compiler 44 | ;; 45 | "x86" | "x86_64") 46 | yum update -y 47 | yum install -y protobuf-compiler 48 | ;; 49 | esac 50 | args: --release --out ../dist --find-interpreter 51 | sccache: "true" 52 | manylinux: auto 53 | - name: Upload wheels 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: wheels-linux-${{ matrix.target }} 57 | path: dist 58 | 59 | windows: 60 | runs-on: windows-latest 61 | strategy: 62 | matrix: 63 | target: [x64, x86] 64 | python: ["3.9", "3.10", "3.11", "3.12", "3.13"] 65 | steps: 66 | - uses: actions/checkout@v4 67 | - uses: actions/setup-python@v5 68 | with: 69 | python-version: ${{ matrix.python }} 70 | architecture: ${{ matrix.target }} 71 | #- name: Install Protoc 72 | # uses: arduino/setup-protoc@v3 73 | - name: Build wheels 74 | uses: PyO3/maturin-action@v1 75 | with: 76 | working-directory: shaperglot-py 77 | target: ${{ matrix.target }} 78 | args: --release --out ../dist --find-interpreter 79 | sccache: "true" 80 | - name: Upload wheels 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: wheels-windows-${{ matrix.target }}-${{ matrix.python }} 84 | path: dist 85 | 86 | macos: 87 | runs-on: macos-latest 88 | strategy: 89 | matrix: 90 | target: [x86_64, aarch64] 91 | python: ["3.9", "3.10", "3.11", "3.12", "3.13"] 92 | steps: 93 | - uses: actions/checkout@v4 94 | #- name: Install Protoc 95 | # uses: arduino/setup-protoc@v3 96 | # with: 97 | # repo-token: ${{ secrets.GITHUB_TOKEN }} 98 | - uses: actions/setup-python@v5 99 | with: 100 | python-version: ${{ matrix.python }} 101 | - name: Build wheels 102 | uses: PyO3/maturin-action@v1 103 | with: 104 | working-directory: shaperglot-py 105 | target: ${{ matrix.target }} 106 | args: --release --out ../dist --find-interpreter 107 | sccache: "true" 108 | - name: Upload wheels 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: wheels-macos-${{ matrix.target }}-${{ matrix.python }} 112 | path: dist 113 | 114 | pyodide: 115 | runs-on: ubuntu-latest 116 | strategy: 117 | matrix: 118 | # https://pyodide.org/en/stable/project/changelog.html 119 | version: 120 | - python: '3.12' 121 | emscripten: 3.1.58 # pyodide 0.27.* 0.26.* 122 | - python: '3.11' 123 | emscripten: 3.1.46 # pyodide 0.25.* 124 | - python: '3.11' 125 | emscripten: 3.1.45 # pyodide 0.24.* 126 | steps: 127 | - uses: actions/checkout@v4 128 | - name: Install Rust toolchain 129 | uses: dtolnay/rust-toolchain@stable 130 | with: 131 | toolchain: nightly 132 | target: wasm32-unknown-emscripten 133 | - uses: actions/setup-python@v5 134 | with: 135 | python-version: ${{ matrix.version.python }} 136 | - name: Install emscripten 137 | run: | 138 | git clone https://github.com/emscripten-core/emsdk.git 139 | cd emsdk 140 | ./emsdk install ${{ matrix.version.emscripten }} 141 | ./emsdk activate ${{ matrix.version.emscripten }} 142 | source ./emsdk_env.sh 143 | which emcc 144 | emcc --version 145 | - name: Add emcc to PATH 146 | run: echo "/home/runner/work/shaperglot/shaperglot/emsdk/upstream/emscripten" >> $GITHUB_PATH 147 | # Install modern version of binaryen (contains wasm-opt) to allow for a flag that cargo uses but can't be influenced. 148 | # Because the wasm-opt version that ships with emsdk is too old. 149 | # Since I can't influence the PATH of the emsdk (I tried), I have to replace the version that ships with emsdk. 150 | - name: Install specific version of binaryen 151 | run: | 152 | BINARYEN_VERSION=122 153 | wget https://github.com/WebAssembly/binaryen/releases/download/version_$BINARYEN_VERSION/binaryen-version_$BINARYEN_VERSION-x86_64-linux.tar.gz 154 | tar -xzf binaryen-version_$BINARYEN_VERSION-x86_64-linux.tar.gz 155 | sudo cp -r binaryen-version_$BINARYEN_VERSION/* /usr/local/ 156 | which wasm-opt 157 | wasm-opt --version 158 | rm /home/runner/work/shaperglot/shaperglot/emsdk/upstream/bin/wasm-opt 159 | ln -s /usr/local/bin/wasm-opt /home/runner/work/shaperglot/shaperglot/emsdk/upstream/bin/wasm-opt 160 | - name: Build 161 | env: 162 | RUSTUP_TOOLCHAIN: nightly 163 | run: | 164 | pip install maturin 165 | mkdir dist 166 | cd shaperglot-py 167 | maturin build -i python${{ matrix.version.python }} --strip --release --target wasm32-unknown-emscripten -o ../dist 168 | - name: Upload Pyodide wheel 169 | uses: actions/upload-artifact@v4 170 | with: 171 | name: wheels-pyodide-${{ matrix.version.python }}-${{ matrix.version.emscripten }} 172 | path: dist 173 | 174 | sdist: 175 | runs-on: ubuntu-latest 176 | steps: 177 | - uses: actions/checkout@v4 178 | - name: Build sdist 179 | uses: PyO3/maturin-action@v1 180 | with: 181 | working-directory: shaperglot-py 182 | command: sdist 183 | args: --out ../dist 184 | - name: Upload sdist 185 | uses: actions/upload-artifact@v4 186 | with: 187 | name: wheels-sdist 188 | path: dist 189 | 190 | consolidate: 191 | needs: [linux, windows, macos, pyodide, sdist] 192 | name: Consolidate wheels 193 | runs-on: ubuntu-latest 194 | steps: 195 | - uses: actions/upload-artifact/merge@v4 196 | with: 197 | pattern: wheels-* 198 | name: wheels 199 | 200 | release: 201 | name: Release 202 | runs-on: ubuntu-latest 203 | if: "startsWith(github.ref, 'refs/tags/')" 204 | needs: consolidate 205 | steps: 206 | - uses: actions/download-artifact@v4 207 | with: 208 | name: wheels 209 | - name: Publish to PyPI 210 | uses: PyO3/maturin-action@v1 211 | env: 212 | MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 213 | with: 214 | command: upload 215 | args: --non-interactive --skip-existing * 216 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*" # Push events to matching `v*` version srings. e.g. v1.0, v20.15.10 5 | 6 | name: Create and Publish Release 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | submodules: recursive 16 | fetch-depth: 0 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.x' 21 | 22 | - name: Install release dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install --upgrade setuptools wheel build 26 | 27 | - name: Get release notes 28 | id: release_notes 29 | run: | 30 | # By default, GH Actions checkout will only fetch a single commit. 31 | # For us to extract the release notes, we need to fetch the tags 32 | # and tag annotations as well. 33 | # https://github.com/actions/checkout/issues/290 34 | git fetch --tags --force 35 | TAG_NAME=${GITHUB_REF/refs\/tags\//} 36 | echo "$(git tag -l --format='%(contents)' $TAG_NAME)" > "${{ runner.temp }}/CHANGELOG.md" 37 | 38 | - name: Create GitHub release 39 | id: create_release 40 | uses: actions/create-release@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | tag_name: ${{ github.ref }} 45 | release_name: ${{ github.ref }} 46 | body_path: "${{ runner.temp }}/CHANGELOG.md" 47 | draft: false 48 | prerelease: false 49 | 50 | - name: Build a binary wheel and a source tarball 51 | run: python3 -m build 52 | - name: Store the distribution packages 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: python-package-distributions 56 | path: dist/ 57 | 58 | publish-to-pypi: 59 | name: >- 60 | Publish Python 🐍 distribution 📦 to PyPI 61 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 62 | needs: 63 | - build 64 | runs-on: ubuntu-latest 65 | environment: 66 | name: pypi 67 | url: https://pypi.org/p/shaperglot 68 | permissions: 69 | id-token: write # IMPORTANT: mandatory for trusted publishing 70 | steps: 71 | - name: Download all the dists 72 | uses: actions/download-artifact@v4 73 | with: 74 | name: python-package-distributions 75 | path: dist/ 76 | - name: Publish distribution 📦 to PyPI 77 | uses: pypa/gh-action-pypi-publish@v1.12.4 78 | with: 79 | # repository-url: https://test.pypi.org/legacy/ # for testing purposes 80 | verify-metadata: false # twine previously didn't verify metadata when uploading 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | # Temporary Python files 4 | *.pyc 5 | *.egg-info 6 | __pycache__ 7 | .ipynb_checkpoints 8 | setup.py 9 | pip-wheel-metadata/ 10 | 11 | # Temporary OS files 12 | Icon* 13 | 14 | # Temporary virtual environment files 15 | /.cache/ 16 | /.venv/ 17 | 18 | # Temporary server files 19 | .env 20 | *.pid 21 | 22 | # Generated documentation 23 | /docs/gen/ 24 | /docs/apidocs/ 25 | /site/ 26 | /*.html 27 | /docs/*.png 28 | 29 | # Google Drive 30 | *.gdoc 31 | *.gsheet 32 | *.gslides 33 | *.gdraw 34 | 35 | # Testing and coverage results 36 | /.coverage 37 | /.coverage.* 38 | /htmlcov/ 39 | coverage.xml 40 | 41 | # Build and release directories 42 | /build/ 43 | /dist/ 44 | *.spec 45 | 46 | # Sublime Text 47 | *.sublime-workspace 48 | 49 | # Eclipse 50 | .settings 51 | -------------------------------------------------------------------------------- /.pydocstyle.ini: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | 3 | # D211: No blank lines allowed before class docstring 4 | add_select = D211 5 | 6 | # D100: Missing docstring in public module 7 | # D101: Missing docstring in public class 8 | # D102: Missing docstring in public method 9 | # D103: Missing docstring in public function 10 | # D104: Missing docstring in public package 11 | # D105: Missing docstring in magic method 12 | # D107: Missing docstring in __init__ 13 | # D202: No blank lines allowed after function docstring 14 | add_ignore = D100,D101,D102,D103,D104,D105,D107,D202 15 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=CVS 3 | jobs=1 4 | persistent=yes 5 | unsafe-load-any-extension=no 6 | 7 | disable= 8 | C0116, 9 | C0115, 10 | C0114, 11 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: ./shaperglot-py/docs/conf.py 5 | builder: html 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "latest" 11 | rust: "latest" 12 | apt_packages: 13 | - protobuf-compiler 14 | 15 | python: 16 | install: 17 | - method: pip 18 | path: ./shaperglot-py 19 | extra_requirements: 20 | - docs 21 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | # This is the official list of authors for copyright purposes. 2 | # This file is distinct from the CONTRIBUTORS files. 3 | # See the latter for an explanation. 4 | # 5 | # Names should be added to this file as: 6 | # Name or Organization 7 | # The email address is not required for organizations. 8 | 9 | Google LLC 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.0.0 (YYYY-MM-DD) 2 | 3 | - TBD 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing a Shaperglot Profile 2 | 3 | Shaperglot relies on language-specific profile files which encode information about how languages should be correctly implemented. Additional language profiles from people with knowledge of a particular language's shaping requirements are very welcome. 4 | 5 | ## The structure of a profile 6 | 7 | Language profiles are implemented as [YAML](https://www.cloudbees.com/blog/yaml-tutorial-everything-you-need-get-started) files, placed in the `shaperglot/languages` directory. The language profile should be named after the [ISO639-3](https://iso639-3.sil.org/code_tables/639/data) tag for the language. For example, a language profile for Bemba should be found in the file `shaperglot/languages/bem.yaml`. 8 | 9 | Shaperglot automatically tests that a font supports all base and mark characters identified by Hyperglot as being required for language support. Beyond that, Shaperglot language profiles define various tests for correct shaping. Writing these tests correctly is more of an art than a science. **Because font engineers may implement language-specific layout rules in arbitrary ways and because fonts may have an arbitrary set of glyphs, these tests must be written in a way that "probes" the font - without checking for specific rules or making assumptions about glyph names or the presence or absence of specific glyphs - but which collectively provide a picture of the font's likely shaping implementation.** In many cases, we will be testing that the font does *something* in particular situations - without necessarily testing for some specific result. 10 | 11 | A language profile may have the following top-level elements: 12 | 13 | ### `features` 14 | 15 | This is a list of OpenType feature tags, together with certain tests: 16 | 17 | * `present` simply checks that the feature is present. 18 | 19 | ```yaml 20 | features: 21 | locl: 22 | - present 23 | ``` 24 | 25 | * `involves` checks whether the given Unicode codepoint is involved in *any* rules defined by this feature. For example, this code: 26 | 27 | ```yaml 28 | features: 29 | init: 30 | - involves: 0x0639 31 | ``` 32 | 33 | ensures that a rule in the `init` feature does *something* with U+0639 ARABIC LETTER AIN. In the particular case of the `mark` feature, the special check `involves: hyperglot` ensures that all codepoints designated as marks in the Hyperglot database are involved in rules in the `mark` feature. 34 | 35 | ### `languagesystems` 36 | 37 | This checks that the given script and language tag is present in the font's language systems list: 38 | 39 | ```yaml 40 | languagesystems: ["arab", "URD "] 41 | ``` 42 | 43 | ### `shaping` 44 | 45 | This is where the majority of the checks will find themselves. This will run the Harfbuzz shaper on one or more `inputs`, and allow you to check the results. Here is how inputs are specified: 46 | 47 | ### `inputs` 48 | 49 | Each input is a dictionary with at least the `text` key, and optionally `language` and `features` keys: 50 | 51 | ```yaml 52 | shaping: 53 | inputs: 54 | - text: "abc" 55 | language: "bem" 56 | features: 57 | smcp: true 58 | ``` 59 | 60 | The `features` dictionary is passed to `uharfbuzz`, and can specify either boolean values to turn on/off certain features, or a list of ranges of character positions and whether the feature should be on or off. Here is an example of the second form of the `features` dictionary: 61 | 62 | ```yaml 63 | shaping: 64 | inputs: 65 | - text: "abcdef" 66 | features: 67 | smcp: 68 | - [0, 3, 0] 69 | - [3, 6, 1] 70 | ``` 71 | 72 | This turns off the `smcp` feature for the first three characters but turns it on for characters `def`. 73 | 74 | Once the inputs have been shaped, the next stage is to compare the outputs. As the results will be glyph IDs, and we cannot assume anything about glyph coverage, tests will generally ensure that something *differs* between shaping runs. 75 | 76 | ### `differs` 77 | 78 | This check compares the glyph IDs returned from the shaper at different cluster positions, and ensures that they differ. Returned glyph ID sequences to be compared are specified based on their *index*, which can take three forms: 79 | 80 | * `[input ID, cluster ID, glyph within cluster]` 81 | * `[input ID, cluster ID]` 82 | * `cluster ID` (for comparisons of two clusters within the same, first input) 83 | 84 | > You will need to ensure that you are familiar with the concept of a *cluster* in OpenType shaping. The Harfbuzz documentation defines a cluster as "a sequence of codepoints that must be treated as an indivisible unit. Clusters can include code-point sequences that form a ligature or base-and-mark sequences." 85 | 86 | For example, the Burmese medial ra glyph needs to stretch across the width of the consonant that it surrounds. To test that this happens correctly, we shape two inputs, one with a medial ra surrounding a single bowl consonant and one with a double bowl consonant: 87 | 88 | ```yaml 89 | shaping: 90 | inputs: 91 | - text: "ပြ" 92 | - text: "ကြ" 93 | ``` 94 | 95 | This sets up our inputs. Now we check that the first glyph of the first cluster we got from shaping the first input is different to the first glyph of the first cluster we got from shaping the second input: 96 | 97 | ```yaml 98 | differs: 99 | - [0,0,0] 100 | - [1,0,0] 101 | ``` 102 | 103 | and we provide a reason for the test, which is displayed with the test report: 104 | 105 | ```yaml 106 | rationale: Medial ra should be selected based on width of base consonant 107 | ``` 108 | 109 | To test for correct `smcp` handling of the letter `i` in Turkish (which should uppercase to `İ`), we first shape small-cap `i` *without* Turkish language support, and then we shape it again with Turkish language turned on, and check we get different results: 110 | 111 | ```yaml 112 | - inputs: 113 | - text: "hi" 114 | features: 115 | smcp: true 116 | - text: "hi" 117 | language: "tr" 118 | features: 119 | smcp: true 120 | differs: 121 | - [0,1] 122 | - [1,1] 123 | rationale: "Small-caps processing of Turkish should replace i with dotted I" 124 | ``` 125 | 126 | Notice that here we are using the second index format to compare whole clusters; it's just that in non-syllabic scripts, there's only one glyph in a cluster. 127 | 128 | Of course, this check only applies to fonts which *have* a small caps feature, so we can make the execution of the check conditional on the `smcp` feature being present: 129 | 130 | ```yaml 131 | if: 132 | feature: smcp 133 | ``` 134 | 135 | And finally, an example of the shorthand index format for testing for differences within a single input: 136 | 137 | ```yaml 138 | - inputs: 139 | - "کک" 140 | differs: 141 | - 0 142 | - 1 143 | rationale: Initial and final forms should differ 144 | ``` 145 | 146 | This shapes a single input of Arabic text, and checks that the glyphs returned at cluster zero differ from the glyphs at cluster one. 147 | 148 | ### `forms_cluster` 149 | 150 | Another shaping test checks that certain character sequences end up in the same cluster after shaping. This is important in two ways: to ensure that syllables have been correctly formed, and to ensure that required ligatures have been applied. 151 | 152 | For example, the lam-alif ligature is mandatory in Arabic, and we can test for its presence by shaping a lam-alif pair an ensuring that both characters end up in the same cluster: 153 | 154 | ```yaml 155 | - inputs: 156 | - "پلا" 157 | forms_cluster: [1, 2] 158 | rationale: Lam-alif should form ligature 159 | ``` 160 | 161 | The parameter for the `forms_cluster` check is a list of *character positions* in the input. Only one input string should be provided for `forms_cluster` checks. 162 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | # This is the official list of people who can contribute 2 | # (and typically have contributed) code to this repository. 3 | # The AUTHORS file lists the copyright holders; this file 4 | # lists people. For example, Google employees are listed here 5 | # but not in AUTHORS, because Google holds the copyright. 6 | # 7 | # Names should be added to this file only after verifying that 8 | # the individual or the individual's organization has agreed to 9 | # the appropriate Contributor License Agreement, found here: 10 | # 11 | # http://code.google.com/legal/individual-cla-v1.0.html 12 | # http://code.google.com/legal/corporate-cla-v1.0.html 13 | # 14 | # The agreement for individuals can be filled out on the web. 15 | # 16 | # When adding J Random Contributor's name to this file, 17 | # either J's name or J's organization's name should be 18 | # added to the AUTHORS file, depending on whether the 19 | # individual or corporate CLA was used. 20 | # 21 | # Names should be added to this file like so: 22 | # Name 23 | # 24 | # Please keep the list sorted. 25 | # (first name; alphabetical order) 26 | 27 | Simon Cozens 28 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "ambassador" 16 | version = "0.4.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "6b27ba24e4d8a188489d5a03c7fabc167a60809a383cdb4d15feb37479cd2a48" 19 | dependencies = [ 20 | "itertools 0.10.5", 21 | "proc-macro2", 22 | "quote", 23 | "syn 1.0.109", 24 | ] 25 | 26 | [[package]] 27 | name = "anstream" 28 | version = "0.6.18" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 31 | dependencies = [ 32 | "anstyle", 33 | "anstyle-parse", 34 | "anstyle-query", 35 | "anstyle-wincon", 36 | "colorchoice", 37 | "is_terminal_polyfill", 38 | "utf8parse", 39 | ] 40 | 41 | [[package]] 42 | name = "anstyle" 43 | version = "1.0.10" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 46 | 47 | [[package]] 48 | name = "anstyle-parse" 49 | version = "0.2.6" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 52 | dependencies = [ 53 | "utf8parse", 54 | ] 55 | 56 | [[package]] 57 | name = "anstyle-query" 58 | version = "1.1.2" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 61 | dependencies = [ 62 | "windows-sys", 63 | ] 64 | 65 | [[package]] 66 | name = "anstyle-wincon" 67 | version = "3.0.7" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 70 | dependencies = [ 71 | "anstyle", 72 | "once_cell", 73 | "windows-sys", 74 | ] 75 | 76 | [[package]] 77 | name = "anyhow" 78 | version = "1.0.95" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 81 | 82 | [[package]] 83 | name = "arrayvec" 84 | version = "0.7.6" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 87 | 88 | [[package]] 89 | name = "autocfg" 90 | version = "1.4.0" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 93 | 94 | [[package]] 95 | name = "bitflags" 96 | version = "2.8.0" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 99 | 100 | [[package]] 101 | name = "bumpalo" 102 | version = "3.17.0" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 105 | 106 | [[package]] 107 | name = "bytemuck" 108 | version = "1.21.0" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" 111 | dependencies = [ 112 | "bytemuck_derive", 113 | ] 114 | 115 | [[package]] 116 | name = "bytemuck_derive" 117 | version = "1.8.1" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" 120 | dependencies = [ 121 | "proc-macro2", 122 | "quote", 123 | "syn 2.0.98", 124 | ] 125 | 126 | [[package]] 127 | name = "bytes" 128 | version = "1.10.0" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" 131 | 132 | [[package]] 133 | name = "cfg-if" 134 | version = "1.0.0" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 137 | 138 | [[package]] 139 | name = "clap" 140 | version = "4.5.38" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" 143 | dependencies = [ 144 | "clap_builder", 145 | "clap_derive", 146 | ] 147 | 148 | [[package]] 149 | name = "clap_builder" 150 | version = "4.5.38" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" 153 | dependencies = [ 154 | "anstream", 155 | "anstyle", 156 | "clap_lex", 157 | "strsim", 158 | ] 159 | 160 | [[package]] 161 | name = "clap_derive" 162 | version = "4.5.32" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 165 | dependencies = [ 166 | "heck", 167 | "proc-macro2", 168 | "quote", 169 | "syn 2.0.98", 170 | ] 171 | 172 | [[package]] 173 | name = "clap_lex" 174 | version = "0.7.4" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 177 | 178 | [[package]] 179 | name = "colorchoice" 180 | version = "1.0.3" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 183 | 184 | [[package]] 185 | name = "colored" 186 | version = "3.0.0" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" 189 | dependencies = [ 190 | "windows-sys", 191 | ] 192 | 193 | [[package]] 194 | name = "console_error_panic_hook" 195 | version = "0.1.7" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 198 | dependencies = [ 199 | "cfg-if", 200 | "wasm-bindgen", 201 | ] 202 | 203 | [[package]] 204 | name = "core_maths" 205 | version = "0.1.1" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" 208 | dependencies = [ 209 | "libm", 210 | ] 211 | 212 | [[package]] 213 | name = "either" 214 | version = "1.13.0" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" 217 | 218 | [[package]] 219 | name = "equivalent" 220 | version = "1.0.1" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 223 | 224 | [[package]] 225 | name = "errno" 226 | version = "0.3.10" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 229 | dependencies = [ 230 | "libc", 231 | "windows-sys", 232 | ] 233 | 234 | [[package]] 235 | name = "fastrand" 236 | version = "2.3.0" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 239 | 240 | [[package]] 241 | name = "fixedbitset" 242 | version = "0.5.7" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 245 | 246 | [[package]] 247 | name = "font-types" 248 | version = "0.9.0" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" 251 | dependencies = [ 252 | "bytemuck", 253 | ] 254 | 255 | [[package]] 256 | name = "fontations" 257 | version = "0.1.0" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "011db3cf1a28538f05bd7118555aec155a6295689ecba65f62da8d2cdf10fe83" 260 | dependencies = [ 261 | "font-types", 262 | "read-fonts", 263 | "skrifa", 264 | "write-fonts", 265 | ] 266 | 267 | [[package]] 268 | name = "getrandom" 269 | version = "0.3.1" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" 272 | dependencies = [ 273 | "cfg-if", 274 | "libc", 275 | "wasi", 276 | "windows-targets", 277 | ] 278 | 279 | [[package]] 280 | name = "glob" 281 | version = "0.3.2" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" 284 | 285 | [[package]] 286 | name = "google-fonts-languages" 287 | version = "0.7.5" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "94a0d497e170bd982dabb247f7585463cbbea1e6dbc1fd7ed67181d5019d713e" 290 | dependencies = [ 291 | "bytes", 292 | "glob", 293 | "itertools 0.13.0", 294 | "prettyplease", 295 | "proc-macro2", 296 | "prost", 297 | "prost-build", 298 | "protobuf", 299 | "protobuf-parse", 300 | "protobuf-support", 301 | "protoc-bin-vendored", 302 | "quote", 303 | "serde", 304 | "serde_json", 305 | "syn 2.0.98", 306 | ] 307 | 308 | [[package]] 309 | name = "hashbrown" 310 | version = "0.12.3" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 313 | 314 | [[package]] 315 | name = "hashbrown" 316 | version = "0.15.2" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 319 | 320 | [[package]] 321 | name = "heck" 322 | version = "0.5.0" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 325 | 326 | [[package]] 327 | name = "home" 328 | version = "0.5.11" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" 331 | dependencies = [ 332 | "windows-sys", 333 | ] 334 | 335 | [[package]] 336 | name = "indexmap" 337 | version = "1.9.3" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 340 | dependencies = [ 341 | "autocfg", 342 | "hashbrown 0.12.3", 343 | "serde", 344 | ] 345 | 346 | [[package]] 347 | name = "indexmap" 348 | version = "2.7.1" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 351 | dependencies = [ 352 | "equivalent", 353 | "hashbrown 0.15.2", 354 | ] 355 | 356 | [[package]] 357 | name = "indoc" 358 | version = "2.0.5" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" 361 | 362 | [[package]] 363 | name = "is_terminal_polyfill" 364 | version = "1.70.1" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 367 | 368 | [[package]] 369 | name = "itertools" 370 | version = "0.10.5" 371 | source = "registry+https://github.com/rust-lang/crates.io-index" 372 | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" 373 | dependencies = [ 374 | "either", 375 | ] 376 | 377 | [[package]] 378 | name = "itertools" 379 | version = "0.13.0" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 382 | dependencies = [ 383 | "either", 384 | ] 385 | 386 | [[package]] 387 | name = "itertools" 388 | version = "0.14.0" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" 391 | dependencies = [ 392 | "either", 393 | ] 394 | 395 | [[package]] 396 | name = "itoa" 397 | version = "1.0.14" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 400 | 401 | [[package]] 402 | name = "js-sys" 403 | version = "0.3.77" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 406 | dependencies = [ 407 | "once_cell", 408 | "wasm-bindgen", 409 | ] 410 | 411 | [[package]] 412 | name = "kurbo" 413 | version = "0.11.2" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "1077d333efea6170d9ccb96d3c3026f300ca0773da4938cc4c811daa6df68b0c" 416 | dependencies = [ 417 | "arrayvec", 418 | "smallvec", 419 | ] 420 | 421 | [[package]] 422 | name = "libc" 423 | version = "0.2.169" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 426 | 427 | [[package]] 428 | name = "libm" 429 | version = "0.2.11" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" 432 | 433 | [[package]] 434 | name = "linux-raw-sys" 435 | version = "0.4.15" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 438 | 439 | [[package]] 440 | name = "log" 441 | version = "0.4.27" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 444 | 445 | [[package]] 446 | name = "memchr" 447 | version = "2.7.4" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 450 | 451 | [[package]] 452 | name = "memoffset" 453 | version = "0.9.1" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" 456 | dependencies = [ 457 | "autocfg", 458 | ] 459 | 460 | [[package]] 461 | name = "multimap" 462 | version = "0.10.0" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" 465 | 466 | [[package]] 467 | name = "once_cell" 468 | version = "1.20.3" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" 471 | 472 | [[package]] 473 | name = "petgraph" 474 | version = "0.7.1" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" 477 | dependencies = [ 478 | "fixedbitset", 479 | "indexmap 2.7.1", 480 | ] 481 | 482 | [[package]] 483 | name = "portable-atomic" 484 | version = "1.10.0" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" 487 | 488 | [[package]] 489 | name = "prettyplease" 490 | version = "0.2.29" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "6924ced06e1f7dfe3fa48d57b9f74f55d8915f5036121bef647ef4b204895fac" 493 | dependencies = [ 494 | "proc-macro2", 495 | "syn 2.0.98", 496 | ] 497 | 498 | [[package]] 499 | name = "proc-macro2" 500 | version = "1.0.93" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 503 | dependencies = [ 504 | "unicode-ident", 505 | ] 506 | 507 | [[package]] 508 | name = "prost" 509 | version = "0.13.5" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" 512 | dependencies = [ 513 | "bytes", 514 | "prost-derive", 515 | ] 516 | 517 | [[package]] 518 | name = "prost-build" 519 | version = "0.13.5" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" 522 | dependencies = [ 523 | "heck", 524 | "itertools 0.14.0", 525 | "log", 526 | "multimap", 527 | "once_cell", 528 | "petgraph", 529 | "prettyplease", 530 | "prost", 531 | "prost-types", 532 | "regex", 533 | "syn 2.0.98", 534 | "tempfile", 535 | ] 536 | 537 | [[package]] 538 | name = "prost-derive" 539 | version = "0.13.5" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" 542 | dependencies = [ 543 | "anyhow", 544 | "itertools 0.14.0", 545 | "proc-macro2", 546 | "quote", 547 | "syn 2.0.98", 548 | ] 549 | 550 | [[package]] 551 | name = "prost-types" 552 | version = "0.13.5" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" 555 | dependencies = [ 556 | "prost", 557 | ] 558 | 559 | [[package]] 560 | name = "protobuf" 561 | version = "3.7.1" 562 | source = "registry+https://github.com/rust-lang/crates.io-index" 563 | checksum = "a3a7c64d9bf75b1b8d981124c14c179074e8caa7dfe7b6a12e6222ddcd0c8f72" 564 | dependencies = [ 565 | "once_cell", 566 | "protobuf-support", 567 | "thiserror", 568 | ] 569 | 570 | [[package]] 571 | name = "protobuf-parse" 572 | version = "3.7.1" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "322330e133eab455718444b4e033ebfac7c6528972c784fcde28d2cc783c6257" 575 | dependencies = [ 576 | "anyhow", 577 | "indexmap 2.7.1", 578 | "log", 579 | "protobuf", 580 | "protobuf-support", 581 | "tempfile", 582 | "thiserror", 583 | "which", 584 | ] 585 | 586 | [[package]] 587 | name = "protobuf-support" 588 | version = "3.7.1" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "b088fd20b938a875ea00843b6faf48579462630015c3788d397ad6a786663252" 591 | dependencies = [ 592 | "thiserror", 593 | ] 594 | 595 | [[package]] 596 | name = "protoc-bin-vendored" 597 | version = "3.1.0" 598 | source = "registry+https://github.com/rust-lang/crates.io-index" 599 | checksum = "dd89a830d0eab2502c81a9b8226d446a52998bb78e5e33cb2637c0cdd6068d99" 600 | dependencies = [ 601 | "protoc-bin-vendored-linux-aarch_64", 602 | "protoc-bin-vendored-linux-ppcle_64", 603 | "protoc-bin-vendored-linux-x86_32", 604 | "protoc-bin-vendored-linux-x86_64", 605 | "protoc-bin-vendored-macos-aarch_64", 606 | "protoc-bin-vendored-macos-x86_64", 607 | "protoc-bin-vendored-win32", 608 | ] 609 | 610 | [[package]] 611 | name = "protoc-bin-vendored-linux-aarch_64" 612 | version = "3.1.0" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "f563627339f1653ea1453dfbcb4398a7369b768925eb14499457aeaa45afe22c" 615 | 616 | [[package]] 617 | name = "protoc-bin-vendored-linux-ppcle_64" 618 | version = "3.1.0" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "5025c949a02cd3b60c02501dd0f348c16e8fff464f2a7f27db8a9732c608b746" 621 | 622 | [[package]] 623 | name = "protoc-bin-vendored-linux-x86_32" 624 | version = "3.1.0" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "9c9500ce67d132c2f3b572504088712db715755eb9adf69d55641caa2cb68a07" 627 | 628 | [[package]] 629 | name = "protoc-bin-vendored-linux-x86_64" 630 | version = "3.1.0" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "5462592380cefdc9f1f14635bcce70ba9c91c1c2464c7feb2ce564726614cc41" 633 | 634 | [[package]] 635 | name = "protoc-bin-vendored-macos-aarch_64" 636 | version = "3.1.0" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "c637745681b68b4435484543667a37606c95ddacf15e917710801a0877506030" 639 | 640 | [[package]] 641 | name = "protoc-bin-vendored-macos-x86_64" 642 | version = "3.1.0" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "38943f3c90319d522f94a6dfd4a134ba5e36148b9506d2d9723a82ebc57c8b55" 645 | 646 | [[package]] 647 | name = "protoc-bin-vendored-win32" 648 | version = "3.1.0" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "7dc55d7dec32ecaf61e0bd90b3d2392d721a28b95cfd23c3e176eccefbeab2f2" 651 | 652 | [[package]] 653 | name = "pyo3" 654 | version = "0.25.0" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "f239d656363bcee73afef85277f1b281e8ac6212a1d42aa90e55b90ed43c47a4" 657 | dependencies = [ 658 | "indoc", 659 | "libc", 660 | "memoffset", 661 | "once_cell", 662 | "portable-atomic", 663 | "pyo3-build-config", 664 | "pyo3-ffi", 665 | "pyo3-macros", 666 | "unindent", 667 | ] 668 | 669 | [[package]] 670 | name = "pyo3-build-config" 671 | version = "0.25.0" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "755ea671a1c34044fa165247aaf6f419ca39caa6003aee791a0df2713d8f1b6d" 674 | dependencies = [ 675 | "once_cell", 676 | "target-lexicon", 677 | ] 678 | 679 | [[package]] 680 | name = "pyo3-ffi" 681 | version = "0.25.0" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "fc95a2e67091e44791d4ea300ff744be5293f394f1bafd9f78c080814d35956e" 684 | dependencies = [ 685 | "libc", 686 | "pyo3-build-config", 687 | ] 688 | 689 | [[package]] 690 | name = "pyo3-macros" 691 | version = "0.25.0" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "a179641d1b93920829a62f15e87c0ed791b6c8db2271ba0fd7c2686090510214" 694 | dependencies = [ 695 | "proc-macro2", 696 | "pyo3-macros-backend", 697 | "quote", 698 | "syn 2.0.98", 699 | ] 700 | 701 | [[package]] 702 | name = "pyo3-macros-backend" 703 | version = "0.25.0" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "9dff85ebcaab8c441b0e3f7ae40a6963ecea8a9f5e74f647e33fcf5ec9a1e89e" 706 | dependencies = [ 707 | "heck", 708 | "proc-macro2", 709 | "pyo3-build-config", 710 | "quote", 711 | "syn 2.0.98", 712 | ] 713 | 714 | [[package]] 715 | name = "pythonize" 716 | version = "0.25.0" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "597907139a488b22573158793aa7539df36ae863eba300c75f3a0d65fc475e27" 719 | dependencies = [ 720 | "pyo3", 721 | "serde", 722 | ] 723 | 724 | [[package]] 725 | name = "quote" 726 | version = "1.0.38" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 729 | dependencies = [ 730 | "proc-macro2", 731 | ] 732 | 733 | [[package]] 734 | name = "read-fonts" 735 | version = "0.29.0" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "5ce8e2ca6b24313587a03ca61bb74c384e2a815bd90cf2866cfc9f5fb7a11fa0" 738 | dependencies = [ 739 | "bytemuck", 740 | "font-types", 741 | ] 742 | 743 | [[package]] 744 | name = "regex" 745 | version = "1.11.1" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 748 | dependencies = [ 749 | "aho-corasick", 750 | "memchr", 751 | "regex-automata", 752 | "regex-syntax", 753 | ] 754 | 755 | [[package]] 756 | name = "regex-automata" 757 | version = "0.4.9" 758 | source = "registry+https://github.com/rust-lang/crates.io-index" 759 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 760 | dependencies = [ 761 | "aho-corasick", 762 | "memchr", 763 | "regex-syntax", 764 | ] 765 | 766 | [[package]] 767 | name = "regex-syntax" 768 | version = "0.8.5" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 771 | 772 | [[package]] 773 | name = "rustix" 774 | version = "0.38.44" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 777 | dependencies = [ 778 | "bitflags", 779 | "errno", 780 | "libc", 781 | "linux-raw-sys", 782 | "windows-sys", 783 | ] 784 | 785 | [[package]] 786 | name = "rustversion" 787 | version = "1.0.19" 788 | source = "registry+https://github.com/rust-lang/crates.io-index" 789 | checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" 790 | 791 | [[package]] 792 | name = "rustybuzz" 793 | version = "0.20.1" 794 | source = "registry+https://github.com/rust-lang/crates.io-index" 795 | checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" 796 | dependencies = [ 797 | "bitflags", 798 | "bytemuck", 799 | "core_maths", 800 | "log", 801 | "smallvec", 802 | "ttf-parser", 803 | "unicode-bidi-mirroring", 804 | "unicode-ccc", 805 | "unicode-properties", 806 | "unicode-script", 807 | ] 808 | 809 | [[package]] 810 | name = "ryu" 811 | version = "1.0.19" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" 814 | 815 | [[package]] 816 | name = "serde" 817 | version = "1.0.219" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 820 | dependencies = [ 821 | "serde_derive", 822 | ] 823 | 824 | [[package]] 825 | name = "serde_derive" 826 | version = "1.0.219" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 829 | dependencies = [ 830 | "proc-macro2", 831 | "quote", 832 | "syn 2.0.98", 833 | ] 834 | 835 | [[package]] 836 | name = "serde_json" 837 | version = "1.0.140" 838 | source = "registry+https://github.com/rust-lang/crates.io-index" 839 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 840 | dependencies = [ 841 | "indexmap 2.7.1", 842 | "itoa", 843 | "memchr", 844 | "ryu", 845 | "serde", 846 | ] 847 | 848 | [[package]] 849 | name = "serde_spanned" 850 | version = "0.6.8" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 853 | dependencies = [ 854 | "serde", 855 | ] 856 | 857 | [[package]] 858 | name = "shaperglot" 859 | version = "1.0.0" 860 | dependencies = [ 861 | "ambassador", 862 | "colored", 863 | "fontations", 864 | "google-fonts-languages", 865 | "indexmap 2.7.1", 866 | "itertools 0.14.0", 867 | "log", 868 | "rustybuzz", 869 | "serde", 870 | "serde_json", 871 | "toml", 872 | "unicode-joining-type", 873 | "unicode-normalization", 874 | "unicode-properties", 875 | ] 876 | 877 | [[package]] 878 | name = "shaperglot-cli" 879 | version = "1.0.0" 880 | dependencies = [ 881 | "clap", 882 | "fontations", 883 | "itertools 0.14.0", 884 | "serde_json", 885 | "shaperglot", 886 | "toml", 887 | ] 888 | 889 | [[package]] 890 | name = "shaperglot-py" 891 | version = "1.0.2" 892 | dependencies = [ 893 | "pyo3", 894 | "pythonize", 895 | "shaperglot", 896 | ] 897 | 898 | [[package]] 899 | name = "shaperglot-web" 900 | version = "0.1.0" 901 | dependencies = [ 902 | "console_error_panic_hook", 903 | "fontations", 904 | "google-fonts-languages", 905 | "indexmap 1.9.3", 906 | "js-sys", 907 | "serde_json", 908 | "shaperglot", 909 | "wasm-bindgen", 910 | ] 911 | 912 | [[package]] 913 | name = "skrifa" 914 | version = "0.31.0" 915 | source = "registry+https://github.com/rust-lang/crates.io-index" 916 | checksum = "bbe6666ab11018ab91ff7b03f1a3b9fdbecfb610848436fefa5ce50343d3d913" 917 | dependencies = [ 918 | "bytemuck", 919 | "read-fonts", 920 | ] 921 | 922 | [[package]] 923 | name = "smallvec" 924 | version = "1.15.0" 925 | source = "registry+https://github.com/rust-lang/crates.io-index" 926 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 927 | 928 | [[package]] 929 | name = "strsim" 930 | version = "0.11.1" 931 | source = "registry+https://github.com/rust-lang/crates.io-index" 932 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 933 | 934 | [[package]] 935 | name = "syn" 936 | version = "1.0.109" 937 | source = "registry+https://github.com/rust-lang/crates.io-index" 938 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 939 | dependencies = [ 940 | "proc-macro2", 941 | "quote", 942 | "unicode-ident", 943 | ] 944 | 945 | [[package]] 946 | name = "syn" 947 | version = "2.0.98" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" 950 | dependencies = [ 951 | "proc-macro2", 952 | "quote", 953 | "unicode-ident", 954 | ] 955 | 956 | [[package]] 957 | name = "target-lexicon" 958 | version = "0.13.2" 959 | source = "registry+https://github.com/rust-lang/crates.io-index" 960 | checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" 961 | 962 | [[package]] 963 | name = "tempfile" 964 | version = "3.16.0" 965 | source = "registry+https://github.com/rust-lang/crates.io-index" 966 | checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" 967 | dependencies = [ 968 | "cfg-if", 969 | "fastrand", 970 | "getrandom", 971 | "once_cell", 972 | "rustix", 973 | "windows-sys", 974 | ] 975 | 976 | [[package]] 977 | name = "thiserror" 978 | version = "1.0.69" 979 | source = "registry+https://github.com/rust-lang/crates.io-index" 980 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 981 | dependencies = [ 982 | "thiserror-impl", 983 | ] 984 | 985 | [[package]] 986 | name = "thiserror-impl" 987 | version = "1.0.69" 988 | source = "registry+https://github.com/rust-lang/crates.io-index" 989 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 990 | dependencies = [ 991 | "proc-macro2", 992 | "quote", 993 | "syn 2.0.98", 994 | ] 995 | 996 | [[package]] 997 | name = "tinyvec" 998 | version = "1.8.1" 999 | source = "registry+https://github.com/rust-lang/crates.io-index" 1000 | checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" 1001 | dependencies = [ 1002 | "tinyvec_macros", 1003 | ] 1004 | 1005 | [[package]] 1006 | name = "tinyvec_macros" 1007 | version = "0.1.1" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 1010 | 1011 | [[package]] 1012 | name = "toml" 1013 | version = "0.8.22" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" 1016 | dependencies = [ 1017 | "serde", 1018 | "serde_spanned", 1019 | "toml_datetime", 1020 | "toml_edit", 1021 | ] 1022 | 1023 | [[package]] 1024 | name = "toml_datetime" 1025 | version = "0.6.9" 1026 | source = "registry+https://github.com/rust-lang/crates.io-index" 1027 | checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" 1028 | dependencies = [ 1029 | "serde", 1030 | ] 1031 | 1032 | [[package]] 1033 | name = "toml_edit" 1034 | version = "0.22.26" 1035 | source = "registry+https://github.com/rust-lang/crates.io-index" 1036 | checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" 1037 | dependencies = [ 1038 | "indexmap 2.7.1", 1039 | "serde", 1040 | "serde_spanned", 1041 | "toml_datetime", 1042 | "toml_write", 1043 | "winnow", 1044 | ] 1045 | 1046 | [[package]] 1047 | name = "toml_write" 1048 | version = "0.1.1" 1049 | source = "registry+https://github.com/rust-lang/crates.io-index" 1050 | checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" 1051 | 1052 | [[package]] 1053 | name = "ttf-parser" 1054 | version = "0.25.1" 1055 | source = "registry+https://github.com/rust-lang/crates.io-index" 1056 | checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" 1057 | dependencies = [ 1058 | "core_maths", 1059 | ] 1060 | 1061 | [[package]] 1062 | name = "unicode-bidi-mirroring" 1063 | version = "0.4.0" 1064 | source = "registry+https://github.com/rust-lang/crates.io-index" 1065 | checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" 1066 | 1067 | [[package]] 1068 | name = "unicode-ccc" 1069 | version = "0.4.0" 1070 | source = "registry+https://github.com/rust-lang/crates.io-index" 1071 | checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" 1072 | 1073 | [[package]] 1074 | name = "unicode-ident" 1075 | version = "1.0.16" 1076 | source = "registry+https://github.com/rust-lang/crates.io-index" 1077 | checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" 1078 | 1079 | [[package]] 1080 | name = "unicode-joining-type" 1081 | version = "1.0.0" 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" 1083 | checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" 1084 | 1085 | [[package]] 1086 | name = "unicode-normalization" 1087 | version = "0.1.24" 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" 1089 | checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" 1090 | dependencies = [ 1091 | "tinyvec", 1092 | ] 1093 | 1094 | [[package]] 1095 | name = "unicode-properties" 1096 | version = "0.1.3" 1097 | source = "registry+https://github.com/rust-lang/crates.io-index" 1098 | checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" 1099 | 1100 | [[package]] 1101 | name = "unicode-script" 1102 | version = "0.5.7" 1103 | source = "registry+https://github.com/rust-lang/crates.io-index" 1104 | checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" 1105 | 1106 | [[package]] 1107 | name = "unindent" 1108 | version = "0.2.3" 1109 | source = "registry+https://github.com/rust-lang/crates.io-index" 1110 | checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" 1111 | 1112 | [[package]] 1113 | name = "utf8parse" 1114 | version = "0.2.2" 1115 | source = "registry+https://github.com/rust-lang/crates.io-index" 1116 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1117 | 1118 | [[package]] 1119 | name = "wasi" 1120 | version = "0.13.3+wasi-0.2.2" 1121 | source = "registry+https://github.com/rust-lang/crates.io-index" 1122 | checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" 1123 | dependencies = [ 1124 | "wit-bindgen-rt", 1125 | ] 1126 | 1127 | [[package]] 1128 | name = "wasm-bindgen" 1129 | version = "0.2.100" 1130 | source = "registry+https://github.com/rust-lang/crates.io-index" 1131 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1132 | dependencies = [ 1133 | "cfg-if", 1134 | "once_cell", 1135 | "rustversion", 1136 | "wasm-bindgen-macro", 1137 | ] 1138 | 1139 | [[package]] 1140 | name = "wasm-bindgen-backend" 1141 | version = "0.2.100" 1142 | source = "registry+https://github.com/rust-lang/crates.io-index" 1143 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1144 | dependencies = [ 1145 | "bumpalo", 1146 | "log", 1147 | "proc-macro2", 1148 | "quote", 1149 | "syn 2.0.98", 1150 | "wasm-bindgen-shared", 1151 | ] 1152 | 1153 | [[package]] 1154 | name = "wasm-bindgen-macro" 1155 | version = "0.2.100" 1156 | source = "registry+https://github.com/rust-lang/crates.io-index" 1157 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1158 | dependencies = [ 1159 | "quote", 1160 | "wasm-bindgen-macro-support", 1161 | ] 1162 | 1163 | [[package]] 1164 | name = "wasm-bindgen-macro-support" 1165 | version = "0.2.100" 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" 1167 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1168 | dependencies = [ 1169 | "proc-macro2", 1170 | "quote", 1171 | "syn 2.0.98", 1172 | "wasm-bindgen-backend", 1173 | "wasm-bindgen-shared", 1174 | ] 1175 | 1176 | [[package]] 1177 | name = "wasm-bindgen-shared" 1178 | version = "0.2.100" 1179 | source = "registry+https://github.com/rust-lang/crates.io-index" 1180 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1181 | dependencies = [ 1182 | "unicode-ident", 1183 | ] 1184 | 1185 | [[package]] 1186 | name = "which" 1187 | version = "4.4.2" 1188 | source = "registry+https://github.com/rust-lang/crates.io-index" 1189 | checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" 1190 | dependencies = [ 1191 | "either", 1192 | "home", 1193 | "once_cell", 1194 | "rustix", 1195 | ] 1196 | 1197 | [[package]] 1198 | name = "windows-sys" 1199 | version = "0.59.0" 1200 | source = "registry+https://github.com/rust-lang/crates.io-index" 1201 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1202 | dependencies = [ 1203 | "windows-targets", 1204 | ] 1205 | 1206 | [[package]] 1207 | name = "windows-targets" 1208 | version = "0.52.6" 1209 | source = "registry+https://github.com/rust-lang/crates.io-index" 1210 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1211 | dependencies = [ 1212 | "windows_aarch64_gnullvm", 1213 | "windows_aarch64_msvc", 1214 | "windows_i686_gnu", 1215 | "windows_i686_gnullvm", 1216 | "windows_i686_msvc", 1217 | "windows_x86_64_gnu", 1218 | "windows_x86_64_gnullvm", 1219 | "windows_x86_64_msvc", 1220 | ] 1221 | 1222 | [[package]] 1223 | name = "windows_aarch64_gnullvm" 1224 | version = "0.52.6" 1225 | source = "registry+https://github.com/rust-lang/crates.io-index" 1226 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1227 | 1228 | [[package]] 1229 | name = "windows_aarch64_msvc" 1230 | version = "0.52.6" 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" 1232 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1233 | 1234 | [[package]] 1235 | name = "windows_i686_gnu" 1236 | version = "0.52.6" 1237 | source = "registry+https://github.com/rust-lang/crates.io-index" 1238 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1239 | 1240 | [[package]] 1241 | name = "windows_i686_gnullvm" 1242 | version = "0.52.6" 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" 1244 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1245 | 1246 | [[package]] 1247 | name = "windows_i686_msvc" 1248 | version = "0.52.6" 1249 | source = "registry+https://github.com/rust-lang/crates.io-index" 1250 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1251 | 1252 | [[package]] 1253 | name = "windows_x86_64_gnu" 1254 | version = "0.52.6" 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" 1256 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1257 | 1258 | [[package]] 1259 | name = "windows_x86_64_gnullvm" 1260 | version = "0.52.6" 1261 | source = "registry+https://github.com/rust-lang/crates.io-index" 1262 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1263 | 1264 | [[package]] 1265 | name = "windows_x86_64_msvc" 1266 | version = "0.52.6" 1267 | source = "registry+https://github.com/rust-lang/crates.io-index" 1268 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1269 | 1270 | [[package]] 1271 | name = "winnow" 1272 | version = "0.7.10" 1273 | source = "registry+https://github.com/rust-lang/crates.io-index" 1274 | checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" 1275 | dependencies = [ 1276 | "memchr", 1277 | ] 1278 | 1279 | [[package]] 1280 | name = "wit-bindgen-rt" 1281 | version = "0.33.0" 1282 | source = "registry+https://github.com/rust-lang/crates.io-index" 1283 | checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" 1284 | dependencies = [ 1285 | "bitflags", 1286 | ] 1287 | 1288 | [[package]] 1289 | name = "write-fonts" 1290 | version = "0.38.0" 1291 | source = "registry+https://github.com/rust-lang/crates.io-index" 1292 | checksum = "f441672abab9ac7d1c2fe559c0364559728914d7da369d76591e6153cf561fc7" 1293 | dependencies = [ 1294 | "font-types", 1295 | "indexmap 2.7.1", 1296 | "kurbo", 1297 | "log", 1298 | "read-fonts", 1299 | ] 1300 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | members = [ 5 | "shaperglot-lib", 6 | "shaperglot-cli", 7 | "shaperglot-web", 8 | "shaperglot-py", 9 | ] 10 | 11 | default-members = ["shaperglot-lib", "shaperglot-cli"] 12 | 13 | [workspace.dependencies] 14 | fontations = "0.1.0" 15 | itertools = "0.14.0" 16 | google-fonts-languages = "0.7.5" 17 | toml = "0.8.22" 18 | serde_json = { version = "1.0.140", features = ["preserve_order"] } 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shaperglot - Test font files for OpenType language support 2 | 3 | [![PyPI Version](https://img.shields.io/pypi/v/shaperglot.svg)](https://pypi.org/project/shaperglot) 4 | [![PyPI License](https://img.shields.io/pypi/l/shaperglot.svg)](https://pypi.org/project/shaperglot) 5 | [![Read The Docs](https://readthedocs.org/projects/shaperglot/badge/)](https://https://shaperglot.readthedocs.io/en/latest/) 6 | 7 | Try [Shaperglot on the web](https://googlefonts.github.io/shaperglot)! 8 | 9 | Shaperglot is a library and a utility for testing a font's language support. 10 | You give it a font, and it tells you what languages are supported and to what 11 | degree. 12 | 13 | Most other libraries to check for language support (for example, Rosetta's 14 | wonderful [hyperglot](https://hyperglot.rosettatype.com) library) do this by 15 | looking at the Unicode codepoints that the font supports. Shaperglot takes 16 | a different approach. 17 | 18 | ## What's wrong with the Unicode codepoint coverage approach? 19 | 20 | For many common languages, it's possible to check that the language is 21 | supported just by looking at the Unicode coverage. For example, to support 22 | English, you need the 26 lowercase and uppercase letters of the Latin alphabet. 23 | 24 | However, for the majority of scripts around the world, covering the codepoints 25 | needed is not enough to say that a font really _supports_ a particular language. 26 | For correct language support, the font must also _behave_ in a particular way. 27 | 28 | Take the case of Arabic as an example. A font might contain glyphs which cover 29 | all the codepoints in the Arabic block (0x600-0x6FF). But the font only _supports_ 30 | Arabic if it implements joining rules for the `init`, `medi` and `fina` features. 31 | To say that a font supports Devanagari, it needs to implement conjuncts (which 32 | set of conjuncts need to be included before we can say the font "supports" 33 | Devanagari is debated...) and half forms, as well as contain a `languagesystem` 34 | statement which triggers Indic reordering. 35 | 36 | Even within the Latin script, a font only supports a language such as Turkish 37 | if its casing behaving respects the dotless / dotted I distinction; a font 38 | only supports Navajo if its ogonek anchoring is different to the anchoring used in 39 | Polish; and so on. 40 | 41 | But there's a further problem with testing language support by codepoint coverage: 42 | it encourages designers to "fill in the blanks" to get to support, rather than 43 | necessarily engage with the textual requirements of particular languages. 44 | 45 | ## Testing for behaviour, not coverage 46 | 47 | Shaperglot therefore determines language support not just on codepoint coverage, 48 | but also by examining how the font behaves when confronted with certain character 49 | sequences. 50 | 51 | The trick is to do this in a way which is not prescriptive. We know that there 52 | are many different ways of implementing language support within a font, and that 53 | design and other considerations will factor into precisely how a font is 54 | constructed. Shaperglot presents the font with different strings, and makes sure 55 | that "something interesting happened" - without necessarily specifying what. 56 | 57 | In the case of Arabic, we need to know that the `init` feature is present, and that 58 | when we shape some Arabic glyphs, the output with `init` turned on is different 59 | to the output with `init` turned off. We don't care what's different; we only 60 | care that something has happened. _(Yes, this still makes it possible to trick shaperglot into reporting support for a language which is not correctly implemented, but at that point, it's probably less effort to actually implement it...)_ 61 | 62 | Shaperglot includes the following kinds of test: 63 | 64 | - Certain codepoints were mapped to base or mark glyphs. 65 | - A named feature was present. 66 | - A named feature changed the output glyphs. 67 | - A mark glyph was attached to a base glyph or composed into a precomposed glyph (but not left unattached). 68 | - Certain glyphs in the output were different to one another. 69 | - Languagesystems were defined in the font. 70 | - ... 71 | 72 | ## Using Shaperglot 73 | 74 | Shaperglot consists of multiple components: 75 | 76 | ### Shaperglot Web interface 77 | 78 | The easiest way to use Shaperglot as an end-user or font developer is through the 79 | [web interface](https://googlefonts.github.io/shaperglot). This allows you to drag 80 | and drop a font to analyze its language coverage. This is entirely client-side, 81 | and all fonts remain on your computer. Nothing is uploaded. 82 | 83 | ### Shaperglot command line tools 84 | 85 | The next most user-friendly way to use Shaperglot is at the command line. You can 86 | install the latest version with: 87 | 88 | cargo install --git https://github.com/googlefonts/shaperglot 89 | 90 | This will provide you with a new tool called `shaperglot`. It has four subcommands: 91 | 92 | - `shaperglot check ...` checks whether a font supports the given language IDs. 93 | - `shaperglot report ` reports all languages supported by the font. 94 | - `shaperglot describe ` explains what needs to be done for a font to supportt a given language ID. 95 | 96 | ``` 97 | $ shaperglot describe Nuer 98 | The font MUST support the following Nuer bases and marks: 'a', 'A', 'ä', 'Ä', 'a̱', 'A̱', 'b', 'B', 'c', 'C', 'd', 'D', 'e', 'E', 'ë', 'Ë', 'e̱', 'E̱', 'ɛ', 'Ɛ', 'ɛ̈', 'Ɛ̈', 'ɛ̱', 'Ɛ̱', 'ɛ̱̈', 'Ɛ̱̈', 'f', 'F', 'g', 'G', 'ɣ', 'Ɣ', 'h', 'H', 'i', 'I', 'ï', 'Ï', 'i̱', 'I̱', 'j', 'J', 'k', 'K', 'l', 'L', 'm', 'M', 'n', 'N', 'ŋ', 'Ŋ', 'o', 'O', 'ö', 'Ö', 'o̱', 'O̱', 'ɔ', 'Ɔ', 'ɔ̈', 'Ɔ̈', 'ɔ̱', 'Ɔ̱', 'p', 'P', 'q', 'Q', 'r', 'R', 's', 'S', 't', 'T', 'u', 'U', 'v', 'V', 'w', 'W', 'x', 'X', 'y', 'Y', 'z', 'Z', '◌̈', '◌̱' 99 | The font SHOULD support the following auxiliary orthography codepoints: 'ʈ', 'Ʈ' 100 | Latin letters should form small caps when the smcp feature is enabled 101 | ``` 102 | 103 | ### Shaperglot Rust library 104 | 105 | See the documentation on https://docs.rs/shaperglot/latest 106 | 107 | ### Shaperglot Python library 108 | 109 | The Python library wraps the Rust library using PyO3. This new PyO3 implementation 110 | _broadly_ follows the same API as the original 0.x Python implementation, but all 111 | imports are at the top level (`from shaperglot import Checker`, etc.) The PyO3 112 | version is available as a pre-release from Pypi. 113 | 114 | Python Library Documentation: https://shaperglot.readthedocs.io/en/latest/ 115 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "rangeStrategy": "bump", 5 | "packageRules": [ 6 | { 7 | "matchPackageNames": ["/pyo3/", "pythonize"], 8 | "groupName": "pyo3" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /shaperglot-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shaperglot-cli" 3 | version = "1.0.0" 4 | edition = "2021" 5 | authors = ["The Shaperglot Authors"] 6 | description = "Test font files for OpenType language support" 7 | [[bin]] 8 | path = "src/main.rs" 9 | name = "shaperglot" 10 | 11 | [dependencies] 12 | shaperglot = { path = "../shaperglot-lib", features = ["colored"] } 13 | fontations = { workspace = true } 14 | itertools = { workspace = true } 15 | clap = { version = "4.5.38", features = ["derive"] } 16 | serde_json = { workspace = true } 17 | toml = { workspace = true } 18 | -------------------------------------------------------------------------------- /shaperglot-cli/src/check.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use itertools::Itertools; 3 | use shaperglot::{Checker, Reporter}; 4 | use std::{ 5 | collections::{HashMap, HashSet}, 6 | path::PathBuf, 7 | }; 8 | 9 | #[derive(Args)] 10 | pub struct CheckArgs { 11 | /// Number of fixes left to be considered nearly supported 12 | #[arg(long, default_value_t = 5, hide = true)] 13 | nearly: usize, 14 | /// Verbosity 15 | #[arg(short, long, action = clap::ArgAction::Count, conflicts_with = "json")] 16 | verbose: u8, 17 | /// Output check results as JSON 18 | #[arg(long)] 19 | json: bool, 20 | /// Output a fix summary 21 | #[arg(long, conflicts_with = "json")] 22 | fix: bool, 23 | /// Font file to check 24 | font: PathBuf, 25 | /// Language to check 26 | languages: Vec, 27 | } 28 | 29 | pub fn check_command(args: &CheckArgs, language_database: shaperglot::Languages) { 30 | let font_binary = std::fs::read(args.font.as_path()) 31 | .map_err(|e| { 32 | eprintln!("Failed to read font file {}: {}", args.font.display(), e); 33 | std::process::exit(1); 34 | }) 35 | .unwrap(); 36 | let checker = Checker::new(&font_binary).expect("Failed to load font"); 37 | let mut fixes_required = HashMap::new(); 38 | for language in args.languages.iter() { 39 | if let Some(language) = language_database.get_language(language) { 40 | let results = checker.check(language); 41 | if args.json { 42 | println!("{}", serde_json::to_string(&results).unwrap()); 43 | continue; 44 | } 45 | println!("{}", results.to_summary_string(language)); 46 | show_result(&results, args.verbose); 47 | if args.fix { 48 | for (category, fixes) in results.unique_fixes() { 49 | fixes_required 50 | .entry(category) 51 | .or_insert_with(HashSet::new) 52 | .extend(fixes); 53 | } 54 | } 55 | } else { 56 | println!("Language not found ({})", language); 57 | } 58 | } 59 | if args.fix { 60 | show_fixes(&fixes_required); 61 | } 62 | } 63 | 64 | fn show_result(results: &Reporter, verbose: u8) { 65 | for check in results.iter() { 66 | if verbose == 0 && check.problems.is_empty() { 67 | continue; 68 | } 69 | print!(" {}: {}", check.status, check.summary_result()); 70 | if verbose > 1 { 71 | println!( 72 | " (score {:.1}% with weight {})", 73 | check.score * 100.0, 74 | check.weight 75 | ); 76 | if verbose > 2 { 77 | println!(" {}", check.check_description); 78 | } 79 | } else { 80 | println!(); 81 | } 82 | if verbose > 1 || (verbose == 1 && !check.problems.is_empty()) { 83 | for problem in check.problems.iter() { 84 | println!(" * {}", problem.message); 85 | } 86 | } 87 | } 88 | println!(); 89 | } 90 | 91 | fn show_fixes(fixes: &HashMap>) { 92 | if fixes.is_empty() { 93 | return; 94 | } 95 | println!("\nTo add full support:"); 96 | for (category, fixes) in fixes { 97 | println!( 98 | "* {}:", 99 | match category.as_str() { 100 | "add_anchor" => "Add anchors between the following glyphs", 101 | "add_codepoint" => "Add the following codepoints to the font", 102 | "add_feature" => "Add the following features to the font", 103 | _ => category, 104 | } 105 | ); 106 | println!(" {}", fixes.iter().join(", ")); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /shaperglot-cli/src/describe.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | 3 | #[derive(Args)] 4 | pub struct DescribeArgs { 5 | /// Output check definition as TOML 6 | #[arg(long)] 7 | json: bool, 8 | /// Language name or ID to describe 9 | language: String, 10 | } 11 | 12 | pub fn describe_command(args: &DescribeArgs, language_database: shaperglot::Languages) { 13 | if let Some(language) = language_database.get_language(&args.language) { 14 | if args.json { 15 | let json = serde_json::to_string_pretty(&language.checks).unwrap(); 16 | println!("{}", json); 17 | // } 18 | } else { 19 | for check in language.checks.iter() { 20 | println!("{}", check.description); 21 | } 22 | } 23 | } else { 24 | println!("Language not found ({})", &args.language); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /shaperglot-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use check::{check_command, CheckArgs}; 2 | use clap::{Parser, Subcommand}; 3 | use describe::{describe_command, DescribeArgs}; 4 | use report::{report_command, ReportArgs}; 5 | 6 | mod check; 7 | mod describe; 8 | mod report; 9 | 10 | #[derive(Parser)] 11 | #[command(author, version, about, long_about = None)] 12 | #[command(propagate_version = true)] 13 | struct Cli { 14 | #[command(subcommand)] 15 | command: Commands, 16 | } 17 | 18 | #[derive(Subcommand)] 19 | enum Commands { 20 | /// Check language support 21 | Check(CheckArgs), 22 | /// Report language support 23 | Report(ReportArgs), 24 | /// Describe what is needed to support a language 25 | Describe(DescribeArgs), 26 | } 27 | 28 | fn main() { 29 | let cli = Cli::parse(); 30 | let language_database = shaperglot::Languages::new(); 31 | 32 | match &cli.command { 33 | Commands::Check(args) => { 34 | check_command(args, language_database); 35 | } 36 | Commands::Report(args) => { 37 | report_command(args, language_database); 38 | } 39 | Commands::Describe(args) => { 40 | describe_command(args, language_database); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shaperglot-cli/src/report.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use shaperglot::Checker; 3 | use std::path::PathBuf; 4 | 5 | #[derive(Args)] 6 | pub struct ReportArgs { 7 | /// Number of fixes left to be considered nearly supported 8 | #[arg(long, default_value_t = 5, hide = true)] 9 | nearly: usize, 10 | /// Verbosity 11 | #[arg(short, long, action = clap::ArgAction::Count, hide = true)] 12 | verbose: u8, 13 | /// Output check results as JSON 14 | #[arg(long, hide = true)] 15 | json: bool, 16 | /// Output check results as CSV 17 | #[arg(long, hide = true)] 18 | csv: bool, 19 | /// Regular expression to filter languages 20 | #[arg(long)] 21 | filter: Option, 22 | /// Output a fix summary 23 | #[arg(long, hide = true)] 24 | fix: bool, 25 | /// Font file to check 26 | font: PathBuf, 27 | } 28 | 29 | pub fn report_command(args: &ReportArgs, language_database: shaperglot::Languages) { 30 | let font_binary = std::fs::read(args.font.as_path()) 31 | .map_err(|e| { 32 | eprintln!("Failed to read font file {}: {}", args.font.display(), e); 33 | std::process::exit(1); 34 | }) 35 | .unwrap(); 36 | let checker = Checker::new(&font_binary).expect("Failed to load font"); 37 | for language in language_database.iter() { 38 | if let Some(filter) = &args.filter { 39 | if !language.id().contains(filter) { 40 | continue; 41 | } 42 | } 43 | let results = checker.check(language); 44 | if results.is_unknown() { 45 | continue; 46 | } 47 | println!("{}", results.to_summary_string(language)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /shaperglot-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shaperglot" 3 | version = "1.0.0" 4 | edition = "2021" 5 | license = "Apache-2.0" 6 | authors = ["The Shaperglot Authors"] 7 | description = "Test font files for OpenType language support" 8 | homepage = "https://github.com/googlefonts/shaperglot" 9 | repository = "https://github.com/googlefonts/shaperglot" 10 | 11 | [lib] 12 | path = "src/lib.rs" 13 | 14 | [features] 15 | default = ["fontations"] 16 | 17 | [dependencies] 18 | google-fonts-languages = { workspace = true } 19 | fontations = { workspace = true, optional = true } 20 | itertools = { workspace = true } 21 | rustybuzz = "0.20.1" 22 | serde_json = { workspace = true } 23 | unicode-normalization = "0.1.24" 24 | colored = { version = "3.0.0", optional = true } 25 | unicode-properties = "0.1.3" 26 | unicode-joining-type = "1.0.0" 27 | indexmap = "2" 28 | log = "0.4.27" 29 | toml = { workspace = true } 30 | serde = "1.0.219" 31 | ambassador = "0.4.1" 32 | -------------------------------------------------------------------------------- /shaperglot-lib/manual_checks.toml: -------------------------------------------------------------------------------- 1 | [[tr_Latn]] 2 | name = "Small caps i should be dotted" 3 | severity = "Warn" 4 | description = "When the letter 'i' is in small caps, it should be dotted" 5 | scoring_strategy = "Continuous" 6 | weight = 10 7 | 8 | [[tr_Latn.implementations]] 9 | type = "ShapingDiffers" 10 | features_optional = true 11 | ignore_notdefs = false 12 | pairs = [[ 13 | { text = "i", features = ["smcp"] }, 14 | { text = "i", features = ["smcp"], language = "tr" }, 15 | ]] 16 | -------------------------------------------------------------------------------- /shaperglot-lib/src/checker.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashSet}; 2 | 3 | use crate::{language::Language, reporter::Reporter, GlyphId, ResultCode}; 4 | use rustybuzz::Face; 5 | 6 | /// The context for running font language support checks 7 | pub struct Checker<'a> { 8 | /// The face to use for shaping 9 | pub face: Face<'a>, 10 | /// The glyph names in the font 11 | pub glyph_names: Vec, 12 | /// The OpenType features present in the font 13 | pub features: HashSet, 14 | /// The character map of the font 15 | pub cmap: BTreeMap, 16 | /// The reversed character map of the font 17 | reversed_cmap: BTreeMap, 18 | } 19 | 20 | impl<'a> Checker<'a> { 21 | /// Create an instance given the binary data of a font. 22 | #[cfg(feature = "fontations")] 23 | pub fn new(data: &'a [u8]) -> Result { 24 | use fontations::skrifa::{FontRef, MetadataProvider}; 25 | 26 | let font = FontRef::new(data)?; 27 | Ok(Self::from_parts( 28 | Face::from_slice(data, 0).expect("could not parse the font"), 29 | crate::font::glyph_names(&font)?, 30 | crate::font::feature_tags(&font)?, 31 | font.charmap() 32 | .mappings() 33 | .map(|(character, glyph)| (character, glyph.to_u32())) 34 | .collect(), 35 | )) 36 | } 37 | 38 | /// Create an instance given the parts of a font. 39 | pub fn from_parts( 40 | face: Face<'a>, 41 | glyph_names: Vec, 42 | features: HashSet, 43 | cmap: BTreeMap, 44 | ) -> Self { 45 | let reversed_cmap = cmap.iter().map(|(k, v)| (*v, *k)).collect(); 46 | Self { 47 | face, 48 | glyph_names, 49 | features, 50 | cmap, 51 | reversed_cmap, 52 | } 53 | } 54 | 55 | /// Get the codepoint for a given glyph ID. 56 | pub fn codepoint_for(&self, gid: GlyphId) -> Option { 57 | self.reversed_cmap.get(&gid).copied() 58 | } 59 | 60 | /// Check if the font supports a given language. 61 | pub fn check(&self, language: &Language) -> Reporter { 62 | let mut results = Reporter::default(); 63 | for check_object in language.checks.iter() { 64 | let checkresult = check_object.execute(self); 65 | let status = checkresult.status; 66 | results.add(checkresult); 67 | if status == ResultCode::StopNow { 68 | break; 69 | } 70 | } 71 | results 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /shaperglot-lib/src/checks/codepoint_coverage.rs: -------------------------------------------------------------------------------- 1 | use super::CheckImplementation; 2 | use crate::{ 3 | checker::Checker, 4 | reporter::{Fix, Problem}, 5 | }; 6 | use itertools::Itertools; 7 | use rustybuzz::Face; 8 | use serde::{Deserialize, Serialize}; 9 | use serde_json::json; 10 | use std::collections::HashSet; 11 | 12 | #[derive(Serialize, Deserialize, Debug, Clone)] 13 | /// A check implementation which ensures codepoints are present in a font 14 | pub struct CodepointCoverage { 15 | /// The codepoints to check for 16 | strings: HashSet, 17 | /// The unique code to return on failure (e.g. "marks-missing") 18 | code: String, 19 | /// Whether to mark the problem as terminal if no codepoints are found 20 | terminal_if_empty: bool, 21 | } 22 | 23 | fn can_shape(text: &str, face: &Face) -> bool { 24 | let mut buffer = rustybuzz::UnicodeBuffer::new(); 25 | buffer.push_str(text); 26 | let glyph_buffer = rustybuzz::shape(face, &[], buffer); 27 | glyph_buffer.glyph_infos().iter().all(|x| x.glyph_id != 0) 28 | } 29 | 30 | impl CheckImplementation for CodepointCoverage { 31 | fn name(&self) -> String { 32 | "CodepointCoverage".to_string() 33 | } 34 | 35 | fn should_skip(&self, _checker: &Checker) -> Option { 36 | None 37 | } 38 | 39 | fn execute(&self, checker: &Checker) -> (Vec, usize) { 40 | let checks_run = self.strings.len(); 41 | let missing_things: Vec<_> = self 42 | .strings 43 | .iter() 44 | .filter(|x| !can_shape(x, &checker.face)) 45 | .cloned() 46 | .collect(); 47 | let mut problems = vec![]; 48 | if !missing_things.is_empty() { 49 | let mut fail = Problem::new( 50 | &self.name(), 51 | &format!("{}s-missing", self.code), 52 | format!( 53 | "The following {} characters are missing from the font: {}", 54 | self.code, 55 | missing_things.join(", ") 56 | ), 57 | ); 58 | if missing_things.len() == self.strings.len() && self.terminal_if_empty { 59 | fail.terminal = true; 60 | } 61 | fail.context = json!({"glyphs": missing_things}); 62 | fail.fixes.extend(missing_things.iter().map(|x| Fix { 63 | fix_type: "add_codepoint".to_string(), 64 | fix_thing: x.to_string(), 65 | })); 66 | problems.push(fail); 67 | } 68 | (problems, checks_run) 69 | } 70 | 71 | fn describe(&self) -> String { 72 | format!( 73 | "Checks that all the following codepoints are covered in the font: {}", 74 | self.strings.iter().join(", ") 75 | ) 76 | } 77 | } 78 | 79 | impl CodepointCoverage { 80 | /// Create a new `CodepointCoverage` check implementation 81 | pub fn new(test_strings: Vec, code: String, terminal_if_empty: bool) -> Self { 82 | Self { 83 | strings: test_strings.into_iter().collect(), 84 | code, 85 | terminal_if_empty, 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /shaperglot-lib/src/checks/mod.rs: -------------------------------------------------------------------------------- 1 | /// A check implementation which ensures codepoints are present in a font 2 | mod codepoint_coverage; 3 | /// A check implementation which ensures marks are anchors to their respective base characters 4 | pub(crate) mod no_orphaned_marks; 5 | /// A check implementation which ensures that two shaping inputs produce different outputs 6 | pub(crate) mod shaping_differs; 7 | 8 | use crate::{ 9 | checker::Checker, 10 | reporter::{CheckResult, Problem}, 11 | ResultCode, 12 | }; 13 | use ambassador::{delegatable_trait, Delegate}; 14 | pub use codepoint_coverage::CodepointCoverage; 15 | pub use no_orphaned_marks::NoOrphanedMarks; 16 | use serde::{Deserialize, Serialize}; 17 | pub use shaping_differs::ShapingDiffers; 18 | 19 | #[delegatable_trait] 20 | /// A check implementation 21 | /// 22 | /// This is a sub-unit of a [Check]; a Check is made up of multiple 23 | /// `CheckImplementations`. For example, an orthography check will 24 | /// first check bases, then marks, then auxiliary codepoints. 25 | pub trait CheckImplementation { 26 | /// The name of the check implementation 27 | fn name(&self) -> String; 28 | /// A description of the check implementation 29 | fn describe(&self) -> String; 30 | /// Whether the subcheck should be skipped for this font 31 | fn should_skip(&self, checker: &Checker) -> Option; 32 | /// Execute the check implementation and return problems found 33 | fn execute(&self, checker: &Checker) -> (Vec, usize); 34 | } 35 | 36 | #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] 37 | /// The scoring strategy for a check 38 | pub enum ScoringStrategy { 39 | /// A continuous score; the score is the proportion of checks that pass 40 | Continuous, 41 | /// An all-or-nothing score; the score is 1 if all checks pass, 0 otherwise 42 | AllOrNothing, 43 | } 44 | 45 | #[derive(Delegate, Serialize, Deserialize, Debug, Clone)] 46 | #[delegate(CheckImplementation)] 47 | #[serde(tag = "type")] 48 | /// Check implementations available to higher-level checks 49 | pub enum CheckType { 50 | /// A check implementation which ensures codepoints are present in a font 51 | CodepointCoverage(CodepointCoverage), 52 | /// A check implementation which ensures marks are anchors to their respective base characters 53 | NoOrphanedMarks(NoOrphanedMarks), 54 | /// A check implementation which ensures that two shaping inputs produce different outputs 55 | ShapingDiffers(ShapingDiffers), 56 | } 57 | 58 | #[derive(Serialize, Deserialize, Debug, Clone)] 59 | /// A check to be executed 60 | pub struct Check { 61 | /// The name of the check 62 | pub name: String, 63 | /// The severity of the check in terms of how it affects language support 64 | pub severity: ResultCode, 65 | /// A description of the check 66 | pub description: String, 67 | /// The scoring strategy for the check 68 | pub scoring_strategy: ScoringStrategy, 69 | /// The weight of the check 70 | pub weight: u8, 71 | /// Individual implementations to be run 72 | pub implementations: Vec, 73 | } 74 | 75 | impl Check { 76 | /// Execute the check and return the results 77 | pub fn execute(&self, checker: &Checker) -> CheckResult { 78 | let mut problems = Vec::new(); 79 | let mut total_checks = 0; 80 | for implementation in &self.implementations { 81 | if let Some(skip_reason) = implementation.should_skip(checker) { 82 | // If there's only one implementation and we skipped, return a skip 83 | // result. Otherwise, add a skip problem. 84 | let skip_problem = Problem::new( 85 | &self.name, 86 | "skip", 87 | format!("Check skipped: {}", skip_reason), 88 | ); 89 | if self.implementations.len() == 1 { 90 | return CheckResult { 91 | check_name: self.name.clone(), 92 | check_description: self.description.clone(), 93 | status: ResultCode::Skip, 94 | score: 0.5, 95 | weight: self.weight, 96 | problems: vec![skip_problem], 97 | total_checks: 1, 98 | }; 99 | } else { 100 | problems.push(skip_problem); 101 | total_checks += 1; 102 | } 103 | } else { 104 | let (local_problems, checks_run) = implementation.execute(checker); 105 | problems.extend(local_problems); 106 | total_checks += checks_run; 107 | } 108 | } 109 | 110 | let score = match self.scoring_strategy { 111 | ScoringStrategy::AllOrNothing => { 112 | if problems.is_empty() { 113 | 1.0 114 | } else { 115 | 0.0 116 | } 117 | } 118 | ScoringStrategy::Continuous => { 119 | if total_checks == 0 { 120 | 1.0 121 | } else { 122 | 1.0 - (problems.len() as f32 / total_checks as f32) 123 | } 124 | } 125 | }; 126 | CheckResult { 127 | check_name: self.name.clone(), 128 | check_description: self.description.clone(), 129 | status: if total_checks == 0 { 130 | ResultCode::Skip 131 | } else if problems.is_empty() { 132 | ResultCode::Pass 133 | } else if self.scoring_strategy == ScoringStrategy::AllOrNothing 134 | && problems.iter().any(|p| p.terminal) 135 | { 136 | ResultCode::StopNow 137 | } else { 138 | self.severity 139 | }, 140 | score, 141 | weight: self.weight, 142 | problems, 143 | total_checks, 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /shaperglot-lib/src/checks/no_orphaned_marks.rs: -------------------------------------------------------------------------------- 1 | use super::CheckImplementation; 2 | use crate::{ 3 | checker::Checker, 4 | reporter::{Fix, Problem}, 5 | shaping::ShapingInput, 6 | }; 7 | use itertools::Itertools; 8 | use serde::{Deserialize, Serialize}; 9 | use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | /// A check implementation which ensures marks are anchors to their respective base characters 13 | pub struct NoOrphanedMarks { 14 | /// The strings to shape and check 15 | test_strings: Vec, 16 | /// Whether the language has orthography data 17 | /// 18 | /// If this is true, we will not report notdefs, as the orthography check will 19 | /// catch them. 20 | has_orthography: bool, 21 | } 22 | 23 | impl CheckImplementation for NoOrphanedMarks { 24 | fn name(&self) -> String { 25 | "No Orphaned Marks".to_string() 26 | } 27 | 28 | fn should_skip(&self, _checker: &Checker) -> Option { 29 | None 30 | } 31 | 32 | fn execute(&self, checker: &Checker) -> (Vec, usize) { 33 | let tests_run = self.test_strings.len(); 34 | let dotted_circle = checker.cmap.get(&0x25CC).cloned(); 35 | let mut problems = vec![]; 36 | 37 | for string in self.test_strings.iter() { 38 | let mut previous = None; 39 | let literally_a_dotted_circle = string.text.chars().any(|c| c == '\u{25CC}'); 40 | let glyph_buffer = string 41 | .shape(checker) 42 | .expect("Failed to shape string for NoOrphanedMarks"); 43 | for (codepoint, position) in glyph_buffer 44 | .glyph_infos() 45 | .iter() 46 | .zip(glyph_buffer.glyph_positions().iter()) 47 | { 48 | // We got a notdef. The orthographies check would tell us about missing 49 | // glyphs, so if we are running one (we have exemplars) we ignore it; if not, 50 | // we report it. 51 | if codepoint.glyph_id == 0 && !self.has_orthography { 52 | let mut fail = Problem::new( 53 | &self.name(), 54 | "notdef-produced", 55 | format!("Shaper produced a .notdef while {}", string), 56 | ); 57 | if let Some(input_codepoint) = string.char_at(codepoint.cluster as usize) { 58 | fail.fixes = vec![Fix { 59 | fix_type: "add_codepoint".to_string(), 60 | fix_thing: input_codepoint.to_string(), 61 | }]; 62 | } 63 | problems.push(fail); 64 | } 65 | if checker 66 | .codepoint_for(codepoint.glyph_id) 67 | .map(simple_mark_check) 68 | .unwrap_or(false) 69 | { 70 | if previous.is_some() && previous == dotted_circle && !literally_a_dotted_circle 71 | { 72 | let fail = Problem { 73 | check_name: self.name(), 74 | message: format!("Shaper produced a dotted circle when {}", string), 75 | code: "dotted-circle-produced".to_string(), 76 | terminal: false, 77 | context: serde_json::json!({ 78 | "text": previous, 79 | "mark": codepoint.glyph_id, 80 | }), 81 | fixes: vec![Fix { 82 | fix_type: "add_feature".to_string(), 83 | fix_thing: format!("to avoid a dotted circle while {}", string), 84 | }], 85 | }; 86 | problems.push(fail); 87 | } else if position.x_offset == 0 && position.y_offset == 0 { 88 | // Suspicious 89 | let previous_name = previous.map_or_else( 90 | || "the base glyph".to_string(), 91 | |gid| { 92 | checker 93 | .glyph_names 94 | .get(gid as usize) 95 | .cloned() 96 | .unwrap_or_else(|| format!("Glyph #{}", gid)) 97 | }, 98 | ); 99 | let this_name = checker 100 | .glyph_names 101 | .get(codepoint.glyph_id as usize) 102 | .cloned() 103 | .unwrap_or_else(|| format!("Glyph #{}", codepoint.glyph_id)); 104 | let fail = Problem { 105 | check_name: self.name(), 106 | terminal: false, 107 | message: format!( 108 | "Shaper didn't attach {} to {} when {}", 109 | this_name, previous_name, string 110 | ), 111 | code: "orphaned-mark".to_string(), 112 | context: serde_json::json!({ 113 | "text": string, 114 | "mark": this_name, 115 | "base": previous_name, 116 | }), 117 | fixes: vec![Fix { 118 | fix_type: "add_anchor".to_string(), 119 | fix_thing: format!("{}/{}", previous_name, this_name), 120 | }], 121 | }; 122 | problems.push(fail); 123 | } 124 | } 125 | previous = Some(codepoint.glyph_id); 126 | } 127 | } 128 | (problems, tests_run) 129 | } 130 | 131 | fn describe(&self) -> String { 132 | format!( 133 | "Checks that, when {}, no marks are left unattached", 134 | self.test_strings.iter().map(|x| x.describe()).join(" and ") 135 | ) 136 | } 137 | } 138 | 139 | /// Check if a codepoint is a nonspacing mark 140 | fn simple_mark_check(c: u32) -> bool { 141 | char::from_u32(c) 142 | .map(|c| matches!(c.general_category(), GeneralCategory::NonspacingMark)) 143 | .unwrap_or(false) 144 | } 145 | 146 | impl NoOrphanedMarks { 147 | /// Create a new `NoOrphanedMarks` check implementation 148 | pub fn new(test_strings: Vec, has_orthography: bool) -> Self { 149 | Self { 150 | test_strings, 151 | has_orthography, 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /shaperglot-lib/src/checks/shaping_differs.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use super::CheckImplementation; 4 | use crate::{ 5 | checker::Checker, 6 | reporter::{Fix, Problem}, 7 | shaping::ShapingInput, 8 | }; 9 | use itertools::Itertools; 10 | use rustybuzz::SerializeFlags; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | #[derive(Copy, Clone, Debug, Serialize, Deserialize)] 14 | /// Whether the features are optional 15 | pub struct FeaturesOptional(pub bool); 16 | 17 | #[derive(Copy, Clone, Debug, Serialize, Deserialize)] 18 | /// Whether to ignore any notdefs generated while shaping 19 | pub struct IgnoreNotdefs(pub bool); 20 | 21 | #[derive(Serialize, Deserialize, Debug, Clone)] 22 | /// A check implementation which ensures that two shaping inputs produce different outputs 23 | pub struct ShapingDiffers { 24 | /// The pairs of strings to shape and compare 25 | pairs: Vec<(ShapingInput, ShapingInput)>, 26 | /// Whether the features are optional 27 | /// 28 | /// If this is true, the check will only run if the font contains the requested feature; 29 | /// otherwise it will be skiped. If it is false, the check will always run. 30 | features_optional: FeaturesOptional, 31 | /// Whether to ignore any notdefs generated while shaping 32 | ignore_notdefs: IgnoreNotdefs, 33 | } 34 | 35 | impl CheckImplementation for ShapingDiffers { 36 | fn name(&self) -> String { 37 | "Shaping Differs".to_string() 38 | } 39 | 40 | fn should_skip(&self, checker: &Checker) -> Option { 41 | if !self.features_optional.0 { 42 | return None; 43 | } 44 | let needed_features: HashSet = self 45 | .pairs 46 | .iter() 47 | .flat_map(|(a, b)| a.features.iter().chain(b.features.iter())) 48 | .cloned() 49 | .collect(); 50 | let missing_features: Vec = needed_features 51 | .difference(&checker.features) 52 | .cloned() 53 | .collect(); 54 | if missing_features.is_empty() { 55 | return None; 56 | } 57 | Some(format!( 58 | "The following features are needed for this check, but are missing: {}", 59 | missing_features.join(", ") 60 | )) 61 | } 62 | 63 | fn execute(&self, checker: &Checker) -> (Vec, usize) { 64 | let mut problems = vec![]; 65 | for (before, after) in self.pairs.iter() { 66 | let glyph_buffer_before = before 67 | .shape(checker) 68 | .expect("Failed to shape before string for ShapingDiffers"); 69 | let glyph_buffer_after = after 70 | .shape(checker) 71 | .expect("Failed to shape after string for ShapingDiffers"); 72 | let serialized_before = 73 | glyph_buffer_before.serialize(&checker.face, SerializeFlags::default()); 74 | let serialized_after = 75 | glyph_buffer_after.serialize(&checker.face, SerializeFlags::default()); 76 | if serialized_before != serialized_after { 77 | continue; 78 | } 79 | if self.ignore_notdefs.0 80 | && glyph_buffer_before 81 | .glyph_infos() 82 | .iter() 83 | .any(|glyph| glyph.glyph_id == 0) 84 | { 85 | continue; 86 | } 87 | let mut fail = Problem::new( 88 | &self.name(), 89 | "shaping-same", 90 | format!( 91 | "When {} and {}, the output is expected to be different, but was the same", 92 | before.describe(), 93 | after.describe() 94 | ), 95 | ); 96 | fail.fixes.push(Fix { 97 | fix_type: "add_feature".to_string(), 98 | fix_thing: format!( 99 | "A rule such that {} and {} give different results", 100 | before.describe(), 101 | after.describe() 102 | ), 103 | }); 104 | problems.push(fail); 105 | } 106 | (problems, self.pairs.len()) 107 | } 108 | 109 | fn describe(&self) -> String { 110 | format!( 111 | "in the following situations, different results are produced: {}", 112 | self.pairs 113 | .iter() 114 | .map(|(a, b)| format!("{} versus {}", a.describe(), b.describe())) 115 | .join(", ") 116 | ) 117 | } 118 | } 119 | 120 | impl ShapingDiffers { 121 | /// Create a new `ShapingDiffers` check implementation 122 | pub fn new( 123 | pairs: Vec<(ShapingInput, ShapingInput)>, 124 | features_optional: FeaturesOptional, 125 | ignore_notdefs: IgnoreNotdefs, 126 | ) -> Self { 127 | Self { 128 | pairs, 129 | features_optional, 130 | ignore_notdefs, 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /shaperglot-lib/src/font.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use fontations::skrifa::{ 4 | raw::{ 5 | tables::post::{PString, DEFAULT_GLYPH_NAMES}, 6 | types::Version16Dot16, 7 | ReadError, TableProvider, 8 | }, 9 | FontRef, 10 | }; 11 | 12 | /// Get a list of glyph names for a font 13 | pub(crate) fn glyph_names(font: &FontRef) -> Result, ReadError> { 14 | #[allow(clippy::unwrap_used)] // Heck, Skrifa does the same 15 | let glyph_count = font.maxp().unwrap().num_glyphs().into(); 16 | let mut names = Vec::with_capacity(glyph_count); 17 | if let Ok(post) = font.post() { 18 | match post.version() { 19 | Version16Dot16::VERSION_1_0 => { 20 | names.extend(DEFAULT_GLYPH_NAMES.into_iter().map(|x| x.to_string())); 21 | } 22 | Version16Dot16::VERSION_2_0 => { 23 | if let Some(data) = post.string_data() { 24 | let strings: Vec> = data.iter().map(|x| x.ok()).collect(); 25 | if let Some(index) = post.glyph_name_index() { 26 | names.extend( 27 | (0..glyph_count) 28 | .map(|gid| { 29 | ( 30 | gid, 31 | index.get(gid).and_then(|idx| { 32 | let idx = idx.get() as usize; 33 | if idx < 258 { 34 | Some(DEFAULT_GLYPH_NAMES[idx].to_string()) 35 | } else { 36 | let entry = strings.get(idx - 258)?; 37 | entry.map(|x| x.to_string()) 38 | } 39 | }), 40 | ) 41 | }) 42 | .map(|(gid, maybe_name)| { 43 | maybe_name.unwrap_or_else(|| format!("gid{}", gid)) 44 | }), 45 | ); 46 | } 47 | } 48 | } 49 | _ => {} 50 | } 51 | } 52 | if names.len() < glyph_count { 53 | names.extend((names.len()..glyph_count).map(|gid| format!("gid{}", gid))); 54 | } 55 | Ok(names) 56 | } 57 | 58 | /// Get a list of feature tags present in a font 59 | pub(crate) fn feature_tags(font: &FontRef) -> Result, ReadError> { 60 | let mut tags = HashSet::new(); 61 | if let Some(gsub_featurelist) = font.gsub().ok().and_then(|gsub| gsub.feature_list().ok()) { 62 | gsub_featurelist 63 | .feature_records() 64 | .iter() 65 | .map(|feature| feature.feature_tag().to_string()) 66 | .for_each(|tag| { 67 | tags.insert(tag); 68 | }); 69 | } 70 | if let Some(gpos_featurelist) = font.gpos().ok().and_then(|gpos| gpos.feature_list().ok()) { 71 | gpos_featurelist 72 | .feature_records() 73 | .iter() 74 | .map(|feature| feature.feature_tag().to_string()) 75 | .for_each(|tag| { 76 | tags.insert(tag); 77 | }); 78 | } 79 | Ok(tags) 80 | } 81 | -------------------------------------------------------------------------------- /shaperglot-lib/src/language.rs: -------------------------------------------------------------------------------- 1 | use google_fonts_languages::{LanguageProto, LANGUAGES}; 2 | use unicode_normalization::UnicodeNormalization; 3 | 4 | use crate::{ 5 | checks::Check, 6 | providers::{BaseCheckProvider, Provider}, 7 | }; 8 | 9 | /// A language definition, including checks and exemplar characters 10 | #[derive(Clone, Debug)] 11 | pub struct Language { 12 | /// The underlying language definition from the google-fonts-languages database 13 | pub proto: Box, 14 | /// The checks that apply to this language 15 | pub checks: Vec, 16 | /// Mandatory base characters for the language 17 | pub bases: Vec, 18 | /// Optional auxiliary characters for the language 19 | pub auxiliaries: Vec, 20 | /// Mandatory mark characters for the language 21 | pub marks: Vec, 22 | } 23 | 24 | impl Language { 25 | /// The language's ISO 639-3 code 26 | pub fn id(&self) -> &str { 27 | self.proto.id() 28 | } 29 | 30 | /// The language's name 31 | pub fn name(&self) -> &str { 32 | self.proto.name() 33 | } 34 | 35 | /// The language's ISO15924 script code 36 | pub fn script(&self) -> &str { 37 | self.proto.script() 38 | } 39 | } 40 | 41 | /// The language database 42 | pub struct Languages(Vec); 43 | 44 | impl Languages { 45 | /// Instantiate a new language database 46 | /// 47 | /// This loads the database and fills it with checks. 48 | pub fn new() -> Self { 49 | let mut languages = Vec::new(); 50 | for (_id, proto) in LANGUAGES.iter() { 51 | let bases = proto 52 | .exemplar_chars 53 | .as_ref() 54 | .map(|e| parse_chars(e.base())) 55 | .unwrap_or_else(Vec::new); 56 | let auxiliaries = proto 57 | .exemplar_chars 58 | .as_ref() 59 | .map(|e| parse_chars(e.auxiliary())) 60 | .unwrap_or_else(Vec::new); 61 | let marks = proto 62 | .exemplar_chars 63 | .as_ref() 64 | .map(|e| e.marks().split_whitespace().collect()) 65 | .unwrap_or_else(Vec::new) 66 | .iter() 67 | .map(|x| { 68 | if x.starts_with('\u{25cc}') { 69 | x.to_string() 70 | } else { 71 | format!("\u{25cc}{}", x) 72 | } 73 | }) 74 | .collect(); 75 | 76 | let mut lang = Language { 77 | proto: Box::new(*proto.clone()), 78 | checks: vec![], 79 | bases, 80 | auxiliaries, 81 | marks, 82 | }; 83 | lang.checks = BaseCheckProvider.checks_for(&lang); 84 | languages.push(lang); 85 | } 86 | Languages(languages) 87 | } 88 | 89 | /// Get an iterator over the languages 90 | pub fn iter(&self) -> std::slice::Iter { 91 | self.0.iter() 92 | } 93 | /// Get a single language by ID or name 94 | pub fn get_language(&self, id: &str) -> Option<&Language> { 95 | self.0 96 | .iter() 97 | .find(|l| l.id() == id) 98 | .or_else(|| self.0.iter().find(|l| l.name() == id)) 99 | } 100 | } 101 | 102 | impl IntoIterator for Languages { 103 | type Item = Language; 104 | type IntoIter = std::vec::IntoIter; 105 | 106 | fn into_iter(self) -> Self::IntoIter { 107 | self.0.into_iter() 108 | } 109 | } 110 | impl Default for Languages { 111 | fn default() -> Self { 112 | Self::new() 113 | } 114 | } 115 | 116 | /// Split up an exemplars string into individual characters 117 | fn parse_chars(chars: &str) -> Vec { 118 | chars 119 | .split_whitespace() 120 | .flat_map(|x| { 121 | let mut s = x.to_string(); 122 | if s.len() > 1 { 123 | s = s.trim_start_matches("{").trim_end_matches("}").to_string() 124 | } 125 | let normalized = s.nfc().collect::(); 126 | if normalized != s { 127 | vec![s, normalized] 128 | } else { 129 | vec![s] 130 | } 131 | }) 132 | .filter(|x| !x.is_empty()) 133 | .collect() 134 | } 135 | -------------------------------------------------------------------------------- /shaperglot-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![deny(clippy::missing_docs_in_private_items)] 3 | //! Shaperglot is a library for checking a font's language support. 4 | //! 5 | //! Unlike other language coverage tools, shaperglot is based on the idea 6 | //! that the font must not simply cover Unicode codepoints to support a 7 | //! language but must also behave in certain ways. Shaperglot does not 8 | //! dictate particular implementations of language support, in terms of 9 | //! what glyphs or rules are present in the font or how glyphs should be named, 10 | //! but tests a font for its behaviour. 11 | //! 12 | //! # Example 13 | //! 14 | //! ``` 15 | //! use shaperglot::{Checker, Languages, Provider}; 16 | //! 17 | //! fn test_font(font_binary: &[u8]) { 18 | //! let font = Checker::new(font_binary).expect("Failed to load font"); 19 | //! let languages = Languages::new(); 20 | //! for language in languages.iter() { 21 | //! let results = font.check(language); 22 | //! println!("{}", results.to_summary_string(language)); 23 | //! } 24 | //! } 25 | //! ``` 26 | 27 | /// The checker object, representing the context of a check 28 | mod checker; 29 | /// Low-level checks and their implementations 30 | pub mod checks; 31 | /// Utility functions to extract information from a font 32 | #[cfg(feature = "fontations")] 33 | mod font; 34 | /// Structures and routines relating to the language database 35 | mod language; 36 | /// Providers turn a language definition into a set of checks 37 | mod providers; 38 | /// The reporter object, representing the results of a language test 39 | mod reporter; 40 | /// Utility functions for text shaping 41 | mod shaping; 42 | 43 | pub use crate::{ 44 | checker::Checker, 45 | checks::Check, 46 | language::{Language, Languages}, 47 | providers::Provider, 48 | reporter::{CheckResult, Problem, Reporter, ResultCode, SupportLevel}, 49 | }; 50 | 51 | /// A glyph ID. 52 | pub type GlyphId = u32; 53 | -------------------------------------------------------------------------------- /shaperglot-lib/src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{checks::Check, language::Language}; 2 | 3 | /// Orthographic checks provider 4 | mod orthographies; 5 | /// Arabic positional forms checks provider 6 | mod positional; 7 | /// Latin small caps checks provider 8 | mod small_caps; 9 | /// Manually-coded checks provider 10 | mod toml; 11 | 12 | use orthographies::OrthographiesProvider; 13 | use positional::PositionalProvider; 14 | use small_caps::SmallCapsProvider; 15 | use toml::TomlProvider; 16 | 17 | /// A provider of checks for a language 18 | pub trait Provider { 19 | /// Given a language, return a list of checks that apply to it 20 | fn checks_for(&self, language: &Language) -> Vec; 21 | } 22 | 23 | /// The base check provider provides all checks for a language 24 | /// 25 | /// It calls all other known providers to get their checks. 26 | pub struct BaseCheckProvider; 27 | 28 | impl Provider for BaseCheckProvider { 29 | fn checks_for(&self, language: &Language) -> Vec { 30 | let mut checks: Vec = vec![]; 31 | checks.extend(OrthographiesProvider.checks_for(language)); 32 | checks.extend(SmallCapsProvider.checks_for(language)); 33 | checks.extend(PositionalProvider.checks_for(language)); 34 | checks.extend(TomlProvider.checks_for(language)); 35 | checks 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /shaperglot-lib/src/providers/orthographies.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | checks::{Check, CheckType, CodepointCoverage, NoOrphanedMarks, ScoringStrategy}, 3 | language::Language, 4 | shaping::ShapingInput, 5 | Provider, ResultCode, 6 | }; 7 | use itertools::Itertools; 8 | use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory}; 9 | 10 | /// Check if a base character (in NFC) contains a mark 11 | fn has_complex_decomposed_base(base: &str) -> bool { 12 | base.chars() 13 | .any(|c| c.general_category_group() == GeneralCategoryGroup::Mark) 14 | } 15 | 16 | /// Check that the font covers the basic codepoints for the language's orthography 17 | /// 18 | /// This check is mandatory for all languages. Base and mark codepoints are required, 19 | /// and auxiliary codepoints are optional. 20 | pub struct OrthographiesProvider; 21 | 22 | impl Provider for OrthographiesProvider { 23 | fn checks_for(&self, language: &Language) -> Vec { 24 | let mut checks: Vec = vec![]; 25 | 26 | if !language.bases.is_empty() { 27 | checks.push(mandatory_orthography(language)); 28 | } 29 | 30 | if let Some(check) = auxiliaries_check(language) { 31 | checks.push(check); 32 | } 33 | checks 34 | } 35 | } 36 | 37 | /// Orthography check. We MUST have all bases and marks. 38 | fn mandatory_orthography(language: &Language) -> Check { 39 | let mut mandatory_orthography = Check { 40 | name: "Mandatory orthography codepoints".to_string(), 41 | description: format!( 42 | "The font MUST support the following {} bases{}: {}", 43 | language.name(), 44 | if !language.marks.is_empty() { 45 | " and marks" 46 | } else { 47 | "" 48 | }, 49 | language 50 | .bases 51 | .iter() 52 | .map(|x| format!("'{}'", x)) 53 | .chain(language.marks.iter().map(|x| format!("'{}'", x))) 54 | .join(", ") 55 | ), 56 | severity: ResultCode::Fail, 57 | weight: 80, 58 | scoring_strategy: ScoringStrategy::AllOrNothing, 59 | implementations: vec![CheckType::CodepointCoverage(CodepointCoverage::new( 60 | language.bases.clone(), 61 | "base".to_string(), 62 | true, 63 | ))], 64 | }; 65 | let marks: Vec = language.marks.iter().map(|s| s.replace("◌", "")).collect(); 66 | if !marks.is_empty() { 67 | mandatory_orthography 68 | .implementations 69 | .push(CheckType::CodepointCoverage(CodepointCoverage::new( 70 | marks, 71 | "mark".to_string(), 72 | false, 73 | ))); 74 | } 75 | let complex_bases: Vec = language 76 | .bases 77 | .iter() 78 | .filter(|s| has_complex_decomposed_base(s)) 79 | .map(|x| ShapingInput::new_simple(x.to_string())) 80 | .collect(); 81 | if !complex_bases.is_empty() { 82 | // If base exemplars contain marks, they MUST NOT be orphaned. 83 | mandatory_orthography 84 | .implementations 85 | .push(CheckType::NoOrphanedMarks(NoOrphanedMarks::new( 86 | complex_bases, 87 | true, 88 | ))); 89 | } 90 | mandatory_orthography 91 | } 92 | 93 | /// We SHOULD have auxiliaries 94 | fn auxiliaries_check(language: &Language) -> Option { 95 | if language.auxiliaries.is_empty() { 96 | return None; 97 | } 98 | let complex_auxs: Vec = language 99 | .auxiliaries 100 | .iter() 101 | .filter(|s| has_complex_decomposed_base(s)) 102 | .map(|s| { 103 | if s.chars().count() == 1 { 104 | format!("◌{}", s) 105 | } else { 106 | s.to_string() 107 | } 108 | }) 109 | .collect(); 110 | 111 | let mut auxiliaries_check = Check { 112 | name: "Auxiliary orthography codepoints".to_string(), 113 | description: format!( 114 | "The font SHOULD support the following auxiliary orthography codepoints: {}", 115 | language 116 | .auxiliaries 117 | .iter() 118 | .map(|x| format!("'{}'", x)) 119 | .join(", ") 120 | ), 121 | weight: 20, 122 | scoring_strategy: ScoringStrategy::Continuous, 123 | implementations: vec![], 124 | severity: ResultCode::Warn, 125 | }; 126 | // Since this is a continuous score, we add a check for each codepoint: 127 | for codepoint in &language.auxiliaries { 128 | auxiliaries_check 129 | .implementations 130 | .push(CheckType::CodepointCoverage(CodepointCoverage::new( 131 | vec![codepoint.clone()], 132 | "auxiliary".to_string(), 133 | false, 134 | ))); 135 | } 136 | // If auxiliary exemplars contain marks, they SHOULD NOT be orphaned. 137 | auxiliaries_check 138 | .implementations 139 | .push(CheckType::NoOrphanedMarks(NoOrphanedMarks::new( 140 | complex_auxs 141 | .iter() 142 | .map(|x| ShapingInput::new_simple(x.to_string())) 143 | .collect(), 144 | true, 145 | ))); 146 | Some(auxiliaries_check) 147 | } 148 | -------------------------------------------------------------------------------- /shaperglot-lib/src/providers/positional.rs: -------------------------------------------------------------------------------- 1 | use unicode_joining_type::{get_joining_type, JoiningType}; 2 | use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory}; 3 | 4 | use crate::{ 5 | checks::{ 6 | shaping_differs::{FeaturesOptional, IgnoreNotdefs}, 7 | Check, CheckType, ScoringStrategy, ShapingDiffers, 8 | }, 9 | language::Language, 10 | shaping::ShapingInput, 11 | Provider, ResultCode, 12 | }; 13 | 14 | /// Zero Width Joiner 15 | const ZWJ: &str = "\u{200D}"; 16 | 17 | // const MARKS_FOR_LANG: [(&str, &str); 1] = [( 18 | // "ar_Arab", 19 | // "\u{064E}\u{0651} \u{064B}\u{0651} \u{0650}\u{0651} \u{064D}\u{0651} \u{064F}\u{0651} \u{064C}\u{0651}", 20 | // )]; 21 | 22 | /// A provider that checks for positional forms in Arabic 23 | /// 24 | /// A font which supports Arabic should not only cover the Arabic codepoints, 25 | /// but contain OpenType shaping rules for the `init`, `medi`, and `fina` features. 26 | /// This provider checks that Arabic letters form positional forms when the `init`, `medi`, and `fina` features are enabled. 27 | pub struct PositionalProvider; 28 | 29 | impl Provider for PositionalProvider { 30 | fn checks_for(&self, language: &Language) -> Vec { 31 | if language.script() != "Arab" { 32 | return vec![]; 33 | } 34 | // let marks = language 35 | // .marks 36 | // .iter() 37 | // .map(|s| s.replace("\u{25CC}", "")) 38 | // .filter(|s| { 39 | // s.chars() 40 | // .all(|c| c.general_category() == GeneralCategory::NonspacingMark) 41 | // }); 42 | let letters = language.bases.iter().filter(|s| { 43 | s.chars().count() == 1 44 | && s.chars() 45 | .all(|c| c.general_category_group() == GeneralCategoryGroup::Letter) 46 | }); 47 | let mut fina_pairs = vec![]; 48 | let mut medi_pairs = vec![]; 49 | let mut init_pairs = vec![]; 50 | for base in letters { 51 | match get_joining_type(base.chars().next().unwrap()) { 52 | JoiningType::DualJoining => { 53 | init_pairs.push(positional_check("", base, ZWJ, "init")); 54 | medi_pairs.push(positional_check(ZWJ, base, ZWJ, "medi")); 55 | fina_pairs.push(positional_check(ZWJ, base, "", "fina")); 56 | } 57 | JoiningType::RightJoining => { 58 | fina_pairs.push(positional_check(ZWJ, base, "", "fina")); 59 | } 60 | _ => {} 61 | } 62 | } 63 | let implementations = vec![ 64 | CheckType::ShapingDiffers(ShapingDiffers::new( 65 | init_pairs, 66 | FeaturesOptional(false), 67 | IgnoreNotdefs(true), 68 | )), 69 | CheckType::ShapingDiffers(ShapingDiffers::new( 70 | medi_pairs, 71 | FeaturesOptional(false), 72 | IgnoreNotdefs(true), 73 | )), 74 | CheckType::ShapingDiffers(ShapingDiffers::new( 75 | fina_pairs, 76 | FeaturesOptional(false), 77 | IgnoreNotdefs(true), 78 | )), 79 | ]; 80 | vec![Check { 81 | name: "Positional forms for Arabic letters".to_string(), 82 | severity: ResultCode::Fail, 83 | description: "Arabic letters MUST form positional forms when the init, medi, and fina features are enabled" 84 | .to_string(), 85 | scoring_strategy: ScoringStrategy::Continuous, 86 | weight: 20, 87 | implementations, 88 | }] 89 | } 90 | } 91 | 92 | /// Create a pair of ShapingInputs for a positional form check 93 | fn positional_check( 94 | pre: &str, 95 | character: &str, 96 | post: &str, 97 | feature: &str, 98 | ) -> (ShapingInput, ShapingInput) { 99 | let input = pre.to_string() + character + post; 100 | let before = ShapingInput::new_with_feature(input.clone(), "-".to_string() + feature); 101 | let after = ShapingInput::new_simple(input); 102 | (before, after) 103 | } 104 | -------------------------------------------------------------------------------- /shaperglot-lib/src/providers/small_caps.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | checks::{ 3 | shaping_differs::{FeaturesOptional, IgnoreNotdefs}, 4 | Check, CheckType, ScoringStrategy, ShapingDiffers, 5 | }, 6 | language::Language, 7 | shaping::ShapingInput, 8 | Provider, ResultCode, 9 | }; 10 | use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; 11 | 12 | /// A provider that checks for small caps support in Latin-based languages 13 | /// 14 | /// This provider checks that Latin letters form small caps when the `smcp` feature is enabled. 15 | /// If the `smcp` feature is not present in the font, the check will be skipped. 16 | pub struct SmallCapsProvider; 17 | 18 | impl Provider for SmallCapsProvider { 19 | fn checks_for(&self, language: &Language) -> Vec { 20 | if language.script() != "Latn" { 21 | return vec![]; 22 | } 23 | 24 | let smcp_able = language 25 | .bases 26 | .iter() 27 | .chain(language.auxiliaries.iter()) 28 | .filter(|s| { 29 | s.chars().count() == 1 30 | && s.chars() 31 | .all(|c| c.general_category() == GeneralCategory::LowercaseLetter) 32 | }); 33 | let implementations = vec![CheckType::ShapingDiffers(ShapingDiffers::new( 34 | smcp_able 35 | .map(|s| { 36 | ( 37 | ShapingInput::new_simple(s.to_string()), 38 | ShapingInput::new_with_feature(s.to_string(), "smcp"), 39 | ) 40 | }) 41 | .collect(), 42 | FeaturesOptional(true), 43 | IgnoreNotdefs(true), 44 | ))]; 45 | vec![Check { 46 | name: "Small caps for Latin letters".to_string(), 47 | severity: ResultCode::Warn, 48 | description: "Latin letters should form small caps when the smcp feature is enabled" 49 | .to_string(), 50 | scoring_strategy: ScoringStrategy::Continuous, 51 | weight: 10, 52 | implementations, 53 | }] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /shaperglot-lib/src/providers/toml.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{checks::Check, language::Language, Provider}; 4 | 5 | /// The manual checks profile, a TOML file 6 | const TOML_PROFILE: &str = include_str!("../../manual_checks.toml"); 7 | 8 | use std::sync::LazyLock; 9 | 10 | /// The manual checks, loaded from the TOML profile 11 | static MANUAL_CHECKS: LazyLock>> = 12 | LazyLock::new(|| toml::from_str(TOML_PROFILE).expect("Could not parse manual checks file: ")); 13 | 14 | /// Provide additional language-specific checks via a static TOML file 15 | pub struct TomlProvider; 16 | 17 | impl Provider for TomlProvider { 18 | fn checks_for(&self, language: &Language) -> Vec { 19 | MANUAL_CHECKS 20 | .get(language.id()) 21 | .cloned() 22 | .unwrap_or_default() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shaperglot-lib/src/reporter.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "colored")] 2 | use colored::Colorize; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{ 5 | collections::{HashMap, HashSet}, 6 | fmt::Display, 7 | hash::Hash, 8 | }; 9 | 10 | use serde_json::Value; 11 | 12 | use crate::language::Language; 13 | 14 | /// A code representing the overall status of an individual check 15 | #[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)] 16 | pub enum ResultCode { 17 | /// The check passed successfully 18 | #[default] 19 | Pass, 20 | /// There was a problem which does not prevent the font from being used 21 | Warn, 22 | /// There was a problem which does prevent the font from being used 23 | Fail, 24 | /// The check was skipped because some condition was not met 25 | Skip, 26 | /// The font doesn't support something fundamental, no need to test further 27 | StopNow, 28 | } 29 | 30 | impl Display for ResultCode { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | #[cfg(feature = "colored")] 33 | let to_string = match self { 34 | ResultCode::Pass => "PASS".green(), 35 | ResultCode::Warn => "WARN".yellow(), 36 | ResultCode::Fail => "FAIL".red(), 37 | ResultCode::Skip => "SKIP".blue(), 38 | ResultCode::StopNow => "STOP".red(), 39 | }; 40 | #[cfg(not(feature = "colored"))] 41 | let to_string = match self { 42 | ResultCode::Pass => "PASS", 43 | ResultCode::Warn => "WARN", 44 | ResultCode::Fail => "FAIL", 45 | ResultCode::Skip => "SKIP", 46 | ResultCode::StopNow => "STOP", 47 | }; 48 | write!(f, "{}", to_string) 49 | } 50 | } 51 | 52 | #[derive(Debug, Default, Serialize, Deserialize, Clone)] 53 | /// Suggestions for how to fix the problem 54 | pub struct Fix { 55 | /// The broad category of fix 56 | pub fix_type: String, 57 | /// What the designer needs to do 58 | pub fix_thing: String, 59 | } 60 | 61 | #[derive(Debug, Default, Serialize, Deserialize, Clone)] 62 | /// A problem found during a sub-test of a check 63 | pub struct Problem { 64 | /// The name of the check that found the problem 65 | pub check_name: String, 66 | /// The message describing the problem 67 | pub message: String, 68 | /// A unique code for the problem 69 | pub code: String, 70 | /// Whether the problem is terminal (i.e. the font is unusable) 71 | pub terminal: bool, 72 | /// Additional context for the problem 73 | #[serde(skip_serializing_if = "Value::is_null")] 74 | pub context: Value, 75 | /// Suggestions for how to fix the problem 76 | #[serde(skip_serializing_if = "Vec::is_empty")] 77 | pub fixes: Vec, 78 | } 79 | 80 | impl Problem { 81 | /// Create a new problem 82 | pub fn new(check_name: &str, code: &str, message: String) -> Self { 83 | Self { 84 | check_name: check_name.to_string(), 85 | code: code.to_string(), 86 | message: message.to_string(), 87 | ..Default::default() 88 | } 89 | } 90 | } 91 | 92 | impl Hash for Problem { 93 | fn hash(&self, state: &mut H) { 94 | self.check_name.hash(state); 95 | self.message.hash(state); 96 | } 97 | } 98 | 99 | impl PartialEq for Problem { 100 | fn eq(&self, other: &Self) -> bool { 101 | self.check_name == other.check_name && self.message == other.message 102 | } 103 | } 104 | 105 | impl Display for Problem { 106 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 107 | write!(f, "{}", self.message) 108 | } 109 | } 110 | impl Eq for Problem {} 111 | 112 | #[derive(Debug, Default, Serialize, Deserialize, Clone)] 113 | /// The result of an individual check 114 | pub struct CheckResult { 115 | /// The name of the check 116 | pub check_name: String, 117 | /// A description of what the check does and why 118 | pub check_description: String, 119 | /// The score for the check from 0.0 to 1.0 120 | pub score: f32, 121 | /// The weight of the check in the overall score for language support 122 | pub weight: u8, 123 | /// The problems found during the check 124 | pub problems: Vec, 125 | /// The total number of sub-tests run 126 | pub total_checks: usize, 127 | /// The overall status of the check 128 | pub status: ResultCode, 129 | } 130 | 131 | impl Display for CheckResult { 132 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 133 | write!(f, "{}:", self.check_name)?; 134 | for message in &self.problems { 135 | write!(f, "\n {}", message)?; 136 | } 137 | Ok(()) 138 | } 139 | } 140 | impl CheckResult { 141 | /// Describe the result in a sentence 142 | pub fn summary_result(&self) -> String { 143 | if self.problems.is_empty() { 144 | return format!("{}: no problems found", self.check_name); 145 | } 146 | format!("{} check failed", self.check_name) 147 | } 148 | } 149 | 150 | #[derive(Debug, Default, Serialize)] 151 | /// A collection of check results 152 | pub struct Reporter(Vec); 153 | 154 | impl Reporter { 155 | /// Create a new, empty reporter 156 | pub fn new() -> Self { 157 | Self(vec![]) 158 | } 159 | 160 | /// Add a check result to the reporter 161 | pub fn add(&mut self, checkresult: CheckResult) { 162 | self.0.push(checkresult); 163 | } 164 | 165 | /// Iterate over check results 166 | pub fn iter(&self) -> impl Iterator { 167 | self.0.iter() 168 | } 169 | 170 | /// Iterate over individual problems found while checking 171 | pub fn iter_problems(&self) -> impl Iterator { 172 | self.0.iter().flat_map(|r| r.problems.iter()) 173 | } 174 | 175 | /// A unique set of fixes required, organised by category 176 | /// 177 | /// Some checks may have multiple problems with the same fix, 178 | /// so this method gathers the problems by category and fix required. 179 | pub fn unique_fixes(&self) -> HashMap> { 180 | // Arrange by fix type 181 | let mut fixes: HashMap> = HashMap::new(); 182 | for result in self.0.iter() { 183 | for message in &result.problems { 184 | for fix in &message.fixes { 185 | let entry = fixes.entry(fix.fix_type.clone()).or_default(); 186 | entry.insert(fix.fix_thing.clone()); 187 | } 188 | } 189 | } 190 | fixes 191 | } 192 | 193 | /// Language support as a numerical score 194 | /// 195 | /// This is a weighted sum of all scores of the checks run, out of 100% 196 | pub fn score(&self) -> f32 { 197 | let total_weight: u8 = self.0.iter().map(|r| r.weight).sum(); 198 | let weighted_scores = self.0.iter().map(|r| r.score * f32::from(r.weight)); 199 | let total_score: f32 = weighted_scores.sum(); 200 | total_score / f32::from(total_weight) * 100.0 201 | } 202 | 203 | /// The overall level of support for a language 204 | pub fn support_level(&self) -> SupportLevel { 205 | if self.0.iter().any(|r| r.status == ResultCode::StopNow) { 206 | return SupportLevel::None; 207 | } 208 | if self.is_unknown() { 209 | return SupportLevel::Indeterminate; 210 | } 211 | if self.is_success() { 212 | return SupportLevel::Complete; 213 | } 214 | if self.0.iter().any(|r| r.status == ResultCode::Fail) { 215 | return SupportLevel::Unsupported; 216 | } 217 | if self.0.iter().any(|r| r.status == ResultCode::Warn) { 218 | return SupportLevel::Incomplete; 219 | } 220 | SupportLevel::Supported 221 | } 222 | 223 | /// Whether the font supports the language 224 | pub fn is_success(&self) -> bool { 225 | !self.is_unknown() && self.0.iter().all(|r| r.problems.is_empty()) 226 | } 227 | 228 | /// Whether the support level is unknown 229 | /// 230 | /// This normally occurs when the language definition is not complete 231 | /// enough to run any checks. 232 | pub fn is_unknown(&self) -> bool { 233 | self.0.iter().map(|r| r.total_checks).sum::() == 0 234 | } 235 | 236 | /// The total number of unique fixes required to provide language support 237 | pub fn fixes_required(&self) -> usize { 238 | self.unique_fixes().values().map(|v| v.len()).sum::() 239 | } 240 | 241 | /// Whether the font is nearly successful in supporting the language 242 | /// 243 | /// This is a designer-focused measure in that it counts the number of 244 | /// fixes required and compares it to a threshold. The threshold is 245 | /// set by the caller. 246 | pub fn is_nearly_success(&self, nearly: usize) -> bool { 247 | self.fixes_required() <= nearly 248 | } 249 | 250 | /// A summary of the language support in one sentence 251 | pub fn to_summary_string(&self, language: &Language) -> String { 252 | match self.support_level() { 253 | SupportLevel::Complete => { 254 | format!( 255 | "Font has complete support for {} ({}): 100%", 256 | language.id(), 257 | language.name() 258 | ) 259 | } 260 | SupportLevel::Supported => format!( 261 | "Font fully supports {} ({}): {:.0}%", 262 | language.id(), 263 | language.name(), 264 | self.score() 265 | ), 266 | SupportLevel::Incomplete => format!( 267 | "Font partially supports {} ({}): {:.0}% ({} fixes required)", 268 | language.id(), 269 | language.name(), 270 | self.score(), 271 | self.fixes_required() 272 | ), 273 | SupportLevel::Unsupported => format!( 274 | "Font does not support {} ({}): {:.0}% ({} fixes required)", 275 | language.id(), 276 | language.name(), 277 | self.score(), 278 | self.fixes_required() 279 | ), 280 | SupportLevel::None => { 281 | format!( 282 | "Font does not attempt to support {} ({})", 283 | language.id(), 284 | language.name() 285 | ) 286 | } 287 | SupportLevel::Indeterminate => { 288 | format!( 289 | "Cannot determine whether font supports {} ({})", 290 | language.id(), 291 | language.name() 292 | ) 293 | } 294 | } 295 | } 296 | } 297 | 298 | #[derive(Debug, Serialize, PartialEq)] 299 | /// Represents different levels of support for the language 300 | pub enum SupportLevel { 301 | /// The support is complete; i.e. nothing can be improved 302 | Complete, 303 | /// There were no FAILs or WARNS, but some optional SKIPs which suggest possible improvements 304 | Supported, 305 | /// The support is incomplete, but usable; ie. there were WARNs, but no FAILs 306 | Incomplete, 307 | /// The language is not usable; ie. there were FAILs 308 | Unsupported, 309 | /// The font failed basic checks and is not usable at all for this language 310 | None, 311 | /// Language support could not be determined 312 | Indeterminate, 313 | } 314 | -------------------------------------------------------------------------------- /shaperglot-lib/src/shaping.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{Display, Formatter}, 3 | str::FromStr, 4 | }; 5 | 6 | use rustybuzz::GlyphBuffer; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use crate::Checker; 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | /// A struct representing the input to the shaping process. 13 | pub struct ShapingInput { 14 | /// The text to shape. 15 | pub text: String, 16 | /// The OpenType features to apply. 17 | #[serde(skip_serializing_if = "Vec::is_empty")] 18 | pub features: Vec, 19 | /// The language to shape the text in. 20 | #[serde(skip_serializing_if = "Option::is_none")] 21 | pub language: Option, 22 | } 23 | 24 | impl ShapingInput { 25 | /// Create a new `ShapingInput` with the given text, no features and language supplied 26 | pub fn new_simple(text: String) -> Self { 27 | Self { 28 | text, 29 | features: Vec::new(), 30 | language: None, 31 | } 32 | } 33 | 34 | /// Create a new `ShapingInput` with the given text and a single OpenType feature 35 | pub fn new_with_feature(text: String, feature: impl AsRef) -> Self { 36 | Self { 37 | text, 38 | features: vec![feature.as_ref().to_string()], 39 | language: None, 40 | } 41 | } 42 | 43 | /// Shape the text using the given checker context 44 | pub fn shape(&self, checker: &Checker) -> Result { 45 | let mut buffer = rustybuzz::UnicodeBuffer::new(); 46 | buffer.push_str(&self.text); 47 | if let Some(language) = &self.language { 48 | buffer.set_language(rustybuzz::Language::from_str(language)?); 49 | } 50 | let mut features = Vec::new(); 51 | for f in &self.features { 52 | features.push(rustybuzz::Feature::from_str(f)?); 53 | } 54 | let glyph_buffer = rustybuzz::shape(&checker.face, &features, buffer); 55 | Ok(glyph_buffer) 56 | } 57 | 58 | /// Describe the shaping input 59 | pub fn describe(&self) -> String { 60 | let mut description = format!("shaping the text '{}'", self.text); 61 | if let Some(language) = &self.language { 62 | description.push_str(&format!(" in language '{}'", language)); 63 | } 64 | if !self.features.is_empty() { 65 | description.push_str(" with features: "); 66 | description.push_str(&self.features.join(", ")); 67 | } 68 | description 69 | } 70 | 71 | /// Get the character at the given position in the text 72 | pub fn char_at(&self, pos: usize) -> Option { 73 | self.text.chars().nth(pos) 74 | } 75 | } 76 | 77 | impl Display for ShapingInput { 78 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 79 | write!(f, "{}", self.describe()) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /shaperglot-py/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shaperglot-py" 3 | version = "1.0.2" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | name = "shaperglot" 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | shaperglot = { path = "../shaperglot-lib" } 13 | pyo3 = "0.25" 14 | pythonize = "0.25.0" 15 | -------------------------------------------------------------------------------- /shaperglot-py/docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /shaperglot-py/docs/_build/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /shaperglot-py/docs/conf.py: -------------------------------------------------------------------------------- 1 | import shaperglot 2 | 3 | project = 'shaperglot' 4 | copyright = '2025, Simon Cozens' 5 | author = 'Simon Cozens' 6 | release = shaperglot.__version__ 7 | version = release 8 | extensions = [ 9 | 'sphinx.ext.autodoc', 10 | 'sphinx.ext.autosummary', 11 | 'sphinx.ext.intersphinx', 12 | 'sphinx.ext.todo', 13 | 'sphinx.ext.inheritance_diagram', 14 | 'sphinx.ext.autosectionlabel', 15 | 'sphinx.ext.napoleon', 16 | 'myst_parser', 17 | ] 18 | 19 | autosummary_generate = True 20 | autosummary_imported_members = True 21 | 22 | templates_path = ['_templates'] 23 | 24 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 25 | 26 | html_theme = 'alabaster' 27 | html_static_path = ['_static'] 28 | 29 | html_theme_options = { 30 | 'nosidebar': True, 31 | } 32 | -------------------------------------------------------------------------------- /shaperglot-py/docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.md 2 | :parser: myst_parser.sphinx_ 3 | 4 | Library usage 5 | ------------- 6 | 7 | Reading the code of the CLI tool is a good way to understand how to use the library. 8 | However, the most common use case - checking a font for language support - looks like 9 | this:: 10 | 11 | from shaperglot import Checker, Languages 12 | 13 | langs = Languages() # Load a language database 14 | checker = Checker(filename) # Create a checker context for the font 15 | supported = [] 16 | for lang_id, language in langs.values(): 17 | if checker.check(language).score > 80: 18 | supported.append(lang_id) 19 | 20 | Running checks and getting results 21 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 22 | 23 | .. autoclass:: shaperglot.Checker 24 | :members: 25 | :undoc-members: 26 | 27 | .. autoclass:: shaperglot.Reporter 28 | :members: 29 | :undoc-members: 30 | 31 | .. autoclass:: shaperglot.CheckResult 32 | :members: 33 | :undoc-members: 34 | 35 | .. autoclass:: shaperglot.Problem 36 | :members: 37 | :undoc-members: 38 | 39 | Handling languages 40 | ^^^^^^^^^^^^^^^^^^ 41 | 42 | .. autoclass:: shaperglot.Languages 43 | :members: 44 | :undoc-members: 45 | 46 | .. autoclass:: shaperglot.Language 47 | :members: 48 | :undoc-members: 49 | 50 | Low level check information 51 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 52 | 53 | .. autoclass:: shaperglot.Check 54 | :members: 55 | :undoc-members: 56 | 57 | -------------------------------------------------------------------------------- /shaperglot-py/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.8.6,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "shaperglot" 7 | requires-python = ">=3.8" 8 | classifiers = [ 9 | "Programming Language :: Rust", 10 | "Programming Language :: Python :: Implementation :: CPython", 11 | "Programming Language :: Python :: Implementation :: PyPy", 12 | ] 13 | dynamic = ["version"] 14 | [tool.maturin] 15 | features = ["pyo3/extension-module"] 16 | module-name = "shaperglot._shaperglot" 17 | python-source = "python" 18 | 19 | [project.optional-dependencies] 20 | docs = ["sphinx", "sphinxcontrib-napoleon", "sphinx_rtd_theme", "myst_parser"] 21 | 22 | [project.scripts] 23 | shaperglot = "shaperglot.cli:main" 24 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/__init__.py: -------------------------------------------------------------------------------- 1 | from shaperglot._shaperglot import ( 2 | Check, 3 | Checker, 4 | CheckResult, 5 | Language, 6 | Languages, 7 | Problem, 8 | Reporter, 9 | ) 10 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Package entry point.""" 4 | 5 | 6 | from shaperglot.cli import main 7 | 8 | if __name__ == '__main__': # pragma: no cover 9 | main() 10 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from shaperglot.cli.check import check 5 | from shaperglot.cli.describe import describe 6 | from shaperglot.cli.report import report 7 | from shaperglot.cli.whatuses import whatuses 8 | 9 | try: 10 | import glyphsets 11 | except ImportError: 12 | glyphsets = None 13 | 14 | 15 | def main(args=None) -> None: 16 | if args is None: 17 | args = sys.argv[1:] 18 | parser = argparse.ArgumentParser( 19 | description="Check a font file's language coverage" 20 | ) 21 | subparsers = parser.add_subparsers(help='sub-commands') 22 | 23 | parser_describe = subparsers.add_parser('describe', help=describe.__doc__) 24 | parser_describe.add_argument('--verbose', '-v', action='count') 25 | parser_describe.add_argument( 26 | 'lang', metavar='LANG', help='an ISO639-3 language code' 27 | ) 28 | parser_describe.set_defaults(func=describe) 29 | 30 | parser_check = subparsers.add_parser('check', help=check.__doc__) 31 | parser_check.add_argument('--verbose', '-v', action='count') 32 | parser_check.add_argument( 33 | '--nearly', 34 | type=int, 35 | help="Number of fixes left to be considered nearly supported", 36 | default=5, 37 | ) 38 | parser_check.add_argument('font', metavar='FONT', help='the font file') 39 | parser_check.add_argument( 40 | 'lang', 41 | metavar='LANG', 42 | help='one or more ISO639-3 language codes' 43 | + (" or glyphsets" if glyphsets else ""), 44 | nargs="+", 45 | ) 46 | parser_check.set_defaults(func=check) 47 | 48 | parser_report = subparsers.add_parser('report', help=report.__doc__) 49 | parser_report.add_argument('font', metavar='FONT', help='the font file') 50 | parser_report.add_argument('--verbose', '-v', action='count') 51 | parser_report.add_argument( 52 | '--nearly', 53 | type=int, 54 | help="Number of fixes left to be considered nearly supported", 55 | default=5, 56 | ) 57 | parser_report.add_argument('--csv', action='store_true', help="Output as CSV") 58 | parser_report.add_argument( 59 | '--group', action='store_true', help="Group by success/failure" 60 | ) 61 | parser_report.add_argument( 62 | '--filter', type=str, help="Regular expression to filter languages" 63 | ) 64 | if glyphsets: 65 | parser_report.add_argument( 66 | '--glyphset', 67 | help="Glyph set to use for checking", 68 | choices=glyphsets.defined_glyphsets(), 69 | ) 70 | parser_report.set_defaults(func=report) 71 | 72 | parser_whatuses = subparsers.add_parser('whatuses', help=whatuses.__doc__) 73 | parser_whatuses.add_argument('char', metavar='CHAR', help='Character or code point') 74 | parser_whatuses.set_defaults(func=whatuses) 75 | 76 | options = parser.parse_args(args) 77 | if not hasattr(options, "func"): 78 | parser.print_help() 79 | sys.exit(1) 80 | options.func(options) 81 | 82 | 83 | if __name__ == '__main__': # pragma: no cover 84 | main() 85 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/cli/check.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Optional 3 | 4 | from shaperglot import Checker, Languages, Reporter 5 | 6 | try: 7 | import glyphsets 8 | except ImportError: 9 | glyphsets = None 10 | 11 | 12 | def find_lang(lang: str, langs: Languages) -> Optional[str]: 13 | # Find the language in the languages list; could be by ID, by name, etc. 14 | if lang in langs: 15 | return lang 16 | for lang_id in langs.keys(): 17 | lang_info = langs[lang_id] 18 | if ( 19 | lang_info['name'].lower() == lang.lower() 20 | or lang_id.lower() == lang.lower() 21 | or lang_info["language"].lower() == lang.lower() 22 | or lang_info.get("autonym", "").lower() == lang.lower() 23 | ): 24 | return lang_id 25 | return None 26 | 27 | 28 | def check(options) -> None: 29 | """Check a particular language or languages are supported""" 30 | checker = Checker(options.font) 31 | langs = Languages() 32 | fixes_needed = defaultdict(set) 33 | lang_arg = [] 34 | for lang in options.lang: 35 | if glyphsets and lang in glyphsets.defined_glyphsets(): 36 | lang_arg.extend(glyphsets.languages_per_glyphset(lang)) 37 | else: 38 | lang_arg.append(lang) 39 | 40 | for orig_lang in lang_arg: 41 | lang = find_lang(orig_lang, langs) 42 | if not lang: 43 | print(f"Language '{orig_lang}' not known") 44 | continue 45 | 46 | reporter = checker.check(langs[lang]) 47 | 48 | if reporter.is_unknown: 49 | print(f"Cannot determine whether font supports language '{lang}'") 50 | elif reporter.is_nearly_success(options.nearly): 51 | print(f"Font nearly supports language '{lang}' {reporter.score:.1f}%") 52 | for fixtype, things in reporter.unique_fixes().items(): 53 | fixes_needed[fixtype].update(things) 54 | elif reporter.is_success: 55 | print(f"Font supports language '{lang}'") 56 | else: 57 | print( 58 | f"Font does not fully support language '{lang}' {reporter.score:.1f}%" 59 | ) 60 | 61 | if options.verbose and options.verbose > 1: 62 | for result in reporter: 63 | print(f" * {result.message} {result.status_code}") 64 | elif options.verbose or not reporter.is_success: 65 | for result in reporter: 66 | if not result.is_success: 67 | print(f" * {result}") 68 | 69 | if fixes_needed: 70 | show_how_to_fix(fixes_needed) 71 | 72 | 73 | def show_how_to_fix(reporter: Reporter): 74 | print("\nTo add full support to nearly-supported languages:") 75 | for category, fixes in reporter.items(): 76 | plural = "s" if len(fixes) > 1 else "" 77 | print(f" * {category.replace('_', ' ').capitalize()}{plural}: ", end="") 78 | print("; ".join(sorted(fixes))) 79 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/cli/describe.py: -------------------------------------------------------------------------------- 1 | import os 2 | from textwrap import fill 3 | 4 | from shaperglot import Languages 5 | 6 | 7 | def describe(options) -> None: 8 | """Describe the checks shaperglot will perform to determine support for a given language""" 9 | langs = Languages() 10 | if options.lang not in langs: 11 | maybe = langs.disambiguate(options.lang) 12 | if len(maybe) == 1: 13 | lang = langs[maybe[0]] 14 | print(f"Assuming you meant {maybe[0]} ({lang['full_name']}).") 15 | elif len(maybe) > 1: 16 | print(f"Language '{options.lang}' not known", end="") 17 | print("; try one of: " + ", ".join(maybe)) 18 | return 19 | else: 20 | print(f"Language '{options.lang}' not known", end="") 21 | print("") 22 | return 23 | else: 24 | lang = langs[options.lang] 25 | print(f"To test for {lang['name']} support:") 26 | try: 27 | width = os.get_terminal_size()[0] 28 | except OSError: 29 | width = 80 30 | for shaperglot_check in lang.checks: 31 | print( 32 | fill( 33 | shaperglot_check.description, 34 | initial_indent=" * ", 35 | subsequent_indent=" ", 36 | width=width - 2, 37 | ) 38 | ) 39 | if options.verbose: 40 | for implementation in shaperglot_check.implementations: 41 | print( 42 | fill( 43 | "check " + implementation, 44 | initial_indent=" - ", 45 | subsequent_indent=" ", 46 | width=width - 4, 47 | ) 48 | ) 49 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/cli/report.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from collections import defaultdict 4 | from textwrap import fill 5 | 6 | from shaperglot import Checker, Languages 7 | 8 | 9 | try: 10 | import glyphsets 11 | except ImportError: 12 | glyphsets = None 13 | 14 | 15 | def report(options) -> None: 16 | """Report which languages are supported by the given font""" 17 | checker = Checker(options.font) 18 | langs = Languages() 19 | nearly = [] 20 | supported = [] 21 | unsupported = [] 22 | fixes_needed = defaultdict(set) 23 | 24 | lang_filter = None 25 | if glyphsets and options.glyphset: 26 | lang_filter = glyphsets.languages_per_glyphset(options.glyphset) 27 | 28 | if options.csv: 29 | print( 30 | "Language,Name,Supported,Bases Missing,Marks Missing,Orphaned Marks,Other" 31 | ) 32 | 33 | for lang in sorted(langs.keys()): 34 | if options.filter and not re.search(options.filter, lang): 35 | continue 36 | if lang_filter and lang not in lang_filter: 37 | continue 38 | results = checker.check(langs[lang]) 39 | 40 | if results.is_unknown: 41 | continue 42 | 43 | if options.csv: 44 | report_csv(lang, langs[lang], results) 45 | continue 46 | 47 | if results.is_success: 48 | supported.append(lang) 49 | msg = "supports" 50 | elif results.is_nearly_success(options.nearly): 51 | nearly.append(lang) 52 | msg = "nearly supports" 53 | else: 54 | unsupported.append(lang) 55 | msg = "does not fully support" 56 | 57 | for fixtype, things in results.unique_fixes().items(): 58 | fixes_needed[fixtype].update(things) 59 | if options.group: 60 | continue 61 | print( 62 | f"Font {msg} language '{lang}' ({langs[lang]['name']}) ({results.score:.1f}%)" 63 | ) 64 | 65 | if options.verbose and options.verbose > 1: 66 | for subresult in results: 67 | print(f" * {subresult.status_code}: {subresult.message}") 68 | 69 | if options.csv: 70 | return 71 | 72 | if options.group: 73 | show_grouped(langs, nearly, supported, unsupported) 74 | # Collate a useful fixing guide 75 | short_summary(supported, nearly, unsupported) 76 | if options.verbose: 77 | long_summary(fixes_needed, unsupported) 78 | 79 | 80 | def show_grouped(langs, nearly, supported, unsupported): 81 | if supported: 82 | print("Supported languages") 83 | print("===================\n") 84 | for lang in supported: 85 | print(f"Font supports language '{lang}' ({langs[lang]['name']})") 86 | 87 | if nearly: 88 | print("\nNearly supported languages") 89 | print("===================\n") 90 | for lang in nearly: 91 | print(f"Font nearly supports language '{lang}' ({langs[lang]['name']})") 92 | 93 | if unsupported: 94 | print("\nUnsupported languages") 95 | print("====================\n") 96 | for lang in unsupported: 97 | print(f"Font does not fully support language '{lang}' ({langs[lang]['name']})") 98 | 99 | 100 | def short_summary(supported, nearly, unsupported) -> None: 101 | print("\n== Summary ==\n") 102 | print(f"* {len(supported)+len(nearly)+len(unsupported)} languages checked") 103 | if supported: 104 | print(f"* {len(supported)} languages supported") 105 | if nearly: 106 | print(f"* {len(nearly)} languages nearly supported") 107 | 108 | 109 | def long_summary(fixes_needed, unsupported) -> None: 110 | if unsupported: 111 | print( 112 | fill( 113 | "* Unsupported languages: " + ", ".join(unsupported), 114 | subsequent_indent=" " * 25, 115 | width=os.get_terminal_size()[0] - 2, 116 | ) 117 | ) 118 | print("\nTo add support:") 119 | for category, fixes in fixes_needed.items(): 120 | plural = "s" if len(fixes) > 1 else "" 121 | print(f" * {category.replace('_', ' ').capitalize()}{plural}: ") 122 | for fix in sorted(fixes): 123 | print(" - " + fix) 124 | 125 | 126 | def report_csv(langcode, lang, results) -> None: 127 | print(f"{langcode},\"{lang['name']}\",{results.is_success},", end="") 128 | missing_bases = set() 129 | missing_marks = set() 130 | missing_anchors = set() 131 | other_errors = set() 132 | for result in results: 133 | for problem in result.problems: 134 | if problem.code == "bases-missing": 135 | missing_bases |= set(problem.context["glyphs"]) 136 | elif problem.code == "marks-missing": 137 | missing_marks |= set(problem.context["glyphs"]) 138 | elif problem.code == "orphaned-mark": 139 | missing_anchors.add( 140 | problem.context["base"] + "/" + problem.context["mark"] 141 | ) 142 | else: 143 | other_errors.add(problem.code) 144 | print(" ".join(sorted(missing_bases)), end=",") 145 | print(" ".join(sorted(missing_marks)), end=",") 146 | print(" ".join(sorted(missing_anchors)), end=",") 147 | print(" ".join(sorted(other_errors)), end="\n") 148 | -------------------------------------------------------------------------------- /shaperglot-py/python/shaperglot/cli/whatuses.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from textwrap import wrap 3 | 4 | from shaperglot import Languages 5 | 6 | 7 | def whatuses(options) -> None: 8 | """Report which languages use a particular codepoint""" 9 | 10 | char = options.char 11 | if len(char) > 1: 12 | try: 13 | if ( 14 | char.startswith("U+") 15 | or char.startswith("u+") 16 | or char.startswith("0x") 17 | or char.startswith("0X") 18 | ): 19 | char = chr(int(char[2:], 16)) 20 | else: 21 | char = chr(int(char, 16)) 22 | except ValueError: 23 | print("Could not understand codepoint " + char) 24 | sys.exit(1) 25 | langs = Languages() 26 | base_langs = [] 27 | mark_langs = [] 28 | aux_langs = [] 29 | for lang in langs.values(): 30 | bases = lang.bases 31 | marks = lang.marks 32 | aux = lang.auxiliaries 33 | lang_key = f"{lang['name']} [{lang['id']}]".replace(" ", "\u00A0") 34 | if char in bases: 35 | base_langs.append(lang_key) 36 | elif char in marks: 37 | mark_langs.append(lang_key) 38 | elif char in aux: 39 | aux_langs.append(lang_key) 40 | 41 | if base_langs: 42 | print(f"{char} is used as a base character in the following languages:") 43 | for line in wrap(", ".join(sorted(base_langs)), width=75): 44 | print(" " + line) 45 | print() 46 | if mark_langs: 47 | print(f"◌{char} is used as a mark character in the following languages:") 48 | for line in wrap(", ".join(sorted(mark_langs)), width=75): 49 | print(" " + line) 50 | print() 51 | if aux_langs: 52 | print(f"{char} is used as an auxiliary character in the following languages:") 53 | for line in wrap(", ".join(sorted(aux_langs)), width=75): 54 | print(" " + line) 55 | -------------------------------------------------------------------------------- /shaperglot-py/src/check.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use shaperglot::checks::CheckImplementation; 3 | use shaperglot::Check as RustCheck; 4 | 5 | /// A check to be executed 6 | /// 7 | /// This is a high-level check which is looking for a particular piece of behaviour in 8 | /// a font. It may be made up of multiple "implementations" which are the actual code 9 | /// that is run to check for the behaviour. For example, an orthography check will 10 | /// first check bases, then marks, then auxiliary codepoints. The implementations for 11 | /// this check would be "given this list of bases, ensure the font has coverage for 12 | /// all of them", and so on. 13 | #[pyclass(module = "shaperglot")] 14 | pub(crate) struct Check(pub(crate) RustCheck); 15 | 16 | #[pymethods] 17 | impl Check { 18 | /// A human-readable description of the check 19 | /// 20 | /// Returns: 21 | /// A string describing the check 22 | #[getter] 23 | fn description(&self) -> String { 24 | self.0.description.to_string() 25 | } 26 | 27 | /// An array of human-readable descriptions for what the check does. 28 | /// 29 | /// Returns: 30 | /// An array of strings describing the check 31 | #[getter] 32 | fn implementations(&self) -> Vec { 33 | self.0 34 | .implementations 35 | .iter() 36 | .map(|s| s.describe()) 37 | .collect() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shaperglot-py/src/checker.rs: -------------------------------------------------------------------------------- 1 | use crate::{language::Language, reporter::Reporter}; 2 | use pyo3::{exceptions::PyValueError, prelude::*}; 3 | use shaperglot::Checker as RustChecker; 4 | 5 | use std::sync::Arc; 6 | 7 | #[pyclass(module = "shaperglot")] 8 | /// The context for running font language support checks 9 | /// 10 | /// This is the main entry point to shaperglot; it is used to load a font and run checks 11 | /// against it. 12 | pub(crate) struct Checker(Vec); 13 | 14 | impl Checker { 15 | pub(crate) fn _checker(&self) -> Result, PyErr> { 16 | Ok(Arc::new(RustChecker::new(&self.0).map_err(|e| { 17 | PyErr::new::(e.to_string()) 18 | })?)) 19 | } 20 | } 21 | 22 | #[pymethods] 23 | impl Checker { 24 | /// Create a new checker 25 | /// 26 | /// This will load a font from the given filename and prepare it for running checks. 27 | #[new] 28 | pub(crate) fn new(filename: &str) -> Result { 29 | let font_binary = std::fs::read(filename)?; 30 | Ok(Self(font_binary)) 31 | } 32 | 33 | /// Run a check against the font 34 | /// 35 | /// Args: 36 | /// lang: A `Language` object obtained from the `Languages` directory. 37 | /// 38 | /// Returns: 39 | /// A `Reporter` object with the results of checking the font for language coverage. 40 | pub(crate) fn check(&self, lang: &Language) -> PyResult { 41 | Ok(self._checker()?.check(&lang.0).into()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shaperglot-py/src/checkresult.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pythonize::pythonize; 3 | use shaperglot::{CheckResult as RustCheckResult, Problem as RustProblem, ResultCode}; 4 | 5 | /// The result of running a check 6 | /// 7 | /// Remembering that determining language support is made up of _multiple_ checks 8 | /// which are added together, the result of an individual check could tell us, for 9 | /// example, that all base characters are present, or that some are missing; that 10 | /// some auxiliary characters are missing; that shaping expectations were not met for 11 | /// a particular combination, and so on. 12 | /// 13 | /// Looking in CheckResults can give us a lower-level indication of what is needed for 14 | /// support to be added for a particular language; for a higher-level overview ("is 15 | /// this language supported or not?"), look at the `Reporter` object. 16 | #[pyclass(module = "shaperglot")] 17 | pub(crate) struct CheckResult(pub(crate) RustCheckResult); 18 | 19 | #[pymethods] 20 | impl CheckResult { 21 | /// The message of the check result 22 | #[getter] 23 | pub(crate) fn message(&self) -> String { 24 | self.0.to_string() 25 | } 26 | 27 | pub(crate) fn __str__(&self) -> String { 28 | self.0.to_string() 29 | } 30 | 31 | /// Whether the check was successful 32 | #[getter] 33 | pub(crate) fn is_success(&self) -> bool { 34 | self.0.status == ResultCode::Pass 35 | } 36 | 37 | /// The result of the check 38 | /// 39 | /// Returns: 40 | /// str: The result of the check - one of "PASS", "WARN", "FAIL", "SKIP" or "STOP" 41 | #[getter] 42 | pub(crate) fn status_code(&self) -> String { 43 | self.0.status.to_string() 44 | } 45 | 46 | /// The problems found during the check 47 | /// 48 | /// These "problems" are aimed towards font designers, to guide them towards 49 | /// adding support for a particular language. 50 | /// 51 | /// Returns: 52 | /// List[Problem]: A list of problems found during the check 53 | #[getter] 54 | pub(crate) fn problems(&self) -> Vec { 55 | self.0.problems.iter().map(|p| Problem(p.clone())).collect() 56 | } 57 | } 58 | 59 | /// A problem found during a check 60 | #[pyclass(module = "shaperglot")] 61 | pub(crate) struct Problem(pub(crate) RustProblem); 62 | #[pymethods] 63 | impl Problem { 64 | /// The name of the check that found the problem 65 | #[getter] 66 | fn check_name(&self) -> String { 67 | self.0.check_name.to_string() 68 | } 69 | 70 | /// A textual description of the problem 71 | #[getter] 72 | fn message(&self) -> String { 73 | self.0.message.to_string() 74 | } 75 | 76 | /// A status code (e.g. ``bases-missing``) 77 | #[getter] 78 | fn code(&self) -> String { 79 | self.0.code.to_string() 80 | } 81 | 82 | /// Whether the problem is terminal 83 | /// 84 | /// Some problems are so bad that there's no point testing for any more 85 | /// language coverage. (Imagine checking a font for Arabic support which is 86 | /// missing the letter BEH. Once you've determined that, there's not much 87 | /// point checking if it supports correct shaping behaviour.) 88 | #[getter] 89 | fn terminal(&self) -> bool { 90 | self.0.terminal 91 | } 92 | 93 | /// The context of the problem 94 | /// 95 | /// Returns: 96 | /// dict: A dictionary of additional information about the problem 97 | #[getter] 98 | fn context<'py>(&self, py: Python<'py>) -> Result, PyErr> { 99 | pythonize(py, &self.0.context) 100 | .map_err(|e| PyErr::new::(e.to_string())) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /shaperglot-py/src/language.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use shaperglot::{Language as RustLanguage, Languages as RustLanguages}; 3 | 4 | use crate::check::Check; 5 | 6 | #[pyclass(module = "shaperglot", mapping)] 7 | /// A language in the database 8 | /// 9 | /// For backwards compatibility, this can be used as a dictionary in a very limited way; 10 | /// the following keys are supported: 11 | /// 12 | /// - `name`: The name of the language 13 | /// - `language`: The language code 14 | /// - `autonym`: The autonym of the language (name of the language in the language itself) 15 | pub(crate) struct Language(pub(crate) RustLanguage); 16 | 17 | #[pymethods] 18 | impl Language { 19 | fn __getitem__(&self, key: &str) -> Option { 20 | match key { 21 | "name" => Some(self.0.name().to_string()), 22 | "language" => Some(self.0.proto.language().to_string()), 23 | "autonym" => Some(self.0.proto.autonym().to_string()), 24 | _ => None, 25 | } 26 | } 27 | 28 | /// Base characters needed for support 29 | /// 30 | /// Returns: 31 | /// List[str]: A list of base characters needed for support 32 | #[getter] 33 | fn bases(&self) -> Vec { 34 | self.0.bases.iter().map(|s| s.to_string()).collect() 35 | } 36 | 37 | /// Marks needed for support 38 | /// 39 | /// Returns: 40 | /// List[str]: A list of marks needed for support 41 | #[getter] 42 | fn marks(&self) -> Vec { 43 | self.0.marks.iter().map(|s| s.to_string()).collect() 44 | } 45 | 46 | /// Auxiliary characters 47 | /// 48 | /// Auxiliary characters are not required but are recommended for support. 49 | /// The most common case for these is for borrowed words which are occasionally 50 | /// used within the language. 51 | /// For example, the letter é is not a required character to support the English 52 | /// language, but the word "café" is used in English and includes the letter é, 53 | /// so is an auxiliary character. 54 | /// 55 | /// Returns: 56 | /// List[str]: A list of auxiliary characters 57 | #[getter] 58 | fn auxiliaries(&self) -> Vec { 59 | self.0.auxiliaries.iter().map(|s| s.to_string()).collect() 60 | } 61 | 62 | /// Checks for the language 63 | /// 64 | /// Returns: 65 | /// List[Check]: A list of checks for the language 66 | #[getter] 67 | fn checks(&self) -> Vec { 68 | self.0.checks.iter().map(|c| Check(c.clone())).collect() 69 | } 70 | } 71 | /// The language database 72 | /// 73 | /// Instantiating `Languages` object loads the database and fills it with checks. 74 | /// The database can be used like a Python dictionary, with the language ID as the key. 75 | /// Language IDs are made up of an ISO639-3 language code, an underscore, and a ISO 15927 76 | /// script code. (e.g. `en_Latn` for English in the Latin script.) 77 | 78 | #[pyclass(module = "shaperglot", mapping)] 79 | 80 | pub(crate) struct Languages(RustLanguages); 81 | 82 | #[pymethods] 83 | impl Languages { 84 | #[new] 85 | pub(crate) fn new() -> Self { 86 | Self(RustLanguages::new()) 87 | } 88 | 89 | pub(crate) fn __iter__(slf: PyRef<'_, Self>) -> PyResult> { 90 | let iter = LanguageIterator { 91 | // Make a new one, they're all the same 92 | inner: RustLanguages::new().into_iter(), 93 | }; 94 | Py::new(slf.py(), iter) 95 | } 96 | 97 | pub(crate) fn __getitem__(&self, lang: &str) -> Option { 98 | self.0.get_language(lang).cloned().map(Language) 99 | } 100 | 101 | pub(crate) fn __contains__(&self, lang: &str) -> bool { 102 | self.0.get_language(lang).is_some() 103 | } 104 | 105 | /// Get a list of all language IDs in the database 106 | /// 107 | /// Returns: 108 | /// List[str]: A list of all language IDs in the database 109 | pub(crate) fn keys(&self) -> Vec { 110 | self.0.iter().map(|l| l.id().to_string()).collect() 111 | } 112 | 113 | /// Get a list of all languages in the database 114 | /// 115 | /// Returns: 116 | /// List[Language]: A list of all languages in the database 117 | pub(crate) fn values(&self) -> Vec { 118 | self.0.iter().cloned().map(Language).collect() 119 | } 120 | 121 | /// Try to find a matching language ID given an ID or name 122 | /// 123 | /// This will try to find a language ID that matches the given string; it will return 124 | /// a list of candidate language IDs. For example, if you provide "en", it will return 125 | /// "en_Latn" and "en_Cyrl" if those are in the database. Otherwise, it will look for 126 | /// a matching name - if you provide "english", it will return "en_Latn". 127 | /// 128 | /// Args: 129 | /// lang (str): The language ID or name to search for 130 | /// 131 | /// Returns: 132 | /// List[str]: A list of candidate language IDs 133 | pub(crate) fn disambiguate(&self, lang: &str) -> Vec { 134 | let maybe_keys: Vec = self 135 | .0 136 | .iter() 137 | .map(|l| l.id()) 138 | .filter(|k| k.to_lowercase().starts_with(&(lang.to_lowercase() + "_"))) 139 | .map(|k| k.to_string()) 140 | .collect(); 141 | if !maybe_keys.is_empty() { 142 | return maybe_keys; 143 | } 144 | self.0 145 | .iter() 146 | .filter(|l| l.name().to_lowercase().starts_with(&lang.to_lowercase())) 147 | .map(|l| l.id().to_string()) 148 | .collect() 149 | } 150 | } 151 | 152 | #[pyclass(module = "shaperglot")] 153 | pub(crate) struct LanguageIterator { 154 | inner: std::vec::IntoIter, 155 | } 156 | 157 | #[pymethods] 158 | impl LanguageIterator { 159 | pub(crate) fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { 160 | slf 161 | } 162 | 163 | pub(crate) fn __next__(mut slf: PyRefMut<'_, Self>) -> Option { 164 | slf.inner.next().map(Language) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /shaperglot-py/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::unwrap_used, clippy::expect_used)] 2 | use check::Check; 3 | use checkresult::{CheckResult, Problem}; 4 | use language::{Language, Languages}; 5 | use pyo3::prelude::*; 6 | use reporter::Reporter; 7 | 8 | mod check; 9 | mod checker; 10 | mod checkresult; 11 | mod language; 12 | mod reporter; 13 | 14 | use crate::checker::Checker; 15 | #[pymodule(name = "_shaperglot")] 16 | fn shaperglot(m: &Bound<'_, PyModule>) -> PyResult<()> { 17 | m.add_class::()?; 18 | m.add_class::()?; 19 | m.add_class::()?; 20 | m.add_class::()?; 21 | m.add_class::()?; 22 | m.add_class::()?; 23 | m.add_class::() 24 | } 25 | -------------------------------------------------------------------------------- /shaperglot-py/src/reporter.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::unwrap_used, clippy::expect_used)] 2 | use std::collections::{HashMap, HashSet}; 3 | 4 | use pyo3::prelude::*; 5 | use shaperglot::{ 6 | CheckResult as RustCheckResult, Reporter as RustReporter, ResultCode, SupportLevel, 7 | }; 8 | 9 | use crate::{checkresult::CheckResult, language::Language}; 10 | 11 | /// The result of testing a font for support of a particular language 12 | /// 13 | /// The Reporter object can be iterated on to return a list of `CheckResult` objects. 14 | #[pyclass(module = "shaperglot")] 15 | pub(crate) struct Reporter(pub(crate) RustReporter); 16 | 17 | #[pymethods] 18 | impl Reporter { 19 | /// Whether the language supported could not be determined 20 | /// 21 | /// If the languages database does not contain enough information about a 22 | /// language to determine whether or not a font supports it - for example, 23 | /// if there are no base characters defined - then the support level will 24 | /// be "indeterminate", and this method will return ``True``. 25 | #[getter] 26 | fn is_unknown(&self) -> bool { 27 | self.0.is_unknown() 28 | } 29 | 30 | /// Whether the font can be easily fixed to support the language. 31 | /// 32 | /// The audience of this method is the designer of the font, not the user of 33 | /// the font. It returns True if a font requires fewer than ``fixes`` fixes 34 | /// to support the language. 35 | fn is_nearly_success(&self, fixes: usize) -> bool { 36 | self.0.is_nearly_success(fixes) 37 | } 38 | 39 | /// Whether the font fully supports the language 40 | /// 41 | /// This method returns ``True`` if the font fully supports the language. Note 42 | /// that *fully* is a relatively high standard. For practical usage, a `score` 43 | /// of more than 80% is good enough. 44 | #[getter] 45 | fn is_success(&self) -> bool { 46 | self.0.is_success() 47 | } 48 | 49 | /// The set of unique fixes which need to be made to add support. 50 | /// 51 | /// The audience of this method is the designer of the font, not the user of 52 | /// the font. This returns a dictionary of fixes required, where the key is 53 | /// the area of support and the value is the set of fixes required. 54 | fn unique_fixes(&self) -> HashMap> { 55 | self.0.unique_fixes().into_iter().collect() 56 | } 57 | 58 | /// Number of fixes required to add support. 59 | #[getter] 60 | fn fixes_required(&self) -> usize { 61 | self.0.fixes_required() 62 | } 63 | 64 | /// Failing checks 65 | /// 66 | /// This returns `CheckResult` objects for all checks which failed. 67 | #[getter] 68 | fn fails(&self) -> Vec { 69 | self.0 70 | .iter() 71 | .filter(|r| r.status == ResultCode::Fail) 72 | .map(|r| CheckResult(r.clone())) 73 | .collect() 74 | } 75 | 76 | /// Warnings 77 | /// 78 | /// This returns `CheckResult` objects for all checks which returned 79 | /// a warning status. 80 | #[getter] 81 | fn warns(&self) -> Vec { 82 | self.0 83 | .iter() 84 | .filter(|r| r.status == ResultCode::Warn) 85 | .map(|r| CheckResult(r.clone())) 86 | .collect() 87 | } 88 | 89 | /// The score of the font for the language 90 | /// 91 | /// Returns how supported the language is, as a percentage. Shaperglot is 92 | /// calibrated so that a score of 80% is adequate for everyday use. However, 93 | /// language support can often be improved - for example, by supporting 94 | /// optional auxiliary glyphs, adding small caps support, and so on. 95 | #[getter] 96 | fn score(&self) -> f32 { 97 | self.0.score() 98 | } 99 | 100 | /// The support level of the font for the language 101 | /// 102 | /// Returns a string describing the support level; one of: 103 | /// - "none": No support at all; the checker hit a "stop now" condition, 104 | /// usually caused by a missing mandatory base 105 | /// - "complete": Nothing can be done to improve this font's language support. 106 | /// - "supported": There were no FAILs or WARNS, but some optional SKIPs which suggest possible improvements 107 | /// - "incomplete": The support is incomplete, but usable; ie. there were WARNs, but no FAILs 108 | /// - "unsupported": The language is not usable; ie. there were FAILs 109 | /// - "indeterminate": The language support could not be determined, usually due to an incomplete language definition. 110 | #[getter] 111 | fn support_level(&self) -> &str { 112 | match self.0.support_level() { 113 | SupportLevel::None => "none", 114 | SupportLevel::Complete => "complete", 115 | SupportLevel::Supported => "supported", 116 | SupportLevel::Incomplete => "incomplete", 117 | SupportLevel::Unsupported => "unsupported", 118 | SupportLevel::Indeterminate => "indeterminate", 119 | } 120 | } 121 | 122 | /// The summary of the font's support for the language 123 | /// 124 | /// Returns a summary of the font's support for the language, in the form of a string 125 | /// suitable for display to the user. e.g.:: 126 | /// 127 | /// "Font fully supports en_Latn (English): 95%" 128 | fn to_summary_string(&self, language: &Language) -> String { 129 | self.0.to_summary_string(&language.0) 130 | } 131 | 132 | fn __iter__(slf: PyRef<'_, Self>) -> PyResult> { 133 | let iter = CheckResultIterator { 134 | inner: slf.0.iter().cloned().collect::>().into_iter(), 135 | }; 136 | Py::new(slf.py(), iter) 137 | } 138 | } 139 | 140 | #[pyclass(module = "shaperglot")] 141 | pub(crate) struct CheckResultIterator { 142 | inner: std::vec::IntoIter, 143 | } 144 | 145 | #[pymethods] 146 | impl CheckResultIterator { 147 | pub(crate) fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { 148 | slf 149 | } 150 | 151 | pub(crate) fn __next__(mut slf: PyRefMut<'_, Self>) -> Option { 152 | slf.inner.next().map(CheckResult) 153 | } 154 | } 155 | 156 | impl From for Reporter { 157 | fn from(reporter: RustReporter) -> Self { 158 | Self(reporter) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /shaperglot-web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /shaperglot-web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shaperglot-web" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | wasm-bindgen = { version = "0.2.100" } 8 | console_error_panic_hook = { version = "0.1.7" } 9 | js-sys = { version = "0.3.77" } 10 | shaperglot = { path = "../shaperglot-lib" } 11 | fontations = { workspace = true } 12 | serde_json = { workspace = true } 13 | indexmap = { version = "1.9.3", features = ["serde-1"] } 14 | google-fonts-languages = { workspace = true } 15 | 16 | [lib] 17 | crate-type = ["cdylib", "rlib"] 18 | path = "src/lib.rs" 19 | -------------------------------------------------------------------------------- /shaperglot-web/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use fontations::skrifa::{raw::tables::name::NameId, FontRef, MetadataProvider}; 4 | use wasm_bindgen::prelude::*; 5 | extern crate console_error_panic_hook; 6 | use google_fonts_languages::{RegionProto, ScriptProto, REGIONS, SCRIPTS}; 7 | use shaperglot::{Checker, Languages}; 8 | 9 | #[wasm_bindgen] 10 | pub fn version() -> String { 11 | env!("CARGO_PKG_VERSION").to_string() 12 | } 13 | 14 | #[wasm_bindgen] 15 | extern "C" { 16 | // Use `js_namespace` here to bind `console.log(..)` instead of just 17 | // `log(..)` 18 | #[wasm_bindgen(js_namespace = console)] 19 | fn log(s: &str); 20 | } 21 | 22 | #[wasm_bindgen] 23 | pub fn scripts() -> Result { 24 | let script_hash: HashMap = SCRIPTS 25 | .iter() 26 | .map(|(id, proto)| (id.to_string(), *proto.clone())) 27 | .collect(); 28 | serde_json::to_string(&script_hash).map_err(|e| e.to_string().into()) 29 | } 30 | #[wasm_bindgen] 31 | pub fn regions() -> Result { 32 | let region_hash: HashMap = REGIONS 33 | .iter() 34 | .map(|(id, proto)| (id.to_string(), *proto.clone())) 35 | .collect(); 36 | serde_json::to_string(®ion_hash).map_err(|e| e.to_string().into()) 37 | } 38 | 39 | #[wasm_bindgen] 40 | pub fn family_name(font_data: &[u8]) -> Result { 41 | let font = FontRef::new(font_data).map_err(|e| e.to_string())?; 42 | Ok(font 43 | .localized_strings(NameId::FAMILY_NAME) 44 | .english_or_first() 45 | .map(|s| s.chars().collect()) 46 | .unwrap_or_default()) 47 | } 48 | 49 | #[wasm_bindgen] 50 | pub fn check_font(font_data: &[u8]) -> Result { 51 | let checker = Checker::new(font_data).map_err(|e| e.to_string())?; 52 | let languages = Languages::new(); 53 | let mut results = vec![]; 54 | for language in languages.iter() { 55 | let result = checker.check(language); 56 | if result.is_unknown() { 57 | continue; 58 | } 59 | results.push(( 60 | serde_json::to_value(&language.proto).map_err(|e| e.to_string())?, 61 | serde_json::to_value(result.support_level()).map_err(|e| e.to_string())?, 62 | serde_json::to_value(&result).map_err(|e| e.to_string())?, 63 | )); 64 | } 65 | serde_json::to_string(&results).map_err(|e| e.to_string().into()) 66 | } 67 | -------------------------------------------------------------------------------- /shaperglot-web/www/bootstrap.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import("./index.js") 5 | .catch(e => console.error("Error importing `index.js`:", e)); 6 | -------------------------------------------------------------------------------- /shaperglot-web/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Shaperglot! 7 | 8 | 11 | 27 | 29 | 30 | 31 | 32 | 33 | 43 | 44 | 55 | 70 | 71 |
72 |
73 |

Shaperglot

74 |

A tool for evaluating language coverage, based on the 75 | Google Fonts Language Database 76 |

77 |

Test file

78 | 79 |
80 |
81 |
82 |
83 | 84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /shaperglot-web/www/index.js: -------------------------------------------------------------------------------- 1 | const shaperglotWorker = new Worker(new URL("./webworker.js", import.meta.url)); 2 | const fix_descriptions = { 3 | add_anchor: "Add anchors between the following glyphs", 4 | add_codepoint: "Add the following codepoints to the font", 5 | add_feature: "Add the following features to the font", 6 | }; 7 | const STATUS_INT = { 8 | "Complete": 5, 9 | "Supported": 4, 10 | "Incomplete": 3, 11 | "Unsupported": 2, 12 | "None": 1, 13 | "Indeterminate": 0, 14 | }; 15 | 16 | function commify(x) { 17 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 18 | } 19 | 20 | jQuery.fn.shake = function (interval, distance, times) { 21 | interval = typeof interval == "undefined" ? 100 : interval; 22 | distance = typeof distance == "undefined" ? 10 : distance; 23 | times = typeof times == "undefined" ? 3 : times; 24 | var jTarget = $(this); 25 | jTarget.css("position", "relative"); 26 | for (var iter = 0; iter < times + 1; iter++) { 27 | jTarget.animate( 28 | { 29 | left: iter % 2 == 0 ? distance : distance * -1, 30 | }, 31 | interval 32 | ); 33 | } 34 | return jTarget.animate( 35 | { 36 | left: 0, 37 | }, 38 | interval 39 | ); 40 | }; 41 | 42 | class Shaperglot { 43 | constructor() { 44 | this.font = null; 45 | this.scripts = null; 46 | this.regions = null; 47 | } 48 | 49 | dropFile(files, element) { 50 | if (!files[0].name.match(/\.[ot]tf$/i)) { 51 | $(element).shake(); 52 | return; 53 | } 54 | window.thing = files[0]; 55 | $("#filename").text(files[0].name); 56 | let style = document.styleSheets[0].cssRules[0].style; 57 | try { 58 | style.setProperty("src", "url(" + URL.createObjectURL(files[0]) + ")"); 59 | } catch (e) { 60 | console.error(e + `: https://bugzilla.mozilla.org/show_bug.cgi?id=1466489 is RESOLVED FIXED - 61 | and yet here we are.`); 62 | } 63 | var reader = new FileReader(); 64 | let that = this; 65 | reader.onload = function (e) { 66 | let u8 = new Uint8Array(this.result); 67 | that.font = u8; 68 | that.letsDoThis(); 69 | }; 70 | reader.readAsArrayBuffer(files[0]); 71 | } 72 | 73 | progress_callback(message) { 74 | // console.log("Got message", message); 75 | if ("ready" in message) { 76 | $("#bigLoadingModal").hide(); 77 | $("#startModal").show(); 78 | this.scripts = message.scripts; 79 | this.regions = message.regions; 80 | } else if ("results" in message) { 81 | $("#spinnerModal").hide(); 82 | if (message.family_name) { 83 | $("#filename").text(message.family_name); 84 | } 85 | this.renderResults(message.results); 86 | } 87 | } 88 | 89 | renderResults(results) { 90 | let ix = 0; 91 | let issues_by_script = {}; 92 | let count_supported_by_script = {}; 93 | for (let [language, result, problems] of results) { 94 | issues_by_script[language.script] = 95 | issues_by_script[language.script] || []; 96 | issues_by_script[language.script].push([language, result, problems]); 97 | if (result === "Supported" || result === "Complete" || result === "Incomplete") { 98 | count_supported_by_script[language.script] = 99 | (count_supported_by_script[language.script] || 0) + 1; 100 | } 101 | } 102 | 103 | for (let [script, languages] of Object.entries(issues_by_script).sort( 104 | ([script_a, _languages_a], [script_b, _languages_b]) => 105 | (count_supported_by_script[script_b] || 0) - 106 | (count_supported_by_script[script_a] || 0) 107 | )) { 108 | let supported = count_supported_by_script[script] || 0; 109 | let card = $(` 110 |
111 | 119 | 120 |
121 |
122 | 123 |
124 |
125 |
126 | `); 127 | ix += 1; 128 | let pilldiv = $("
"); 129 | pilldiv.addClass("nav nav-pills"); 130 | card.find(".card-body").append(pilldiv); 131 | for (let language of languages) { 132 | let problemSet = language[2]; 133 | let total_weight = problemSet 134 | .map((r) => r.weight) 135 | .reduce((a, b) => a + b); 136 | let weighted_scores = problemSet.map((r) => r.score * r.weight); 137 | let total_score = weighted_scores.reduce((a, b) => a + b); 138 | problemSet.score = (total_score / total_weight) * 100.0; 139 | } 140 | 141 | for (let [language, result, problems] of languages.sort( 142 | (a, b) => STATUS_INT[a[1]] - STATUS_INT[b[1]] || a[0].name.localeCompare(b[0].name) 143 | )) { 144 | var thispill = $(` 145 | 150 | `); 151 | thispill.data("languagedata", language); 152 | thispill.data("problemset", problems); 153 | thispill.data("result", result); 154 | pilldiv.append(thispill); 155 | thispill.on("click", (el) => { 156 | this.renderProblemSet($(el.target)); 157 | $(el.target).siblings().removeClass("active"); 158 | $(el.target).addClass("active"); 159 | }); 160 | } 161 | $("#scriptlist").append(card); 162 | } 163 | } 164 | 165 | renderProblemSet(el) { 166 | let filename = $("#filename").text(); 167 | let result = $("#language-content div"); 168 | result.empty(); 169 | var problemSet = el.data("problemset"); 170 | let language = el.data("languagedata"); 171 | let langname = language.preferred_name || language.name; 172 | result.append(`

${langname} (${Math.round(problemSet.score)}%)

`); 173 | if (language.autonym) { 174 | result.append(`

(${language.autonym})

`); 175 | } 176 | result.append( 177 | `

ISO369-3 Code: ${language.id}

` 178 | ); 179 | if (language.population) { 180 | result.append( 181 | `

Population: ${commify(language.population)}

` 182 | ); 183 | } 184 | if (language.region) { 185 | let regions_list = language.region 186 | .map((r) => this.regions[r].name) 187 | .join(", "); 188 | result.append(`

Regions: ${regions_list}

`); 189 | } 190 | let status = el.data("result"); 191 | 192 | if (language.sample_text) { 193 | let extra_class = ""; 194 | if (status == "Complete" || status == "Supported" || status == "Incomplete") { 195 | extra_class = "testfont"; 196 | } 197 | result.append($( 198 | `

Sample text:

${language.sample_text.specimen_32} ${language.sample_text.specimen_21}

` 199 | )); 200 | } 201 | 202 | 203 | if (status == "Complete") { 204 | result.append( 205 | `
${filename} comprehensively supports ${langname}!
` 206 | ); 207 | } else if (status == "Supported") { 208 | result.append( 209 | `
${filename} supports ${langname} well (but further support is possible).
` 210 | ); 211 | 212 | } else if (status == "Incomplete") { 213 | result.append( 214 | `
${filename} supports ${langname}.
` 215 | ); 216 | } else if (status == "Unsupported") { 217 | result.append( 218 | `
${filename} does not support ${langname}.
` 219 | ); 220 | } else if (status == "None") { 221 | result.append( 222 | `
${filename} does not attempt to support ${langname}.
` 223 | ); 224 | } else { 225 | result.append( 226 | `
Cannot determine whether ${filename} supports ${langname}.
` 227 | ); 228 | } 229 | // result.append(`
${JSON.stringify(problemSet)}
`); 230 | 231 | let problem_html = $("
"); 232 | let fixdiv = $(`
For full support:
`); 233 | let fixes_needed = {}; 234 | for (var check of problemSet) { 235 | let { 236 | check_name, 237 | check_description, 238 | score, 239 | weight, 240 | problems, 241 | total_checks, 242 | status, 243 | fixes, 244 | } = check; 245 | let mark = status == "Pass" ? "✅" : "❌"; 246 | 247 | problem_html.append( 248 | `
249 |
250 | ${check_name} ${mark} (${Math.round( 251 | score * weight * 100 252 | ) / 100}/${weight} points) 253 | 254 |
${check_description} 255 |
256 |
` 257 | ); 258 | let dd = $(`
259 | 260 |
`); 261 | problem_html.append(dd); 262 | if (problems.length > 0) { 263 | dd.append(`

Problems:

    `); 264 | } else { 265 | problem_html.find("details").last().append(`
    • No problems found!
    `); 266 | } 267 | for (var problem of problems) { 268 | let { check_name, message, fixes } = problem; 269 | dd.find("ul").append(`
  • ${message}
  • `); 270 | 271 | for (var fix of fixes || []) { 272 | let { fix_type, fix_thing } = fix; 273 | fixes_needed[fix_type] = fixes_needed[fix_type] || []; 274 | fixes_needed[fix_type].push(fix_thing); 275 | } 276 | } 277 | } 278 | 279 | for (var [fix_type, fix_things] of Object.entries(fixes_needed)) { 280 | let fix_thing = fix_things.join(", "); 281 | fixdiv.append(`
  • ${fix_descriptions[fix_type]}: ${fix_thing}
  • `); 282 | } 283 | 284 | if ($(fixdiv).children().length > 1) { 285 | problem_html.append($("
    ")); 286 | problem_html.append(fixdiv); 287 | } 288 | 289 | result.append(problem_html); 290 | // result.append(`
    ${JSON.stringify(language)}
    `); 291 | } 292 | 293 | letsDoThis() { 294 | $("#startModal").hide(); 295 | $("#spinnerModal").show(); 296 | shaperglotWorker.postMessage({ 297 | font: this.font, 298 | }); 299 | } 300 | } 301 | 302 | $(function () { 303 | window.shaperglot = new Shaperglot(); 304 | shaperglotWorker.onmessage = (e) => 305 | window.shaperglot.progress_callback(e.data); 306 | $("#bigLoadingModal").show(); 307 | 308 | $(".fontdrop").on("dragover dragenter", function (e) { 309 | e.preventDefault(); 310 | e.stopPropagation(); 311 | $(this).addClass("dragging"); 312 | }); 313 | $(".fontdrop").on("dragleave dragend", function (e) { 314 | $(this).removeClass("dragging"); 315 | }); 316 | 317 | $(".fontdrop").on("drop", function (e) { 318 | console.log("Drop!"); 319 | $(this).removeClass("dragging"); 320 | if ( 321 | e.originalEvent.dataTransfer && 322 | e.originalEvent.dataTransfer.files.length 323 | ) { 324 | e.preventDefault(); 325 | e.stopPropagation(); 326 | window.shaperglot.dropFile(e.originalEvent.dataTransfer.files, this); 327 | } 328 | }); 329 | }); 330 | -------------------------------------------------------------------------------- /shaperglot-web/www/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googlefonts/shaperglot/92878be90fabb7bdd93afd81121f4ed885f33d06/shaperglot-web/www/logo.png -------------------------------------------------------------------------------- /shaperglot-web/www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-wasm-app", 3 | "version": "0.1.0", 4 | "description": "create an app to consume rust-generated wasm packages", 5 | "main": "index.js", 6 | "bin": { 7 | "create-wasm-app": ".bin/create-wasm-app.js" 8 | }, 9 | "scripts": { 10 | "build": "webpack --config webpack.config.js", 11 | "start": "webpack-dev-server" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/rustwasm/create-wasm-app.git" 16 | }, 17 | "keywords": [ 18 | "webassembly", 19 | "wasm", 20 | "rust", 21 | "webpack" 22 | ], 23 | "author": "Ashley Williams ", 24 | "license": "(MIT OR Apache-2.0)", 25 | "bugs": { 26 | "url": "https://github.com/rustwasm/create-wasm-app/issues" 27 | }, 28 | "homepage": "https://github.com/rustwasm/create-wasm-app#readme", 29 | "dependencies": { 30 | "shaperglot": "file:../pkg" 31 | }, 32 | "devDependencies": { 33 | "@wasm-tool/wasm-pack-plugin": "^1.7.0", 34 | "@webassemblyjs/ast": "^1.14.1", 35 | "@webassemblyjs/wasm-edit": "^1.14.1", 36 | "@webassemblyjs/wasm-parser": "^1.14.1", 37 | "copy-webpack-plugin": "^12.0.2", 38 | "webpack": "^5.99.9", 39 | "webpack-cli": "^5.1.4", 40 | "webpack-dev-server": "^5.1.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /shaperglot-web/www/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans&family=Noto+Sans+Arabic&family=Noto+Sans+Nandinagari&display=swap'); 3 | 4 | body { 5 | background-color: #b3dbf0 !important; 6 | font-family: "Montserrat", "Noto Sans", "Noto Sans Arabic", "Noto Sans Nandinagari", "sans-serif" !important; 7 | } 8 | 9 | h4 { 10 | color: #ecf0f4 !important; 11 | } 12 | 13 | @font-face { 14 | font-family: "Adobe NotDef"; 15 | src: url(https://cdn.jsdelivr.net/gh/adobe-fonts/adobe-notdef/AND-Regular.ttf); 16 | } 17 | 18 | #locationnav li { 19 | white-space: wrap; 20 | } 21 | 22 | #userdefined div { 23 | width: 100%; 24 | } 25 | 26 | .modal-dialog { 27 | max-width: 100% !important; 28 | margin: 0 !important; 29 | top: 0; 30 | bottom: 0; 31 | left: 0; 32 | right: 0; 33 | height: 100vh; 34 | display: flex; 35 | } 36 | 37 | .fontdrop { 38 | padding: 30px; 39 | border-radius: 20px; 40 | outline: 3px dashed #ffffffff; 41 | outline-offset: -20px; 42 | } 43 | 44 | #fontbefore { 45 | background-color: #ddffdddd; 46 | } 47 | 48 | #fontafter { 49 | background-color: #ffdddddd; 50 | } 51 | 52 | .dragging { 53 | background-image: linear-gradient(rgb(0 0 0/5%) 0 0); 54 | outline: 5px dashed #ffffffff; 55 | } 56 | 57 | .status-Complete { 58 | background-color: #30f15a !important; 59 | } 60 | 61 | .status-Supported { 62 | background-color: #5eee7d !important; 63 | } 64 | 65 | .status-Incomplete { 66 | background-color: #b7fb73 !important; 67 | } 68 | 69 | .status-Unsupported { 70 | background-color: #ffdddd !important; 71 | } 72 | 73 | .status-None { 74 | background-color: #a8a8a8 !important; 75 | } 76 | 77 | .flex-scroll { 78 | height: 100vh; 79 | overflow-y: auto; 80 | } 81 | 82 | .script-0 { 83 | background-color: #f0f0f0; 84 | opacity: 0.5; 85 | } 86 | 87 | #language-content { 88 | background-color: #ebebed; 89 | padding: 10px; 90 | border-radius: 20px; 91 | border: 1px solid #c0c0c0; 92 | } 93 | 94 | .blockquote { 95 | margin-left: 20px; 96 | margin-right: 20px; 97 | background-color: #f0f0f0; 98 | font-size: 0.75em; 99 | } 100 | blockquote { padding: 5px; } 101 | 102 | button.nav-link { 103 | margin-right: 10px; 104 | margin-bottom: 5px; 105 | } 106 | 107 | .nav-pills .nav-link.active, .nav-pills .show>.nav-link { 108 | color: #888; 109 | } 110 | 111 | dd { margin: 10px 25px 10px 25px; } 112 | 113 | dt details blockquote { font-weight: 400; margin: 10px 25px 10px 25px; } -------------------------------------------------------------------------------- /shaperglot-web/www/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 2 | const path = require("path"); 3 | const crypto = require("crypto"); 4 | const crypto_orig_createHash = crypto.createHash; 5 | crypto.createHash = (algorithm) => 6 | crypto_orig_createHash(algorithm == "md4" ? "sha256" : algorithm); 7 | 8 | module.exports = { 9 | entry: "./bootstrap.js", 10 | experiments: { 11 | asyncWebAssembly: true, 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, "..", "..", "docs"), 15 | filename: "bootstrap.js", 16 | }, 17 | mode: "development", 18 | plugins: [ 19 | new CopyWebpackPlugin({ 20 | patterns: [{ from: "index.html" }, { from: "*.css" }], 21 | }), 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /shaperglot-web/www/webworker.js: -------------------------------------------------------------------------------- 1 | var module = import("../pkg/shaperglot_web.js"); 2 | 3 | async function init() { 4 | console.log("Loading the module"); 5 | let wasm = await module; 6 | console.log("Loaded"); 7 | self.postMessage({ 8 | ready: true, 9 | scripts: JSON.parse(wasm.scripts()), 10 | regions: JSON.parse(wasm.regions()), 11 | }); 12 | self.onmessage = async (event) => { 13 | // make sure loading is done 14 | const { font } = event.data; 15 | try { 16 | const results = JSON.parse(wasm.check_font(font)); 17 | self.postMessage({ results: results, family_name: wasm.family_name(font) }); 18 | } catch (error) { 19 | self.postMessage({ error: error.message }); 20 | } 21 | }; 22 | } 23 | init(); 24 | --------------------------------------------------------------------------------