├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── diff3proof ├── Cargo.toml └── src │ └── main.rs ├── diffenator3-cli ├── Cargo.toml └── src │ ├── main.rs │ └── reporters │ ├── html.rs │ ├── json.rs │ ├── mod.rs │ └── text.rs ├── diffenator3-lib ├── Cargo.toml ├── src │ ├── dfont.rs │ ├── html.rs │ ├── lib.rs │ ├── render │ │ ├── cachedoutlines.rs │ │ ├── encodedglyphs.rs │ │ ├── mod.rs │ │ ├── renderer.rs │ │ ├── rustyruzz.rs │ │ ├── utils.rs │ │ └── wordlists.rs │ └── setting.rs └── wordlists │ ├── Adlam.txt.br │ ├── Arabic.txt.br │ ├── Armenian.txt.br │ ├── Avestan.txt.br │ ├── Bengali.txt.br │ ├── Bopomofo.txt.br │ ├── Canadian_Aboriginal.txt.br │ ├── Chakma.txt.br │ ├── Cherokee.txt.br │ ├── Common.txt.br │ ├── Cyrillic.txt.br │ ├── Devanagari.txt.br │ ├── Ethiopic.txt.br │ ├── Georgian.txt.br │ ├── Grantha.txt.br │ ├── Greek.txt.br │ ├── Gujarati.txt.br │ ├── Gurmukhi.txt.br │ ├── Hebrew.txt.br │ ├── Hiragana.txt.br │ ├── Japanese.txt.br │ ├── Kannada.txt.br │ ├── Katakana.txt.br │ ├── Khmer.txt.br │ ├── Lao.txt.br │ ├── Latin.txt.br │ ├── Lisu.txt.br │ ├── Malayalam.txt.br │ ├── Mongolian.txt.br │ ├── Myanmar.txt.br │ ├── Ol_Chiki.txt.br │ ├── Oriya.txt.br │ ├── Osage.txt.br │ ├── Sinhala.txt.br │ ├── Syriac.txt.br │ ├── Tamil.txt.br │ ├── Telugu.txt.br │ ├── Thai.txt.br │ ├── Thanaa.txt.br │ ├── Tibetan.txt.br │ ├── Tifinagh.txt.br │ └── Vai.txt.br ├── diffenator3-web ├── Cargo.toml ├── src │ └── lib.rs └── www │ ├── .gitignore │ ├── AND-Regular.ttf │ ├── LICENSE-APACHE │ ├── LICENSE-MIT │ ├── bootstrap.js │ ├── index.html │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── wasm-style.css │ ├── webpack.config.js │ └── webworker.js ├── docs ├── bootstrap.js ├── d2931c49b29e0e7498c5.module.wasm ├── index.html ├── index_js.bootstrap.js ├── pkg_diffenator3_web_js.bootstrap.js ├── style.css └── webworker_js.bootstrap.js ├── kerndiffer ├── Cargo.toml └── src │ └── main.rs ├── rendertest ├── Cargo.toml └── src │ └── main.rs ├── templates ├── diff3proof.html ├── diffenator.html ├── script.js ├── shared.js └── style.css └── ttj ├── Cargo.toml └── src ├── bin └── ttj.rs ├── context.rs ├── gdef.rs ├── jsondiff.rs ├── layout.rs ├── layout ├── gpos.rs ├── gsub.rs └── variable_scalars.rs ├── lib.rs ├── monkeypatching.rs ├── namemap.rs └── serializefont.rs /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build CLI binaries 2 | on: 3 | - push 4 | permissions: 5 | contents: write 6 | jobs: 7 | build-and-upload: 8 | name: Build and upload 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | include: 14 | - build: linux 15 | os: ubuntu-latest 16 | target: x86_64-unknown-linux-musl 17 | 18 | - build: macos 19 | os: macos-latest 20 | target: x86_64-apple-darwin 21 | 22 | - build: macos-m1 23 | os: macos-latest 24 | target: aarch64-apple-darwin 25 | 26 | - build: windows-gnu 27 | os: windows-latest 28 | target: x86_64-pc-windows-gnu 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | - name: Install Rust 34 | uses: actions-rust-lang/setup-rust-toolchain@v1 35 | with: 36 | target: ${{ matrix.target }} 37 | - name: Install protoc for lang repo 38 | uses: arduino/setup-protoc@v3 39 | with: 40 | repo-token: ${{ secrets.GITHUB_TOKEN }} 41 | - name: Build diffenator3/diff3proof 42 | run: cargo build --verbose --release --target ${{ matrix.target }} 43 | - name: Decide on our version name (tag or "dev") 44 | id: version 45 | shell: bash 46 | run: | 47 | if [ -n "$GITHUB_REF" ]; then 48 | if [[ "$GITHUB_REF" == refs/tags/* ]]; then 49 | echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 50 | else 51 | echo "VERSION=dev" >> $GITHUB_ENV 52 | fi 53 | else 54 | echo "VERSION=dev" >> $GITHUB_ENV 55 | fi 56 | - name: Build archive 57 | shell: bash 58 | run: | 59 | dirname="diffenator3-${{ env.VERSION }}-${{ matrix.target }}" 60 | mkdir "$dirname" 61 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 62 | mv "target/${{ matrix.target }}/release/"*.exe "$dirname" 63 | 7z a "$dirname.zip" "$dirname" 64 | echo "ASSET=$dirname.zip" >> $GITHUB_ENV 65 | else 66 | mv "target/${{ matrix.target }}/release/diffenator3" "$dirname" 67 | mv "target/${{ matrix.target }}/release/diff3proof" "$dirname" 68 | tar -czf "$dirname.tar.gz" "$dirname" 69 | echo "ASSET=$dirname.tar.gz" >> $GITHUB_ENV 70 | fi 71 | - uses: actions/upload-artifact@v4 72 | with: 73 | name: ${{ env.ASSET }} 74 | path: ${{ env.ASSET }} 75 | - if: contains(github.ref, 'refs/tags/') 76 | name: Create a release 77 | uses: softprops/action-gh-release@v1 78 | with: 79 | files: ${{ env.ASSET }} 80 | tag_name: ${{ env.VERSION }} 81 | token: ${{ secrets.GITHUB_TOKEN }} 82 | -------------------------------------------------------------------------------- /.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 wasm-pack 14 | run: cargo install wasm-pack 15 | - name: Build 16 | run: cd diffenator3-web; wasm-pack build 17 | - name: Build web site 18 | run: | 19 | cd diffenator3-web/www 20 | npm install 21 | npm run build 22 | - name: Upload 23 | uses: actions/upload-pages-artifact@v3.0.1 24 | with: 25 | path: docs 26 | deploy: 27 | needs: build 28 | permissions: 29 | pages: write # to deploy to Pages 30 | id-token: write # to verify the deployment originates from an appropriate source 31 | environment: 32 | name: github-pages 33 | url: ${{ steps.deployment.outputs.page_url }} 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Deploy to GitHub Pages 37 | id: deployment 38 | uses: actions/deploy-pages@v4 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ttf 2 | *.png 3 | *.svg 4 | foo* 5 | /target 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "diffenator3-lib", 5 | "diffenator3-cli", 6 | "diffenator3-web", 7 | "diff3proof", 8 | "kerndiffer", 9 | "rendertest", 10 | "ttj", 11 | ] 12 | default-members = ["diffenator3-cli", "diff3proof"] 13 | 14 | [workspace.dependencies] 15 | read-fonts = { version = "0.23.0", features = ["serde"] } 16 | skrifa = "0.24.0" 17 | indexmap = "1.9.3" 18 | serde_json = { version = "1.0.96", features = ["preserve_order"] } 19 | serde = { version = "*", features = ["derive"] } 20 | 21 | 22 | [profile.dev] 23 | # Rustybuzz debug-asserts that the provided script is the same as 24 | # the text's script, *even if* you're using guess_segment_properties. 25 | debug-assertions = false 26 | 27 | [profile.release] 28 | opt-level = "z" 29 | lto = true 30 | -------------------------------------------------------------------------------- /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 | # Diffenator3 2 | 3 | This is a Rust port of 4 | [diffenator2](https://github.com/googlefonts/diffenator2), a utility for 5 | comparing two font files. It comes in two flavours, command line and 6 | WASM. 7 | 8 | The _command line_ version compares two fonts and describes the differences 9 | between them; it produces reports in text, JSON and HTML format. By default, 10 | it compares variable fonts at their named instances, although you can also 11 | ask for comparisons at specific points in the design space, at the 12 | min/max/default for each axis or subdivisions in between, or at master 13 | locations. See the `--help` documentation of `diffenator3` for more details. 14 | 15 | You can customize the look and feel of the HTML report by editing the templates 16 | in the `~/.diffenator3/templates` directory after running `diffenator3 --html` 17 | for the first time. Additionally, you can supply a `--templates` directory for 18 | per-project templates. 19 | 20 | The _WASM_ version compares two font files over the web and displays a HTML 21 | report of the differences. This runs the `diffenator3` code directly inside 22 | your web browser - the fonts are *not* transferred across the Internet. 23 | You can use the WASM version at 24 | https://googlefonts.github.io/diffenator3 25 | 26 | ## diff3proof 27 | 28 | As well as `diffenator3`, there is another utility called `diff3proof` used 29 | to generate HTML proof files showing the difference between the fonts. This 30 | can be used in two modes: `--sample-mode context` (the default), which 31 | shows paragraphs of sample text for each language supported by the font, and 32 | `--sample-mode cover`, which shows a minimal text to cover all the shared 33 | codepoints in the font. These can be helpful for manually checking rendering 34 | differences in different browsers. 35 | 36 | ## Additional utilities 37 | 38 | If you build `diffenator3` from source, there are three additional workspace 39 | crates which build some utilities which are mainly helpful for working on 40 | `diffenator3` itself: 41 | 42 | - [`ttj`](ttj/) serializes a TTF file to JSON in much the same way that `ttx` 43 | serializes to XML. However, there is no deserialization back to TTF at 44 | present. 45 | - [`kerndiffer`](kerndiffer/) is a limited version of `diffenator3` just for 46 | checking kerning differences. You can achieve much the same functionality 47 | with `diffenator3 --no-tables --no-words --no-glyphs`. 48 | - [`rendertest`](rendertest/) is used to test the rendering and bitmap comparison 49 | functionality of `diffenator3`. It generates bitmap images of words in both 50 | fonts and then overlays them. 51 | 52 | ## Installing 53 | 54 | Binary versions can be obtained from the latest GitHub release; development 55 | versions can be obtained via the latest GitHub Action. The `diffenator3` and 56 | `diff3proof` binaries are all you need - they contain all the templates and 57 | wordlists within them - so you can copy them to anywhere in your path. 58 | 59 | Alternatively you can install from source with 60 | `cargo install --git https://github.com/googlefonts/diffenator3`. 61 | 62 | ## License 63 | 64 | This software is licensed under the [Apache 2.0 License](LICENSE.md). 65 | -------------------------------------------------------------------------------- /diff3proof/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "diff3proof" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | diffenator3-lib = { path = "../diffenator3-lib", features = ["html"] } 8 | google-fonts-languages = "0" 9 | tera = "1" 10 | clap = { version = "4.5.9", features = ["derive"] } 11 | env_logger = "0.11" 12 | serde_json = { workspace = true } 13 | fancy-regex = "0.13" 14 | -------------------------------------------------------------------------------- /diff3proof/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::Path; 3 | use std::{collections::HashSet, path::PathBuf}; 4 | 5 | /// Create before/after HTML proofs of two fonts 6 | // In a way this is not related to the core goal of diffenator3, but 7 | // at the same time, we happen to have all the moving parts required 8 | // to make this, and it would be a shame not to use them. 9 | use clap::Parser; 10 | use diffenator3_lib::dfont::{shared_axes, DFont}; 11 | use diffenator3_lib::html::{gen_html, template_engine}; 12 | use env_logger::Env; 13 | use google_fonts_languages::{SampleTextProto, LANGUAGES, SCRIPTS}; 14 | use serde_json::json; 15 | 16 | #[derive(Parser, Debug, clap::ValueEnum, Clone, PartialEq)] 17 | enum SampleMode { 18 | /// Sample text emphasises real language input 19 | Context, 20 | /// Sample text optimizes for codepoint coverage 21 | Cover, 22 | } 23 | 24 | #[derive(Parser, Debug)] 25 | #[command(version, about, long_about = None)] 26 | struct Cli { 27 | /// Output directory for HTML 28 | #[clap(long = "output", default_value = "out")] 29 | output: String, 30 | 31 | /// Directory for custom templates 32 | #[clap(long = "templates")] 33 | templates: Option, 34 | 35 | /// Update diffenator3's stock templates 36 | #[clap(long = "update-templates")] 37 | update_templates: bool, 38 | 39 | /// Point size for sample text in pixels 40 | #[clap(long = "point-size", default_value = "25")] 41 | point_size: u32, 42 | 43 | /// Choice of sample text 44 | #[clap(long = "sample-mode", default_value = "context")] 45 | sample_mode: SampleMode, 46 | 47 | /// Update 48 | /// The first font file to compare 49 | font1: PathBuf, 50 | /// The second font file to compare 51 | font2: Option, 52 | } 53 | 54 | fn main() { 55 | let cli = Cli::parse(); 56 | env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init(); 57 | 58 | let font_binary_a = std::fs::read(&cli.font1).expect("Couldn't open file"); 59 | 60 | let tera = template_engine(cli.templates.as_ref(), cli.update_templates); 61 | let font_a = DFont::new(&font_binary_a); 62 | 63 | let (shared_codepoints, axes, instances) = if let Some(font2) = &cli.font2 { 64 | let font_binary_b = std::fs::read(&font2).expect("Couldn't open file"); 65 | let font_b = DFont::new(&font_binary_b); 66 | 67 | let shared_codepoints: HashSet = font_a 68 | .codepoints 69 | .intersection(&font_b.codepoints) 70 | .copied() 71 | .collect(); 72 | let (axes, instances) = shared_axes(&font_a, &font_b); 73 | (shared_codepoints, axes, instances) 74 | } else { 75 | let shared_codepoints = font_a.codepoints.clone(); 76 | let (axes, instances) = shared_axes(&font_a, &font_a); 77 | (shared_codepoints, axes, instances) 78 | }; 79 | 80 | let axes_instances = serde_json::to_string(&json!({ 81 | "axes": axes, 82 | "instances": instances 83 | })) 84 | .unwrap(); 85 | 86 | let mut variables = serde_json::Map::new(); 87 | variables.insert("axes_instances".to_string(), axes_instances.into()); 88 | match cli.sample_mode { 89 | SampleMode::Context => { 90 | let sample_texts = language_sample_texts(&shared_codepoints); 91 | variables.insert("language_samples".to_string(), json!(sample_texts)); 92 | } 93 | SampleMode::Cover => { 94 | let sample_text = cover_sample_texts(&shared_codepoints); 95 | variables.insert("cover_sample".to_string(), json!(sample_text)); 96 | } 97 | } 98 | 99 | gen_html( 100 | &cli.font1, 101 | &cli.font2.unwrap_or_else(|| cli.font1.clone()), 102 | Path::new(&cli.output), 103 | tera, 104 | "diff3proof.html", 105 | &variables.into(), 106 | "diff3proof.html", 107 | cli.point_size, 108 | ); 109 | } 110 | 111 | fn longest_sampletext(st: &SampleTextProto) -> &str { 112 | if let Some(text) = &st.specimen_16 { 113 | return text; 114 | } 115 | if let Some(text) = &st.specimen_21 { 116 | return text; 117 | } 118 | if let Some(text) = &st.specimen_32 { 119 | return text; 120 | } 121 | if let Some(text) = &st.specimen_36 { 122 | return text; 123 | } 124 | if let Some(text) = &st.specimen_48 { 125 | return text; 126 | } 127 | if let Some(text) = &st.tester { 128 | return text; 129 | } 130 | "" 131 | } 132 | 133 | fn language_sample_texts(codepoints: &HashSet) -> HashMap> { 134 | let mut texts = HashMap::new(); 135 | let re = fancy_regex::Regex::new(r"^(.{20,})(\1)").unwrap(); 136 | let mut seen_cps = HashSet::new(); 137 | // Sort languages by number of speakers 138 | let mut languages: Vec<_> = LANGUAGES.values().collect(); 139 | languages.sort_by_key(|lang| -lang.population.unwrap_or(0)); 140 | 141 | for lang in languages.iter() { 142 | if let Some(sample) = lang.sample_text.as_ref().map(longest_sampletext) { 143 | let mut sample = sample.replace('\n', " "); 144 | let sample_chars = sample.chars().map(|c| c as u32).collect::>(); 145 | 146 | // Can we render this text? 147 | if !sample_chars.is_subset(codepoints) { 148 | continue; 149 | } 150 | // Does this add anything new to the mix? 151 | if sample_chars.is_subset(&seen_cps) { 152 | continue; 153 | } 154 | seen_cps.extend(sample_chars); 155 | let script = lang.script(); 156 | let script_name = SCRIPTS.get(script).unwrap().name(); 157 | // Remove repeated phrases 158 | if let Ok(Some(captures)) = re.captures(&sample) { 159 | sample = captures.get(1).unwrap().as_str().to_string(); 160 | } 161 | texts 162 | .entry(script_name.to_string()) 163 | .or_insert_with(Vec::new) 164 | .push((lang.name().to_string(), sample.to_string())); 165 | } 166 | } 167 | texts 168 | } 169 | 170 | fn cover_sample_texts(codepoints: &HashSet) -> String { 171 | // Create a bag of shapable words 172 | let mut words = HashSet::new(); 173 | let mut languages: Vec<_> = LANGUAGES.values().collect(); 174 | languages.sort_by_key(|lang| -lang.population.unwrap_or(0)); 175 | 176 | for lang in languages.iter() { 177 | if let Some(sample) = lang.sample_text.as_ref().map(longest_sampletext) { 178 | let sample = sample.replace('\n', " "); 179 | for a_word in sample.split_whitespace() { 180 | let word_chars = a_word.chars().map(|c| c as u32).collect::>(); 181 | // Can we render this text? 182 | if !word_chars.is_subset(codepoints) { 183 | continue; 184 | } 185 | words.insert(a_word.to_string()); 186 | } 187 | } 188 | } 189 | 190 | // Now do the greedy cover 191 | let mut uncovered_codepoints = codepoints.clone(); 192 | let mut best_words = vec![]; 193 | let mut prev_count = usize::MAX; 194 | while !uncovered_codepoints.is_empty() { 195 | if uncovered_codepoints.len() == prev_count { 196 | break; 197 | } 198 | prev_count = uncovered_codepoints.len(); 199 | let best_word = words 200 | .iter() 201 | .max_by_key(|word| { 202 | let word_chars = word.chars().map(|c| c as u32).collect::>(); 203 | word_chars.intersection(&uncovered_codepoints).count() 204 | }) 205 | .unwrap(); 206 | for char in best_word.chars() { 207 | uncovered_codepoints.remove(&(char as u32)); 208 | } 209 | best_words.push(best_word.to_string()); 210 | } 211 | best_words.sort(); 212 | best_words.join(" ") 213 | } 214 | -------------------------------------------------------------------------------- /diffenator3-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "diffenator3" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | diffenator3-lib = { path = "../diffenator3-lib", features = ["html"] } 8 | ttj = { path = "../ttj" } 9 | indexmap = { workspace = true } 10 | skrifa = { workspace = true } 11 | serde_json = { workspace = true } 12 | serde = { workspace = true } 13 | rayon = "*" 14 | colored = "2.1.0" 15 | 16 | clap = { version = "4.5.9", features = ["derive"] } 17 | itertools = "0.13.0" 18 | env_logger = "0.11" 19 | -------------------------------------------------------------------------------- /diffenator3-cli/src/reporters/html.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use diffenator3_lib::html::{gen_html, Tera}; 4 | 5 | use super::Report; 6 | 7 | pub(crate) fn report( 8 | font1_pb: &PathBuf, 9 | font2_pb: &PathBuf, 10 | output_dir: &Path, 11 | tera: Tera, 12 | report: &Report, 13 | ) -> ! { 14 | gen_html( 15 | font1_pb, 16 | font2_pb, 17 | output_dir, 18 | tera, 19 | "diffenator.html", 20 | &serde_json::to_value(report).expect("Couldn't serialize report"), 21 | "diffenator.html", 22 | 40, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /diffenator3-cli/src/reporters/json.rs: -------------------------------------------------------------------------------- 1 | use super::Report; 2 | 3 | pub fn report(result: Report, pretty: bool) { 4 | if pretty { 5 | println!("{}", serde_json::to_string_pretty(&result).expect("foo")); 6 | } else { 7 | println!("{}", serde_json::to_string(&result).expect("foo")); 8 | } 9 | std::process::exit(0); 10 | } 11 | -------------------------------------------------------------------------------- /diffenator3-cli/src/reporters/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod html; 2 | pub mod json; 3 | pub mod text; 4 | 5 | use std::collections::HashMap; 6 | 7 | use serde::Serialize; 8 | 9 | use diffenator3_lib::render::encodedglyphs::CmapDiff; 10 | use diffenator3_lib::render::GlyphDiff; 11 | use ttj::jsondiff::Substantial; 12 | 13 | #[derive(Serialize, Default)] 14 | pub struct LocationResult { 15 | pub location: String, 16 | #[serde(skip_serializing_if = "HashMap::is_empty")] 17 | pub coords: HashMap, 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub error: Option, 20 | #[serde(skip_serializing_if = "Vec::is_empty")] 21 | pub glyphs: Vec, 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | pub words: Option, 24 | } 25 | 26 | impl LocationResult { 27 | pub fn is_some(&self) -> bool { 28 | self.error.is_some() 29 | || !self.glyphs.is_empty() 30 | || (self.words.is_some() && self.words.as_ref().unwrap().is_something()) 31 | } 32 | 33 | pub fn from_error(location: String, error: String) -> Self { 34 | LocationResult { 35 | location, 36 | error: Some(error), 37 | ..Default::default() 38 | } 39 | } 40 | } 41 | #[derive(Serialize, Default)] 42 | pub struct Report { 43 | #[serde(skip_serializing_if = "Option::is_none")] 44 | pub tables: Option, 45 | #[serde(skip_serializing_if = "Option::is_none")] 46 | pub kerns: Option, 47 | #[serde(skip_serializing_if = "Option::is_none")] 48 | pub cmap_diff: Option, 49 | #[serde(skip_serializing_if = "Vec::is_empty")] 50 | pub locations: Vec, 51 | } 52 | -------------------------------------------------------------------------------- /diffenator3-cli/src/reporters/text.rs: -------------------------------------------------------------------------------- 1 | use super::{LocationResult, Report}; 2 | 3 | use colored::Colorize; 4 | use serde_json::Map; 5 | use ttj::jsondiff::Substantial; 6 | 7 | pub fn show_map_diff(fields: &Map, indent: usize, succinct: bool) { 8 | for (field, diff) in fields.iter() { 9 | print!("{}", " ".repeat(indent * 2)); 10 | if field == "error" { 11 | println!("{}", diff.as_str().unwrap().red()); 12 | continue; 13 | } 14 | if let Some(lr) = diff.as_array() { 15 | let (left, right) = (&lr[0], &lr[1]); 16 | if succinct && (left.is_something() && !right.is_something()) { 17 | println!( 18 | "{}: {} => {}", 19 | field, 20 | format!("{}", left).green(), 21 | "".red().italic() 22 | ); 23 | } else if succinct && (right.is_something() && !left.is_something()) { 24 | println!( 25 | "{}: {} => {}", 26 | field, 27 | "".green().italic(), 28 | format!("{}", right).red() 29 | ); 30 | } else { 31 | println!( 32 | "{}: {} => {}", 33 | field, 34 | format!("{}", left).green(), 35 | format!("{}", right).red() 36 | ); 37 | } 38 | } else if let Some(fields) = diff.as_object() { 39 | println!("{}:", field); 40 | show_map_diff(fields, indent + 1, succinct) 41 | } 42 | } 43 | } 44 | 45 | pub fn report(result: Report, succinct: bool) { 46 | if let Some(tables) = result.tables { 47 | for (table_name, diff) in tables.as_object().unwrap().iter() { 48 | if diff.is_something() { 49 | println!("\n# {}", table_name); 50 | } 51 | if let Some(lr) = diff.as_array() { 52 | let (left, right) = (&lr[0], &lr[1]); 53 | if succinct && (left.is_something() && !right.is_something()) { 54 | println!("Table was present in LHS but absent in RHS"); 55 | } else if succinct && (right.is_something() && !left.is_something()) { 56 | println!("Table was present in RHS but absent in LHS"); 57 | } else { 58 | println!("LHS had: {}", left); 59 | println!("RHS had: {}", right); 60 | } 61 | } else if let Some(fields) = diff.as_object() { 62 | show_map_diff(fields, 0, succinct); 63 | } else { 64 | println!("Unexpected diff format: {}", diff); 65 | } 66 | } 67 | } 68 | 69 | if let Some(cmap_diff) = result.cmap_diff { 70 | println!("\n# Encoded Glyphs"); 71 | if !cmap_diff.missing.is_empty() { 72 | println!("\nMissing glyphs:"); 73 | for glyph in cmap_diff.missing { 74 | println!(" - {} ", glyph); 75 | } 76 | } 77 | if !cmap_diff.new.is_empty() { 78 | println!("\nNew glyphs:"); 79 | for glyph in cmap_diff.new { 80 | println!(" - {} ", glyph); 81 | } 82 | } 83 | } 84 | 85 | for locationresult in result.locations { 86 | if locationresult.is_some() { 87 | report_location(locationresult); 88 | } 89 | } 90 | } 91 | 92 | fn report_location(locationresult: LocationResult) { 93 | print!("# Differences at location {} ", locationresult.location); 94 | if !locationresult.coords.is_empty() { 95 | print!("( "); 96 | for (k, v) in locationresult.coords.iter() { 97 | print!("{}: {}, ", k, v); 98 | } 99 | print!(")"); 100 | } 101 | println!(); 102 | 103 | if !locationresult.glyphs.is_empty() { 104 | println!("\n## Glyphs"); 105 | for glyph in locationresult.glyphs { 106 | println!(" - {} ({:.3} pixels)", glyph.string, glyph.differing_pixels); 107 | } 108 | } 109 | 110 | if let Some(words) = locationresult.words { 111 | println!("# Words"); 112 | let map = words.as_object().unwrap(); 113 | for (script, script_diff) in map.iter() { 114 | println!("\n## {}", script); 115 | for difference in script_diff.as_array().unwrap().iter() { 116 | println!( 117 | " - {} ({:.3}%)", 118 | difference["word"].as_str().unwrap(), 119 | difference["differing_pixels"].as_i64().unwrap() 120 | ); 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /diffenator3-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "diffenator3-lib" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [target.'cfg(not(target_family = "wasm"))'.dependencies] 7 | rayon = { version = "*" } 8 | indicatif = { version = "*", features = ["rayon"] } 9 | thread_local = "1.1" 10 | 11 | [lib] 12 | crate-type = ["cdylib", "rlib"] 13 | path = "src/lib.rs" 14 | 15 | [features] 16 | html = ["dep:tera", "dep:homedir", "dep:walkdir"] 17 | 18 | [dependencies] 19 | read-fonts = { workspace = true } 20 | skrifa = { workspace = true } 21 | indexmap = { workspace = true } 22 | serde_json = { workspace = true } 23 | serde = { workspace = true } 24 | 25 | ttj = { path = "../ttj" } 26 | cfg-if = "1.0.0" 27 | ab_glyph = "0.2.21" 28 | ab_glyph_rasterizer = "0.1.8" 29 | image = { version = "0.24.6", default-features = false } 30 | rustybuzz = "0.18.0" 31 | ucd = "0.1.1" 32 | unicode_names2 = "0.6.0" 33 | brotli = "6.0.0" 34 | lazy_static = "1.4.0" 35 | zeno = "0.3.1" 36 | log = "0.4" 37 | 38 | # HTML reporter shared code 39 | tera = { version = "1", optional = true } 40 | homedir = { version = "0.3.3", optional = true } 41 | walkdir = { version = "2.5.0", optional = true } 42 | -------------------------------------------------------------------------------- /diffenator3-lib/src/dfont.rs: -------------------------------------------------------------------------------- 1 | use crate::setting::parse_location; 2 | use read_fonts::{types::NameId, FontRef, ReadError, TableProvider}; 3 | use skrifa::{instance::Location, setting::VariationSetting, MetadataProvider}; 4 | use std::{ 5 | borrow::Cow, 6 | collections::{HashMap, HashSet}, 7 | }; 8 | use ttj::monkeypatching::DenormalizeLocation; 9 | use ucd::Codepoint; 10 | 11 | /// A representation of everything we need to know about a font for diffenator purposes 12 | #[derive(Debug, Clone)] 13 | pub struct DFont { 14 | /// The font binary data 15 | pub backing: Vec, 16 | /// The location of the font we are interested in diffing 17 | pub location: Vec, 18 | /// The normalized location of the font 19 | pub normalized_location: Location, 20 | /// The set of encoded codepoints in the font 21 | pub codepoints: HashSet, 22 | } 23 | 24 | impl DFont { 25 | /// Create a new DFont from a byte slice 26 | pub fn new(string: &[u8]) -> Self { 27 | let backing: Vec = string.to_vec(); 28 | 29 | let mut fnt = DFont { 30 | backing, 31 | codepoints: HashSet::new(), 32 | normalized_location: Location::default(), 33 | location: vec![], 34 | }; 35 | let cmap = fnt.fontref().charmap(); 36 | fnt.codepoints = cmap.mappings().map(|(cp, _)| cp).collect(); 37 | fnt 38 | } 39 | 40 | /// Normalize the location 41 | /// 42 | /// This method must be called after the location is changed. 43 | /// (It's that or getters and setters, and nobody wants that.) 44 | pub fn normalize_location(&mut self) { 45 | self.normalized_location = self.fontref().axes().location(&self.location); 46 | } 47 | 48 | /// Set the location of the font given a user-specified location string 49 | pub fn set_location(&mut self, variations: &str) -> Result<(), String> { 50 | self.location = parse_location(variations)?; 51 | self.normalize_location(); 52 | Ok(()) 53 | } 54 | 55 | /// The names of the font's named instances 56 | pub fn instances(&self) -> Vec { 57 | self.fontref() 58 | .named_instances() 59 | .iter() 60 | .flat_map(|ni| { 61 | self.fontref() 62 | .localized_strings(ni.subfamily_name_id()) 63 | .english_or_first() 64 | }) 65 | .map(|s| s.to_string()) 66 | .collect() 67 | } 68 | 69 | /// Set the location of the font to a given named instance 70 | pub fn set_instance(&mut self, instance: &str) -> Result<(), String> { 71 | let instance = self 72 | .fontref() 73 | .named_instances() 74 | .iter() 75 | .find(|ni| { 76 | self.fontref() 77 | .localized_strings(ni.subfamily_name_id()) 78 | .any(|s| instance == s.chars().collect::>()) 79 | }) 80 | .ok_or_else(|| format!("No instance named {}", instance))?; 81 | let user_coords = instance.user_coords(); 82 | let location = instance.location(); 83 | self.location = self 84 | .fontref() 85 | .axes() 86 | .iter() 87 | .zip(user_coords) 88 | .map(|(a, v)| (a.tag(), v).into()) 89 | .collect(); 90 | self.normalized_location = location; 91 | Ok(()) 92 | } 93 | 94 | pub fn fontref(&self) -> FontRef { 95 | FontRef::new(&self.backing).expect("Couldn't parse font") 96 | } 97 | pub fn family_name(&self) -> String { 98 | self.fontref() 99 | .localized_strings(NameId::FAMILY_NAME) 100 | .english_or_first() 101 | .map_or_else(|| "Unknown".to_string(), |s| s.chars().collect()) 102 | } 103 | 104 | pub fn style_name(&self) -> String { 105 | self.fontref() 106 | .localized_strings(NameId::SUBFAMILY_NAME) 107 | .english_or_first() 108 | .map_or_else(|| "Regular".to_string(), |s| s.chars().collect()) 109 | } 110 | 111 | /// The axes of the font 112 | /// 113 | /// Returns a map from axis tag to (min, default, max) values 114 | pub fn axis_info(&self) -> HashMap { 115 | self.fontref() 116 | .axes() 117 | .iter() 118 | .map(|axis| { 119 | ( 120 | axis.tag().to_string(), 121 | (axis.min_value(), axis.default_value(), axis.max_value()), 122 | ) 123 | }) 124 | .collect() 125 | } 126 | 127 | /// Returns a list of scripts where the font has at least one encoded 128 | /// character from that script. 129 | pub fn supported_scripts(&self) -> HashSet { 130 | let cmap = self.fontref().charmap(); 131 | let mut strings = HashSet::new(); 132 | for (codepoint, _glyphid) in cmap.mappings() { 133 | if let Some(script) = char::from_u32(codepoint).and_then(|c| c.script()) { 134 | // Would you believe, no Display, no .to_string(), we just have to grub around with Debug. 135 | strings.insert(format!("{:?}", script)); 136 | } 137 | } 138 | strings 139 | } 140 | 141 | /// Returns a list of the master locations in the font 142 | /// 143 | /// This is derived heuristically from locations of shared tuples in the `gvar` table. 144 | /// This should work well enough for most "normal" fonts. 145 | pub fn masters(&self) -> Result>, ReadError> { 146 | let gvar = self.fontref().gvar()?; 147 | let tuples = gvar.shared_tuples()?.tuples(); 148 | let peaks: Vec> = tuples 149 | .iter() 150 | .flatten() 151 | .flat_map(|tuple| { 152 | let location = tuple 153 | .values() 154 | .iter() 155 | .map(|x| x.get().to_f32()) 156 | .collect::>(); 157 | self.fontref().denormalize_location(&location) 158 | }) 159 | .collect(); 160 | Ok(peaks) 161 | } 162 | } 163 | 164 | type InstancePositions = Vec<(String, HashMap)>; 165 | type AxisDescription = HashMap; 166 | 167 | /// Compare two fonts and return the axes and instances they have in common 168 | pub fn shared_axes(f_a: &DFont, f_b: &DFont) -> (AxisDescription, InstancePositions) { 169 | let mut axes = f_a.axis_info(); 170 | let b_axes = f_b.axis_info(); 171 | let a_axes_names: Vec = axes.keys().cloned().collect(); 172 | for axis_tag in a_axes_names.iter() { 173 | if !b_axes.contains_key(axis_tag) { 174 | axes.remove(axis_tag); 175 | } 176 | } 177 | for (axis_tag, values) in b_axes.iter() { 178 | let (our_min, _our_default, our_max) = values; 179 | axes.entry(axis_tag.clone()) 180 | .and_modify(|(their_min, _their_default, their_max)| { 181 | // This looks upside-down but remember we are 182 | // narrowing the axis ranges to the union of the 183 | // two fonts. 184 | *their_min = their_min.max(*our_min); 185 | *their_max = their_max.min(*our_max); 186 | }); 187 | } 188 | let axis_names: Vec = f_a 189 | .fontref() 190 | .axes() 191 | .iter() 192 | .map(|axis| axis.tag().to_string()) 193 | .collect(); 194 | let instances = f_a 195 | .fontref() 196 | .named_instances() 197 | .iter() 198 | .map(|ni| { 199 | let name = f_a 200 | .fontref() 201 | .localized_strings(ni.subfamily_name_id()) 202 | .english_or_first() 203 | .map_or_else(|| "Unknown".to_string(), |s| s.chars().collect()); 204 | let location_map = axis_names.iter().cloned().zip(ni.user_coords()).collect(); 205 | (name, location_map) 206 | }) 207 | .collect::)>>(); 208 | (axes, instances) 209 | } 210 | -------------------------------------------------------------------------------- /diffenator3-lib/src/html.rs: -------------------------------------------------------------------------------- 1 | // Shared HTML templating code between diffenator3-cli and diff3proof 2 | use serde_json::{json, Value}; 3 | use std::{ 4 | error::Error, 5 | path::{Path, PathBuf}, 6 | }; 7 | use tera::Context; 8 | pub use tera::Tera; 9 | use walkdir::WalkDir; 10 | 11 | pub(crate) fn die(doing: &str, err: impl Error) -> ! { 12 | eprintln!("Error {}: {}", doing, err); 13 | eprintln!(); 14 | eprintln!("Caused by:"); 15 | if let Some(cause) = err.source() { 16 | for (i, e) in std::iter::successors(Some(cause), |e| (*e).source()).enumerate() { 17 | eprintln!(" {}: {}", i, e); 18 | } 19 | } 20 | std::process::exit(1); 21 | } 22 | 23 | #[allow(clippy::too_many_arguments)] 24 | pub fn gen_html( 25 | font1_pb: &PathBuf, 26 | font2_pb: &PathBuf, 27 | output_dir: &Path, 28 | tera: Tera, 29 | template_name: &str, 30 | template_variables: &Value, 31 | output_file: &str, 32 | point_size: u32, 33 | ) -> ! { 34 | // Make output directory 35 | if !output_dir.exists() { 36 | std::fs::create_dir(output_dir).expect("Couldn't create output directory"); 37 | } 38 | 39 | // Copy old font to output/old- 40 | let old_font = output_dir.join(format!( 41 | "old-{}", 42 | font1_pb.file_name().unwrap().to_str().unwrap() 43 | )); 44 | std::fs::copy(font1_pb, &old_font).expect("Couldn't copy old font"); 45 | let new_font = output_dir.join(format!( 46 | "new-{}", 47 | font2_pb.file_name().unwrap().to_str().unwrap() 48 | )); 49 | std::fs::copy(font2_pb, &new_font).expect("Couldn't copy new font"); 50 | 51 | let html = tera 52 | .render( 53 | template_name, 54 | &Context::from_serialize(json!({ 55 | "report": template_variables, 56 | "old_filename": old_font.file_name().unwrap().to_str().unwrap(), 57 | "new_filename": new_font.file_name().unwrap().to_str().unwrap(), 58 | "pt_size": point_size, 59 | })) 60 | .unwrap_or_else(|err| die("creating context", err)), 61 | ) 62 | .unwrap_or_else(|err| die("rendering HTML", err)); 63 | 64 | // Write output 65 | let output_file = output_dir.join(output_file); 66 | println!("Writing output to {}", output_file.to_str().unwrap()); 67 | std::fs::write(output_file, html).expect("Couldn't write output file"); 68 | std::process::exit(0); 69 | } 70 | 71 | /// Instantiate a Tera template engine 72 | /// 73 | /// This function also takes care of working out which templates to use. If the user 74 | /// passes a directory for their own templates, these are used. Otherwise, the 75 | /// templates supplied in the binary are copied into the user's home directory, 76 | /// and this directory is used as the template root. 77 | pub fn template_engine(user_templates: Option<&String>, overwrite: bool) -> Tera { 78 | let homedir = create_user_home_templates_directory(overwrite); 79 | let mut tera = Tera::new(&format!("{}/*", homedir.to_str().unwrap())).unwrap_or_else(|e| { 80 | println!("Problem parsing templates: {:?}", e); 81 | std::process::exit(1) 82 | }); 83 | if let Some(template_dir) = user_templates { 84 | for entry in WalkDir::new(template_dir) { 85 | if entry.as_ref().is_ok_and(|e| e.file_type().is_dir()) { 86 | continue; 87 | } 88 | let path = entry 89 | .as_ref() 90 | .unwrap_or_else(|e| { 91 | println!("Problem reading template path: {:}", e); 92 | std::process::exit(1) 93 | }) 94 | .path(); 95 | if let Err(e) = 96 | tera.add_template_file(path, path.strip_prefix(template_dir).unwrap().to_str()) 97 | { 98 | println!("Problem adding template file: {:}", e); 99 | std::process::exit(1) 100 | } 101 | } 102 | if let Err(e) = tera.build_inheritance_chains() { 103 | println!("Problem building inheritance chains: {:}", e); 104 | std::process::exit(1) 105 | } 106 | } 107 | tera 108 | } 109 | 110 | pub fn create_user_home_templates_directory(force: bool) -> PathBuf { 111 | let home = homedir::my_home() 112 | .expect("Couldn't got home directory") 113 | .expect("No home directory found"); 114 | let templates_dir = home.join(".diffenator3/templates"); 115 | if !templates_dir.exists() { 116 | std::fs::create_dir_all(&templates_dir).unwrap_or_else(|e| { 117 | println!("Couldn't create {}: {}", templates_dir.to_str().unwrap(), e); 118 | std::process::exit(1); 119 | }); 120 | } 121 | let all_templates = [ 122 | ["script.js", include_str!("../../templates/script.js")], 123 | ["shared.js", include_str!("../../templates/shared.js")], 124 | ["style.css", include_str!("../../templates/style.css")], 125 | [ 126 | "diffenator.html", 127 | include_str!("../../templates/diffenator.html"), 128 | ], 129 | [ 130 | "diff3proof.html", 131 | include_str!("../../templates/diff3proof.html"), 132 | ], 133 | ]; 134 | for template in all_templates.iter() { 135 | let path = templates_dir.join(template[0]); 136 | if !path.exists() || force { 137 | std::fs::write(&path, template[1]).unwrap_or_else(|e| { 138 | println!( 139 | "Couldn't write template file {}: {}", 140 | path.to_str().unwrap(), 141 | e 142 | ); 143 | std::process::exit(1) 144 | }); 145 | } 146 | } 147 | templates_dir 148 | } 149 | -------------------------------------------------------------------------------- /diffenator3-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod dfont; 2 | // Shared HTML rendering/templating code 3 | #[cfg(feature = "html")] 4 | pub mod html; 5 | pub mod render; 6 | pub mod setting; 7 | -------------------------------------------------------------------------------- /diffenator3-lib/src/render/cachedoutlines.rs: -------------------------------------------------------------------------------- 1 | /// Speed up the drawing process by caching the outlines of glyphs. 2 | use std::collections::HashMap; 3 | 4 | use skrifa::{ 5 | instance::Size, outline::DrawSettings, prelude::LocationRef, GlyphId, OutlineGlyphCollection, 6 | }; 7 | use zeno::Command; 8 | 9 | use super::utils::RecordingPen; 10 | 11 | pub(crate) struct CachedOutlineGlyphCollection<'a> { 12 | source: OutlineGlyphCollection<'a>, 13 | cache: HashMap>, 14 | size: Size, 15 | location: LocationRef<'a>, 16 | } 17 | 18 | impl<'a> CachedOutlineGlyphCollection<'a> { 19 | pub fn new(source: OutlineGlyphCollection<'a>, size: Size, location: LocationRef<'a>) -> Self { 20 | Self { 21 | source, 22 | size, 23 | location, 24 | cache: HashMap::new(), 25 | } 26 | } 27 | 28 | pub fn get(&mut self, glyph_id: GlyphId) -> Option<&Vec> { 29 | if let std::collections::hash_map::Entry::Vacant(e) = self.cache.entry(glyph_id) { 30 | let outlined = self.source.get(glyph_id).unwrap(); 31 | let mut pen = RecordingPen::default(); 32 | let settings = DrawSettings::unhinted(self.size, self.location); 33 | let _ = outlined.draw(settings, &mut pen); 34 | e.insert(pen.buffer); 35 | } 36 | self.cache.get(&glyph_id) 37 | } 38 | 39 | pub fn draw(&mut self, glyph_id: GlyphId, pen: &mut RecordingPen) { 40 | let commands = self.get(glyph_id).unwrap(); 41 | let matrix = zeno::Transform::translation(pen.offset_x, pen.offset_y); 42 | pen.buffer 43 | .extend(commands.iter().map(|c| c.transform(&matrix))); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /diffenator3-lib/src/render/encodedglyphs.rs: -------------------------------------------------------------------------------- 1 | /// Find and represent differences between encoded glyphs in the fonts. 2 | use std::fmt::Display; 3 | 4 | use crate::dfont::DFont; 5 | use crate::render::rustyruzz::Direction; 6 | use crate::render::{diff_many_words, GlyphDiff}; 7 | use serde::Serialize; 8 | 9 | use super::{DEFAULT_GLYPHS_FONT_SIZE, DEFAULT_GLYPHS_THRESHOLD}; 10 | 11 | #[derive(Serialize)] 12 | pub struct EncodedGlyph { 13 | /// The character, as a string 14 | pub string: String, 15 | /// Name of the character from the Unicode database, if available 16 | #[serde(skip_serializing_if = "Option::is_none")] 17 | pub name: Option, 18 | } 19 | 20 | impl From for EncodedGlyph { 21 | fn from(c: char) -> Self { 22 | EncodedGlyph { 23 | string: c.to_string(), 24 | name: unicode_names2::name(c).map(|s| s.to_string()), 25 | } 26 | } 27 | } 28 | 29 | impl From for EncodedGlyph { 30 | fn from(c: u32) -> Self { 31 | char::from_u32(c).unwrap().into() 32 | } 33 | } 34 | 35 | impl Display for EncodedGlyph { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 | write!( 38 | f, 39 | "{} (U+{:04X})", 40 | self.string, 41 | self.string.chars().next().unwrap() as u32 42 | )?; 43 | if let Some(name) = &self.name { 44 | write!(f, " {}", name) 45 | } else { 46 | Ok(()) 47 | } 48 | } 49 | } 50 | 51 | /// Represents changes to the cmap table - added or removed glyphs 52 | #[derive(Serialize)] 53 | pub struct CmapDiff { 54 | #[serde(skip_serializing_if = "Vec::is_empty")] 55 | pub missing: Vec, 56 | #[serde(skip_serializing_if = "Vec::is_empty")] 57 | pub new: Vec, 58 | } 59 | 60 | impl CmapDiff { 61 | pub fn is_some(&self) -> bool { 62 | !self.missing.is_empty() || !self.new.is_empty() 63 | } 64 | 65 | /// Compare the encoded codepoints from two fonts and return the differences 66 | pub fn new(font_a: &DFont, font_b: &DFont) -> Self { 67 | let cmap_a = &font_a.codepoints; 68 | let cmap_b = &font_b.codepoints; 69 | Self { 70 | missing: cmap_a.difference(cmap_b).map(|&x| x.into()).collect(), 71 | new: cmap_b.difference(cmap_a).map(|&x| x.into()).collect(), 72 | } 73 | } 74 | } 75 | 76 | /// Render the encoded glyphs common to both fonts, and return any differences 77 | pub fn modified_encoded_glyphs(font_a: &DFont, font_b: &DFont) -> Vec { 78 | let cmap_a = &font_a.codepoints; 79 | let cmap_b = &font_b.codepoints; 80 | let same_glyphs = cmap_a.intersection(cmap_b); 81 | let word_list: Vec = same_glyphs 82 | .filter_map(|i| char::from_u32(*i)) 83 | .map(|c| c.to_string()) 84 | .collect(); 85 | let mut result: Vec = diff_many_words( 86 | font_a, 87 | font_b, 88 | DEFAULT_GLYPHS_FONT_SIZE, 89 | word_list, 90 | DEFAULT_GLYPHS_THRESHOLD, 91 | Direction::LeftToRight, 92 | None, 93 | ) 94 | .into_iter() 95 | .map(|x| x.into()) 96 | .collect(); 97 | result.sort_by_key(|x| -(x.differing_pixels as i32)); 98 | result 99 | } 100 | -------------------------------------------------------------------------------- /diffenator3-lib/src/render/renderer.rs: -------------------------------------------------------------------------------- 1 | /// Turn some words into images 2 | use crate::render::rustyruzz::{ 3 | shape_with_plan, Direction, Face, Script, ShapePlan, UnicodeBuffer, Variation, 4 | }; 5 | use image::{DynamicImage, GrayImage, Luma}; 6 | use skrifa::instance::Size; 7 | use skrifa::raw::TableProvider; 8 | use skrifa::{GlyphId, MetadataProvider}; 9 | use zeno::Command; 10 | 11 | use super::cachedoutlines::CachedOutlineGlyphCollection; 12 | use super::utils::{terrible_bounding_box, RecordingPen}; 13 | use crate::dfont::DFont; 14 | 15 | pub struct Renderer<'a> { 16 | face: Face<'a>, 17 | scale: f32, 18 | font: skrifa::FontRef<'a>, 19 | plan: ShapePlan, 20 | outlines: CachedOutlineGlyphCollection<'a>, 21 | } 22 | 23 | impl<'a> Renderer<'a> { 24 | /// Create a new renderer for a font 25 | /// 26 | /// Direction and script are needed for correct shaping; no automatic detection is done. 27 | pub fn new( 28 | dfont: &'a DFont, 29 | font_size: f32, 30 | direction: Direction, 31 | script: Option 8 | 11 | 37 | 39 | 40 | 41 | 42 | 43 | 54 | 55 | 66 | 88 | 89 |
90 | 91 |
92 |
Old
93 |
Animate
94 |
95 |
96 |
97 | 98 |
99 |
100 |
101 |

Modified Words

102 |
103 |
104 | Loading... 105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /diffenator3-web/www/index.js: -------------------------------------------------------------------------------- 1 | const diffWorker = new Worker(new URL("./webworker.js", import.meta.url)); 2 | 3 | import { 4 | addAGlyph, 5 | addAWord, 6 | cmapDiff, 7 | setupAnimation, 8 | diffTables, 9 | diffKerns, 10 | } from "../../templates/shared"; 11 | 12 | jQuery.fn.shake = function (interval, distance, times) { 13 | interval = typeof interval == "undefined" ? 100 : interval; 14 | distance = typeof distance == "undefined" ? 10 : distance; 15 | times = typeof times == "undefined" ? 3 : times; 16 | var jTarget = $(this); 17 | jTarget.css("position", "relative"); 18 | for (var iter = 0; iter < times + 1; iter++) { 19 | jTarget.animate( 20 | { 21 | left: iter % 2 == 0 ? distance : distance * -1, 22 | }, 23 | interval 24 | ); 25 | } 26 | return jTarget.animate( 27 | { 28 | left: 0, 29 | }, 30 | interval 31 | ); 32 | }; 33 | 34 | class Diffenator { 35 | constructor() { 36 | this.beforeFont = null; 37 | this.afterFont = null; 38 | } 39 | 40 | get beforeCssStyle() { 41 | return document.styleSheets[0].cssRules[0].style; 42 | } 43 | get afterCssStyle() { 44 | return document.styleSheets[0].cssRules[1].style; 45 | } 46 | 47 | setVariationStyle(variations) { 48 | let rule = document.styleSheets[0].cssRules[2].style; 49 | rule.setProperty("font-variation-settings", variations); 50 | } 51 | 52 | dropFile(files, element) { 53 | if (!files[0].name.match(/\.[ot]tf$/i)) { 54 | $(element).shake(); 55 | return; 56 | } 57 | var style; 58 | if (element.id == "fontbefore") { 59 | style = this.beforeCssStyle; 60 | $(element).find("h2").addClass("font-before"); 61 | } else { 62 | style = this.afterCssStyle; 63 | $(element).find("h2").addClass("font-after"); 64 | } 65 | window.thing = files[0]; 66 | $(element).find("h2").text(files[0].name); 67 | style.setProperty("src", "url(" + URL.createObjectURL(files[0]) + ")"); 68 | var reader = new FileReader(); 69 | let that = this; 70 | reader.onload = function (e) { 71 | let u8 = new Uint8Array(this.result); 72 | if (element.id == "fontbefore") { 73 | that.beforeFont = u8; 74 | } else { 75 | that.afterFont = u8; 76 | } 77 | if (that.beforeFont && that.afterFont) { 78 | that.letsDoThis(); 79 | } 80 | }; 81 | reader.readAsArrayBuffer(files[0]); 82 | } 83 | 84 | setVariations() { 85 | let cssSetting = $("#axes input") 86 | .map(function () { 87 | return `"${this.id.replace("axis-", "")}" ${this.value}`; 88 | }) 89 | .get() 90 | .join(", "); 91 | this.setVariationStyle(cssSetting); 92 | this.updateGlyphs(); 93 | } 94 | 95 | setupAxes(message) { 96 | $("#axes").empty(); 97 | console.log(message); 98 | let { axes, instances } = message; 99 | for (var [tag, limits] of Object.entries(axes)) { 100 | console.log(tag, limits); 101 | let [axis_min, axis_def, axis_max] = limits; 102 | let axis = $(`
103 | ${tag} 104 | 105 | `); 106 | $("#axes").append(axis); 107 | axis.on("input", this.setVariations.bind(this)); 108 | axis.on("change", this.updateWords.bind(this)); 109 | } 110 | if (Object.keys(instances).length > 0) { 111 | let select = $(""); 112 | for (var [name, location] of instances) { 113 | console.log(location); 114 | let location_str = Object.entries(location) 115 | .map(([k, v]) => `${k}=${v}`) 116 | .join(","); 117 | let option = $(``); 118 | select.append(option); 119 | } 120 | select.on("change", function () { 121 | let location = $(this).val(); 122 | let parts = location.split(","); 123 | for (let [i, part] of parts.entries()) { 124 | let [tag, value] = part.split("="); 125 | console.log(tag, value); 126 | $(`#axis-${tag}`).val(value); 127 | } 128 | $("#axes input").trigger("input"); 129 | $("#axes input").trigger("change"); 130 | }); 131 | $("#axes").append(select); 132 | } 133 | } 134 | 135 | progress_callback(message) { 136 | console.log("Got json ", message); 137 | if ("type" in message && message.type == "ready") { 138 | $("#bigLoadingModal").hide(); 139 | $("#startModal").show(); 140 | } else if (message.type == "axes") { 141 | this.setupAxes(message); // Contains axes and named instances 142 | } else if (message.type == "tables") { 143 | // console.log("Hiding spinner") 144 | $("#spinnerModal").hide(); 145 | diffTables(message); 146 | } else if (message.type == "kerns") { 147 | // console.log("Hiding spinner") 148 | $("#spinnerModal").hide(); 149 | diffKerns(message); 150 | } else if (message.type == "modified_glyphs") { 151 | $("#spinnerModal").hide(); 152 | let glyph_diff = message.modified_glyphs; 153 | this.renderGlyphDiff(glyph_diff); 154 | $(".node").on("click", function (event) { 155 | $(this).children().toggle(); 156 | event.stopPropagation(); 157 | }); 158 | } else if (message.type == "new_missing_glyphs") { 159 | $("#spinnerModal").hide(); 160 | this.renderCmapDiff(message); 161 | $(".node").on("click", function (event) { 162 | $(this).children().toggle(); 163 | event.stopPropagation(); 164 | }); 165 | } else if (message.type == "words") { 166 | $("#spinnerModal").hide(); 167 | $("#wordspinner").hide(); 168 | let diffs = message.words; 169 | for (var [script, words] of Object.entries(diffs)) { 170 | this.renderWordDiff(script, words); 171 | } 172 | } 173 | } 174 | 175 | variationLocation() { 176 | // Return the current axis location as a string of the form 177 | // tag=value,tag=value 178 | return $("#axes input") 179 | .map(function () { 180 | return `${this.id.replace("axis-", "")}=${this.value}`; 181 | }) 182 | .get() 183 | .join(","); 184 | } 185 | 186 | letsDoThis() { 187 | $("#startModal").hide(); 188 | $("#spinnerModal").show(); 189 | diffWorker.postMessage({ 190 | command: "axes", 191 | beforeFont: this.beforeFont, 192 | afterFont: this.afterFont, 193 | }); 194 | diffWorker.postMessage({ 195 | command: "tables", 196 | beforeFont: this.beforeFont, 197 | afterFont: this.afterFont, 198 | }); 199 | diffWorker.postMessage({ 200 | command: "kerns", 201 | beforeFont: this.beforeFont, 202 | afterFont: this.afterFont, 203 | }); 204 | diffWorker.postMessage({ 205 | command: "new_missing_glyphs", 206 | beforeFont: this.beforeFont, 207 | afterFont: this.afterFont, 208 | }); 209 | this.updateGlyphs(); 210 | this.updateWords(); 211 | } 212 | 213 | updateGlyphs() { 214 | let location = this.variationLocation(); 215 | diffWorker.postMessage({ 216 | command: "modified_glyphs", 217 | beforeFont: this.beforeFont, 218 | afterFont: this.afterFont, 219 | location, 220 | }); 221 | } 222 | 223 | updateWords() { 224 | $("#wordspinner").show(); 225 | $("#worddiffinner").empty(); 226 | let location = this.variationLocation(); 227 | diffWorker.postMessage({ 228 | command: "words", 229 | beforeFont: this.beforeFont, 230 | afterFont: this.afterFont, 231 | location, 232 | }); 233 | } 234 | 235 | renderCmapDiff(glyph_diff) { 236 | $("#cmapdiff").empty(); 237 | cmapDiff(glyph_diff); 238 | $('[data-toggle="tooltip"]').tooltip(); 239 | } 240 | 241 | renderGlyphDiff(glyph_diff) { 242 | $("#glyphdiff").empty(); 243 | if (glyph_diff.length > 0) { 244 | $("#glyphdiff").append($(`

Modified glyphs

`)); 245 | let place = $('
'); 246 | $("#glyphdiff").append(place); 247 | 248 | glyph_diff.forEach((glyph) => { 249 | addAGlyph(glyph, place); 250 | }); 251 | $('[data-toggle="tooltip"]').tooltip(); 252 | } 253 | } 254 | 255 | renderWordDiff(script, diffs) { 256 | $("#worddiffinner").append($(`
${script}
`)); 257 | let place = $('
'); 258 | $("#worddiffinner").append(place); 259 | diffs.forEach((glyph) => { 260 | addAWord(glyph, place); 261 | }); 262 | $('[data-toggle="tooltip"]').tooltip(); 263 | } 264 | } 265 | 266 | $(function () { 267 | window.diffenator = new Diffenator(); 268 | diffWorker.onmessage = (e) => window.diffenator.progress_callback(e.data); 269 | $("#bigLoadingModal").show(); 270 | 271 | $(".fontdrop").on("dragover dragenter", function (e) { 272 | e.preventDefault(); 273 | e.stopPropagation(); 274 | $(this).addClass("dragging"); 275 | }); 276 | $(".fontdrop").on("dragleave dragend", function (e) { 277 | $(this).removeClass("dragging"); 278 | }); 279 | 280 | $(".fontdrop").on("drop", function (e) { 281 | $(this).removeClass("dragging"); 282 | if ( 283 | e.originalEvent.dataTransfer && 284 | e.originalEvent.dataTransfer.files.length 285 | ) { 286 | e.preventDefault(); 287 | e.stopPropagation(); 288 | diffenator.dropFile(e.originalEvent.dataTransfer.files, this); 289 | } 290 | }); 291 | 292 | setupAnimation(); 293 | }); 294 | -------------------------------------------------------------------------------- /diffenator3-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 | "diffenator3": "file:../pkg" 31 | }, 32 | "devDependencies": { 33 | "copy-webpack-plugin": "^5.0.0", 34 | "webpack": "^5", 35 | "webpack-cli": "^6", 36 | "webpack-dev-server": "^5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /diffenator3-web/www/wasm-style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googlefonts/diffenator3/6f87bb0fe6270bc9fc07af2085a0372264391baf/diffenator3-web/www/wasm-style.css -------------------------------------------------------------------------------- /diffenator3-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 | output: { 11 | path: path.resolve(__dirname, "..", "..", "docs"), 12 | filename: "bootstrap.js", 13 | }, 14 | mode: "development", 15 | experiments: { asyncWebAssembly: true }, 16 | plugins: [ 17 | new CopyWebpackPlugin([ 18 | "index.html", 19 | "AND-Regular.ttf", 20 | "../../templates/style.css", 21 | ]), 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /diffenator3-web/www/webworker.js: -------------------------------------------------------------------------------- 1 | var module = import("../pkg/diffenator3_web.js"); 2 | async function init() { 3 | let wasm = await module; 4 | self.postMessage({ type: "ready" }); 5 | // console.log("Got wasm module", wasm); 6 | wasm.debugging(); 7 | self.onmessage = async (event) => { 8 | // console.log("Worker received message"); 9 | // console.log(event); 10 | const { command, beforeFont, location, afterFont } = event.data; 11 | if (command == "axes") { 12 | let obj = JSON.parse(wasm.axes(beforeFont, afterFont)); 13 | obj["type"] = "axes"; 14 | self.postMessage(obj); 15 | } else if (command == "tables") { 16 | wasm.diff_tables(beforeFont, afterFont, (tables) => { 17 | self.postMessage({ 18 | type: "tables", 19 | tables: JSON.parse(tables)["tables"], 20 | }); 21 | }); 22 | } else if (command == "kerns") { 23 | wasm.diff_kerns(beforeFont, afterFont, (kerns) => { 24 | self.postMessage({ 25 | type: "kerns", 26 | kerns: JSON.parse(kerns)["kerns"], 27 | }); 28 | }); 29 | } else if (command == "new_missing_glyphs") { 30 | wasm.new_missing_glyphs(beforeFont, afterFont, (new_missing_glyphs) => { 31 | self.postMessage({ 32 | type: "new_missing_glyphs", 33 | cmap_diff: JSON.parse(new_missing_glyphs)["new_missing_glyphs"], 34 | }); 35 | }); 36 | } else if (command == "modified_glyphs") { 37 | wasm.modified_glyphs(beforeFont, afterFont, location, (glyphs) => { 38 | self.postMessage({ 39 | type: "modified_glyphs", 40 | modified_glyphs: JSON.parse(glyphs)["modified_glyphs"], 41 | }); 42 | }); 43 | } else if (command == "words") { 44 | wasm.diff_words(beforeFont, afterFont, location, (words) => { 45 | self.postMessage({ 46 | type: "words", 47 | words: JSON.parse(words)["words"], 48 | }); 49 | }); 50 | } 51 | }; 52 | return self; 53 | } 54 | 55 | init(); 56 | -------------------------------------------------------------------------------- /docs/d2931c49b29e0e7498c5.module.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googlefonts/diffenator3/6f87bb0fe6270bc9fc07af2085a0372264391baf/docs/d2931c49b29e0e7498c5.module.wasm -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Diffenator3 7 | 8 | 11 | 37 | 39 | 40 | 41 | 42 | 43 | 54 | 55 | 66 | 88 | 89 |
90 | 91 |
92 |
Old
93 |
Animate
94 |
95 |
96 |
97 | 98 |
99 |
100 |
101 |

Modified Words

102 |
103 |
104 | Loading... 105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap'); 2 | 3 | body { 4 | background-color: #b3d4f5 !important; 5 | font-family: "Montserrat", sans-serif !important; 6 | } 7 | 8 | .nav-pills .nav-item.show .nav-link, .nav-pills .nav-link.active { 9 | background-color: #ced9e4 !important; 10 | } 11 | .nav-item { 12 | width: 100%; 13 | margin-bottom: 10px; 14 | padding: 2px; 15 | } 16 | 17 | .nav-pills .nav-link{ 18 | background-color: #9fcaf5 !important; 19 | } 20 | h4 { 21 | color: #6f7882 !important; 22 | } 23 | 24 | @font-face { 25 | font-family: "Adobe NotDef"; 26 | src: url(https://cdn.jsdelivr.net/gh/adobe-fonts/adobe-notdef/AND-Regular.ttf); 27 | } 28 | 29 | .box-title { 30 | width: 100%; 31 | font-size: 8pt; 32 | font-weight: 700; 33 | border-top: 1px solid black; 34 | padding-top: 5px; 35 | margin-bottom: 10pt; 36 | display: block; 37 | } 38 | 39 | .box { 40 | margin-bottom: 20pt; 41 | width: 100%; 42 | float: left; 43 | } 44 | 45 | .box-text {} 46 | 47 | #ui-nav { 48 | position: fixed; 49 | right: 20px; 50 | z-index: 100; 51 | } 52 | 53 | #font-toggle {} 54 | 55 | .ui-nav-item { 56 | display: block; 57 | cursor: pointer; 58 | -webkit-user-select: none; 59 | /* Safari */ 60 | -moz-user-select: none; 61 | /* Firefox */ 62 | -ms-user-select: none; 63 | /* IE10+/Edge */ 64 | user-select: none; 65 | /* Standard */ 66 | text-align: center; 67 | color: white; 68 | background-color: black; 69 | display: block; 70 | font-size: 14pt; 71 | padding: 5px; 72 | margin: 2px; 73 | } 74 | 75 | /* Stuff needed for the table differ */ 76 | body { 77 | font-family: Helvetica; 78 | } 79 | 80 | .node { 81 | font-family: courier; 82 | cursor: pointer; 83 | position: relative; 84 | left: 30px; 85 | padding: 5px; 86 | display: block; 87 | border: 1px dashed grey; 88 | background-color: #e9e9ee; 89 | } 90 | 91 | .header { 92 | font-weight: bold; 93 | } 94 | 95 | .attr-before { 96 | color: green; 97 | cursor: text; 98 | } 99 | 100 | .attr-after { 101 | color: red; 102 | cursor: text; 103 | } 104 | 105 | .leaf { 106 | font-weight: bold; 107 | } 108 | 109 | .old .cell .new { 110 | display: none; 111 | } 112 | 113 | .new .cell .old { 114 | display: none; 115 | } 116 | 117 | .both .cell .old { 118 | opacity: 0.75; 119 | color: red; 120 | } 121 | 122 | .both .cell .new { 123 | opacity: 0.75; 124 | position: absolute; 125 | top: 0; 126 | color: green; 127 | } 128 | 129 | .spacer { 130 | display: block; 131 | float: left; 132 | width: 100%; 133 | } 134 | 135 | #ot-panel { 136 | max-height: 400px; 137 | overflow-y: scroll; 138 | background: white; 139 | padding: 10px; 140 | display: none; 141 | } 142 | 143 | .cell{ 144 | z-index: -100; 145 | float: left; 146 | display: block; 147 | text-align: center; 148 | padding: 5pt; 149 | margin: 5pt; 150 | width: 50pt; 151 | font-size: var(--node-pt-size); 152 | line-height: calc(var(--node-pt-size) * 1.5); 153 | } 154 | .cell-word{ 155 | z-index: -100; 156 | float: left; 157 | display: block; 158 | text-align: center; 159 | padding: 5pt; 160 | margin: 5pt; 161 | font-size: var(--node-pt-size); 162 | line-height: calc(var(--node-pt-size) * 1.5); 163 | box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px; 164 | } 165 | .cell-glyph{ 166 | z-index: -100; 167 | float: left; 168 | display: block; 169 | text-align: center; 170 | border-radius: 5pt; 171 | box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px; 172 | padding: 5pt; 173 | margin: 5pt; 174 | width: calc(var(--node-pt-size) * 2); 175 | min-height: calc(var(--node-pt-size) * 2); 176 | font-size: var(--node-pt-size); 177 | } 178 | .cell-glyph.font-before, .cell-word.font-before { 179 | background: #eff6f2; 180 | } 181 | .cell-glyph.font-after, .cell-word.font-after { 182 | background: #f7f4f4; 183 | } 184 | 185 | .cell-glyph:hover { 186 | box-shadow: none; 187 | background: #f0f0f5; 188 | } 189 | .cat-strings .cell { 190 | clear: both; 191 | position: static; 192 | float: none; 193 | text-align: left; 194 | } 195 | .codepoint { 196 | text-align: center; 197 | font-family: sans-serif; 198 | font-size: 10pt; 199 | line-height: 10pt; 200 | } 201 | .box-title { 202 | clear: both; 203 | } 204 | .node.closed::before { 205 | content: "+"; 206 | margin-right: 5pt; 207 | } 208 | .node.open::before { 209 | content: "-"; 210 | margin-right: 5pt; 211 | } 212 | 213 | .tooltip pre { 214 | background-color: #6f7882; 215 | padding: 5px; 216 | white-space: pre-wrap; 217 | word-break: break-all; 218 | } 219 | .tooltip { 220 | line-height: 1em; 221 | } 222 | .tooltip-inner { 223 | max-width: 300px; 224 | } 225 | 226 | h1,h2,h3,h4,h5 { 227 | clear: both; 228 | } 229 | 230 | #locationnav li { 231 | white-space: wrap; 232 | } 233 | 234 | #userdefined div { width: 100%; } 235 | 236 | /* The following rules are used in the WASM version, not in the CLI version. 237 | But we keep them here so we can share them between the two versions. */ 238 | 239 | .modal-dialog { 240 | max-width: 100% !important; 241 | margin: 0 !important; 242 | top: 0; 243 | bottom: 0; 244 | left: 0; 245 | right: 0; 246 | height: 100vh; 247 | display: flex; 248 | } 249 | 250 | .fontdrop { 251 | padding: 30px; 252 | border-radius: 20px; 253 | outline: 3px dashed #ffffffff; 254 | outline-offset: -20px; 255 | } 256 | 257 | #fontbefore { 258 | background-color: #ddffdddd; 259 | } 260 | 261 | #fontafter { 262 | background-color: #ffdddddd; 263 | } 264 | 265 | .dragging { 266 | background-image: linear-gradient(rgb(0 0 0/5%) 0 0); 267 | outline: 5px dashed #ffffffff; 268 | } 269 | 270 | #diffkerns { 271 | max-height: 500px; 272 | overflow-y: scroll; 273 | } -------------------------------------------------------------------------------- /kerndiffer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kerndiffer" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | read-fonts = { workspace = true } 8 | serde_json = { workspace = true } 9 | clap = { version = "4.5.9", features = ["derive"] } 10 | env_logger = "0.11" 11 | colored = "2.1.0" 12 | ttj = { path = "../ttj" } 13 | -------------------------------------------------------------------------------- /kerndiffer/src/main.rs: -------------------------------------------------------------------------------- 1 | /// Stand-alone application for comparing kerning tables. 2 | /// 3 | /// This is a simple command-line tool that compares the kerning tables of two fonts. 4 | /// Normally you would use [diffenator3] instead, but this tool is useful for 5 | /// focusing on the kerning specifically. 6 | use clap::Parser; 7 | use colored::Colorize; 8 | use env_logger::Env; 9 | use read_fonts::FontRef; 10 | use serde_json::{Map, Value}; 11 | use std::path::PathBuf; 12 | use ttj::jsondiff::Substantial; 13 | use ttj::kern_diff; 14 | 15 | #[derive(Parser, Debug)] 16 | #[command(version, about, long_about = None)] 17 | struct Cli { 18 | /// Maximum number of changes to report before giving up 19 | #[clap(long = "max-changes", default_value = "128", help_heading = Some("Report format"))] 20 | max_changes: usize, 21 | 22 | /// Don't try to match glyph names between fonts 23 | #[clap(long = "no-match", help_heading = Some("Report format"))] 24 | no_match: bool, 25 | 26 | /// The first font file to compare 27 | font1: PathBuf, 28 | /// The second font file to compare 29 | font2: PathBuf, 30 | } 31 | 32 | fn main() { 33 | let cli = Cli::parse(); 34 | env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init(); 35 | 36 | let font_binary_a = std::fs::read(&cli.font1).expect("Couldn't open file"); 37 | let font_binary_b = std::fs::read(&cli.font2).expect("Couldn't open file"); 38 | 39 | let font_a = FontRef::new(&font_binary_a).expect("Couldn't parse font"); 40 | let font_b = FontRef::new(&font_binary_b).expect("Couldn't parse font"); 41 | let diff = kern_diff(&font_a, &font_b, cli.max_changes, cli.no_match); 42 | if let Value::Object(diff) = &diff { 43 | show_map_diff(diff, 0, false); 44 | } else { 45 | println!("No differences found"); 46 | } 47 | } 48 | 49 | pub fn show_map_diff(fields: &Map, indent: usize, succinct: bool) { 50 | for (field, diff) in fields.iter() { 51 | print!("{}", " ".repeat(indent * 2)); 52 | if field == "error" { 53 | println!("{}", diff.as_str().unwrap().red()); 54 | continue; 55 | } 56 | if let Some(lr) = diff.as_array() { 57 | let (left, right) = (&lr[0], &lr[1]); 58 | if succinct && (left.is_something() && !right.is_something()) { 59 | println!( 60 | "{}: {} => {}", 61 | field, 62 | format!("{}", left).green(), 63 | "".red().italic() 64 | ); 65 | } else if succinct && (right.is_something() && !left.is_something()) { 66 | println!( 67 | "{}: {} => {}", 68 | field, 69 | "".green().italic(), 70 | format!("{}", right).red() 71 | ); 72 | } else { 73 | println!( 74 | "{}: {} => {}", 75 | field, 76 | format!("{}", left).green(), 77 | format!("{}", right).red() 78 | ); 79 | } 80 | } else if let Some(fields) = diff.as_object() { 81 | println!("{}:", field); 82 | show_map_diff(fields, indent + 1, succinct) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /rendertest/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rendertest" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | diffenator3-lib = { path = "../diffenator3-lib" } 8 | clap = { version = "4.5.9", features = ["derive"] } 9 | image = { version = "0.24.6", features = ["png"] } 10 | zeno = "0.3.1" 11 | -------------------------------------------------------------------------------- /rendertest/src/main.rs: -------------------------------------------------------------------------------- 1 | /// Debug rendering differences between fonts 2 | use clap::Parser; 3 | use diffenator3_lib::render::renderer::Renderer; 4 | use diffenator3_lib::render::utils::{count_differences, make_same_size}; 5 | use diffenator3_lib::render::{wordlists, DEFAULT_GRAY_FUZZ}; 6 | use diffenator3_lib::setting::{parse_location, Setting}; 7 | use image::{Pixel, Rgba, RgbaImage}; 8 | use zeno::Command; 9 | 10 | #[derive(Parser)] 11 | struct Args { 12 | /// First font file 13 | font1: String, 14 | /// Second font file 15 | font2: String, 16 | 17 | /// Location in user space, in the form axis=123,other=456 (may be repeated) 18 | #[clap(long = "location")] 19 | location: Option, 20 | 21 | /// Text to render 22 | text: String, 23 | /// Font size 24 | #[clap(short, long, default_value = "64.0")] 25 | size: f32, 26 | /// Script 27 | #[clap(short, long, default_value = "Latin")] 28 | script: String, 29 | 30 | /// Verbose debugging 31 | #[clap(short, long)] 32 | verbose: bool, 33 | } 34 | 35 | fn main() { 36 | let args = Args::parse(); 37 | let data_a = std::fs::read(&args.font1).expect("Can't read font file"); 38 | let mut dfont_a = diffenator3_lib::dfont::DFont::new(&data_a); 39 | let data_b = std::fs::read(&args.font2).expect("Can't read font file"); 40 | let mut dfont_b = diffenator3_lib::dfont::DFont::new(&data_b); 41 | 42 | if let Some(location) = args.location { 43 | let loc = parse_location(&location).expect("Couldn't parse location"); 44 | Setting::from_setting(loc) 45 | .set_on_fonts(&mut dfont_a, &mut dfont_b) 46 | .expect("Couldn't set location"); 47 | } 48 | 49 | let direction = wordlists::get_script_direction(&args.script); 50 | let script_tag = wordlists::get_script_tag(&args.script); 51 | 52 | let mut renderer_a = Renderer::new(&dfont_a, args.size, direction, script_tag); 53 | let mut renderer_b = Renderer::new(&dfont_b, args.size, direction, script_tag); 54 | let (serialized_buffer_a, commands) = 55 | renderer_a.string_to_positioned_glyphs(&args.text).unwrap(); 56 | let image_a = renderer_a.render_positioned_glyphs(&commands); 57 | if args.verbose { 58 | println!("Commands A: {}", to_svg(commands)); 59 | } 60 | println!("Buffer A: {}", serialized_buffer_a); 61 | 62 | let (serialized_buffer_b, commands) = 63 | renderer_b.string_to_positioned_glyphs(&args.text).unwrap(); 64 | let image_b = renderer_b.render_positioned_glyphs(&commands); 65 | if args.verbose { 66 | println!("Commands B: {}", to_svg(commands)); 67 | } 68 | 69 | println!("Buffer B: {}", serialized_buffer_b); 70 | 71 | let (mut image_a, mut image_b) = make_same_size(image_a, image_b); 72 | image::imageops::flip_vertical_in_place(&mut image_a); 73 | image::imageops::flip_vertical_in_place(&mut image_b); 74 | 75 | image_a.save("image_a.png").expect("Can't save"); 76 | image_b.save("image_b.png").expect("Can't save"); 77 | 78 | // Make an overlay image 79 | let mut overlay = RgbaImage::new(image_a.width(), image_a.height()); 80 | for (x, y, pixel) in overlay.enumerate_pixels_mut() { 81 | let pixel_a = image_a.get_pixel(x, y); 82 | let pixel_b = image_b.get_pixel(x, y); 83 | let mut a_green = Rgba([0, 255 - pixel_a.0[0], 0, 128]); 84 | let b_red = Rgba([255 - pixel_b.0[0], 0, 0, 128]); 85 | a_green.blend(&b_red); 86 | if pixel_a.0[0].abs_diff(pixel_b.0[0]) > DEFAULT_GRAY_FUZZ { 87 | a_green.blend(&Rgba([255, 255, 255, 90])); 88 | } 89 | *pixel = a_green; 90 | } 91 | overlay.save("overlay.png").expect("Can't save"); 92 | 93 | let differing_pixels = count_differences(image_a, image_b, DEFAULT_GRAY_FUZZ); 94 | println!("Pixel differences: {:.2?}", differing_pixels); 95 | println!("See output images: image_a.png, image_b.png, overlay.png"); 96 | } 97 | 98 | fn to_svg(commands: Vec) -> String { 99 | let mut svg = String::new(); 100 | for command in commands { 101 | match command { 102 | Command::MoveTo(p) => { 103 | svg.push_str(&format!("M {} {} ", p.x, p.y)); 104 | } 105 | Command::LineTo(p) => { 106 | svg.push_str(&format!("L {} {} ", p.x, p.y)); 107 | } 108 | Command::QuadTo(p1, p2) => { 109 | svg.push_str(&format!("Q {} {} {} {} ", p1.x, p1.y, p2.x, p2.y)); 110 | } 111 | Command::CurveTo(p1, p2, p3) => { 112 | svg.push_str(&format!( 113 | "C {} {} {} {} {} {} ", 114 | p1.x, p1.y, p2.x, p2.y, p3.x, p3.y 115 | )); 116 | } 117 | Command::Close => { 118 | svg.push_str("Z "); 119 | } 120 | } 121 | } 122 | svg 123 | } 124 | -------------------------------------------------------------------------------- /templates/diff3proof.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Diffenator3 6 | 10 | 15 | 55 | 61 | 62 | 63 | 64 |
65 | {% if old_filename | replace(from="old-", to="") != new_filename | replace(from="new-", to="") %} 66 |
Old
67 |
Animate
68 | {% endif %} 69 |
70 |
71 | 72 |
73 |
74 | 75 |
76 |
77 | 86 |
87 | {% if report.language_samples %} 88 | {% for script, texts in report.language_samples %} 89 |

{{ script }}

90 |
91 | {% for language_text in texts %} 92 |

{{language_text[0]}}{{language_text[1]}}

93 | {% endfor %} 94 | {% endfor %} 95 | {% endif %} 96 | {% if report.cover_sample %} 97 |
98 |

{{ report.cover_sample }}

99 |
100 | {% endif %} 101 |
102 |
103 |
104 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /templates/diffenator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Diffenator3 7 | 8 | 11 | 40 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
Old
49 |
Animate
50 |
51 |
52 |
53 | 54 |
55 |
56 | 58 |
59 |
60 |
61 |
62 |
63 |
64 |

User defined sample text

65 |
66 | You can type your own text here to see it previewed in the fonts. 67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /templates/script.js: -------------------------------------------------------------------------------- 1 | function buildLocation_statichtml(loc) { 2 | // Set font styles to appropriate axis locations 3 | let rule = document.styleSheets[0].cssRules[2].style; 4 | let cssSetting = ""; 5 | let textLocation = "Default"; 6 | if (loc.coords) { 7 | cssSetting = Object.entries(loc.coords) 8 | .map(function ([axis, value]) { 9 | return `"${axis}" ${value}`; 10 | }) 11 | .join(", "); 12 | textLocation = Object.entries(loc.coords) 13 | .map(function ([axis, value]) { 14 | return `${axis}=${value}`; 15 | }) 16 | .join(" "); 17 | rule.setProperty("font-variation-settings", cssSetting); 18 | } 19 | 20 | $("#main").empty(); 21 | 22 | $("#title").html(`

${textLocation}

`); 23 | 24 | if (loc.glyphs) { 25 | loc.glyphs.sort((ga, gb) => new Intl.Collator().compare(ga.string, gb.string)); 26 | $("#main").append("

Modified Glyphs

"); 27 | let glyphs = $("
"); 28 | for (let glyph of loc.glyphs) { 29 | addAGlyph(glyph, glyphs); 30 | } 31 | $("#main").append(glyphs); 32 | } 33 | 34 | if (loc.words) { 35 | $("#main").append("

Modified Words

"); 36 | for (let [script, words] of Object.entries(loc.words)) { 37 | let scriptTitle = $(`
${script}
`); 38 | $("#main").append(scriptTitle); 39 | let worddiv = $("
"); 40 | for (let word of words) { 41 | addAWord(word, worddiv); 42 | } 43 | $("#main").append(worddiv); 44 | } 45 | } 46 | $('[data-toggle="tooltip"]').tooltip(); 47 | } 48 | 49 | $(function () { 50 | if (report["tables"]) { 51 | diffTables(report); 52 | } 53 | if (report["kerns"]) { 54 | diffKerns(report); 55 | } 56 | cmapDiff(report); 57 | $('[data-toggle="tooltip"]').tooltip(); 58 | if (!report["locations"]) { 59 | $("#title").html("

No differences found

"); 60 | $("#ui-nav").hide(); 61 | return; 62 | } 63 | 64 | for (var [index, loc] of report["locations"].entries()) { 65 | var loc_nav = $(``); 71 | $("#locationnav").append(loc_nav); 72 | } 73 | $("#locationnav li a").on("click", function (e) { 74 | $("#locationnav li a").removeClass("active"); 75 | $(this).addClass("active"); 76 | buildLocation_statichtml(report.locations[$(this).data("index")]); 77 | }); 78 | $("#locationnav li a").eq(0).click(); 79 | 80 | document.styleSheets[0].cssRules[0].style.setProperty( 81 | "src", 82 | "url({{ old_filename }})" 83 | ); 84 | document.styleSheets[0].cssRules[1].style.setProperty( 85 | "src", 86 | "url({{ new_filename }})" 87 | ); 88 | setupAnimation(); 89 | }); 90 | -------------------------------------------------------------------------------- /templates/shared.js: -------------------------------------------------------------------------------- 1 | function renderTableDiff(node, toplevel) { 2 | var wrapper = $("
"); 3 | if (!node) { 4 | return wrapper; 5 | } 6 | if (Array.isArray(node) && node.length == 2) { 7 | var before = $(""); 8 | before.addClass("attr-before"); 9 | before.html(" " + node[0] + " "); 10 | var after = $(""); 11 | after.addClass("attr-after"); 12 | after.append(renderTableDiff(node[1], true).children()); 13 | wrapper.append(before); 14 | wrapper.append(after); 15 | return wrapper; 16 | } 17 | if (node.constructor != Object) { 18 | var thing = $(""); 19 | thing.html(node); 20 | wrapper.append(thing); 21 | return wrapper; 22 | } 23 | for (const [key, value] of Object.entries(node)) { 24 | var display = $("
"); 25 | display.addClass("node"); 26 | if (!toplevel) { 27 | display.hide(); 28 | } 29 | display.append(key); 30 | display.append(renderTableDiff(value, false).children()); 31 | if (display.children(".node").length > 0) { 32 | display.addClass("closed"); 33 | } 34 | wrapper.append(display); 35 | } 36 | return wrapper; 37 | } 38 | 39 | function addAGlyph(glyph, where) { 40 | let title = ""; 41 | if (glyph.name) { 42 | title = "name: " + glyph.name; 43 | } 44 | let cp = 45 | "
U+" + 46 | glyph.string.codePointAt(0).toString(16).padStart(4, "0").toUpperCase(); 47 | where.append(` 48 |
49 |
${glyph.string} 50 |
51 | ${cp} 52 |
53 |
54 | `); 55 | } 56 | 57 | function addAWord(diff, where) { 58 | if (!diff.buffer_b) { 59 | diff.buffer_b = diff.buffer_a; 60 | } 61 | where.append(` 62 |
63 | 64 | ${diff.word} 65 | 66 |
67 | `); 68 | } 69 | 70 | function diffTables(report) { 71 | $("#difftable").empty(); 72 | $("#difftable").append(`

Table-level details

`); 73 | $("#difftable").append( 74 | renderTableDiff({ tables: report["tables"] }, true).children() 75 | ); 76 | $("#difftable .node").on("click", function (e) { 77 | $(this).toggleClass("closed open"); 78 | $(this).children(".node").toggle(); 79 | e.stopPropagation(); 80 | }); 81 | } 82 | function diffKerns(report) { 83 | $("#diffkerns").empty(); 84 | $("#diffkerns").append(`

Modified Kerns

`); 85 | $("#diffkerns").append( 86 | `
PairOldNew
` 87 | ); 88 | for (let [pair, value] of Object.entries(report["kerns"])) { 89 | if (pair == "error") { 90 | $("#diffkerns").append(`

Error: ${value}

`); 91 | continue; 92 | } else { 93 | let row = $(""); 94 | row.append(`${pair}`); 95 | row.append(`${serializeKernBefore(value)}`); 96 | row.append(`${serializeKernAfter(value)}`); 97 | $("#diffkerns table").append(row); 98 | } 99 | } 100 | } 101 | 102 | function serializeKernBefore(kern) { 103 | if (Array.isArray(kern)) { 104 | return serializeKern(kern[0], -1); 105 | } 106 | return serializeKern(kern, 0); 107 | } 108 | 109 | function serializeKernAfter(kern) { 110 | if (Array.isArray(kern)) { 111 | return serializeKern(kern[1], -1); 112 | } 113 | return serializeKern(kern, 1); 114 | } 115 | 116 | function serializeKern(kern, index) { 117 | let string = ""; 118 | if (kern === null || kern === undefined) { 119 | return "(null)"; 120 | } 121 | if (kern.x) { 122 | string += serializeKernValue(kern.x, index); 123 | } else if (kern.y) { 124 | string = "0"; 125 | } 126 | 127 | if (kern.y) { 128 | string += "," + serializeKernValue(kern.y, index); 129 | } 130 | if (!kern.x_placement && !kern.y_placement) { 131 | return string; 132 | } 133 | string += "@"; 134 | if (kern.x_placement) { 135 | string += serializeKernValue(kern.x_placement, index); 136 | } else if (kern.y_placement) { 137 | string += "0"; 138 | } 139 | if (kern.y_placement) { 140 | string += "," + serializeKernValue(kern.y_placement, index); 141 | } 142 | return string; 143 | } 144 | 145 | function serializeKernValue(kern, index) { 146 | if (typeof kern == "number") { 147 | return kern; 148 | } 149 | let string = "("; 150 | let verybig = Object.entries(kern).length > 5; 151 | for (let [key, value] of Object.entries(kern)) { 152 | if (key == "default") { 153 | string += value[index] + " "; 154 | } else { 155 | string += value[index] + "@" + key + " "; 156 | } 157 | if (verybig) { 158 | string += "
"; 159 | } 160 | } 161 | return string.trim() + ")"; 162 | } 163 | 164 | function cmapDiff(report) { 165 | if (report.cmap_diff && (report.cmap_diff.new || report.cmap_diff.missing)) { 166 | $("#cmapdiff").append( 167 | `

Added and Removed Encoded Glyphs

` 168 | ); 169 | if (report["cmap_diff"]["new"]) { 170 | $("#cmapdiff").append(`

Added Glyphs

`); 171 | let added = $("
"); 172 | for (let glyph of report["cmap_diff"]["new"]) { 173 | addAGlyph(glyph, added); 174 | } 175 | $("#cmapdiff").append(added); 176 | } 177 | 178 | if (report["cmap_diff"]["missing"]) { 179 | $("#cmapdiff").append(`

Removed Glyphs

`); 180 | let missing = $("
"); 181 | for (let glyph of report["cmap_diff"]["missing"]) { 182 | addAGlyph(glyph, missing); 183 | } 184 | $("#cmapdiff").append(missing); 185 | } 186 | } else { 187 | $("#cmapdiff").append(`

No changes to encoded glyphs

`); 188 | } 189 | } 190 | 191 | function setupAnimation() { 192 | $("#fonttoggle").click(function () { 193 | if ($(this).text() == "Old") { 194 | $(this).text("New"); 195 | $(".font-before").removeClass("font-before").addClass("font-after"); 196 | } else { 197 | $(this).text("Old"); 198 | $(".font-after").removeClass("font-after").addClass("font-before"); 199 | } 200 | }); 201 | 202 | let animationHandle; 203 | function animate() { 204 | $("#fonttoggle").click(); 205 | animationHandle = setTimeout(animate, 1000); 206 | } 207 | $("#fontanimate").click(function () { 208 | if ($(this).text() == "Animate") { 209 | $(this).text("Stop"); 210 | animate(); 211 | } else { 212 | $(this).text("Animate"); 213 | clearTimeout(animationHandle); 214 | } 215 | }); 216 | } 217 | 218 | export { 219 | renderTableDiff, 220 | addAGlyph, 221 | addAWord, 222 | cmapDiff, 223 | diffTables, 224 | diffKerns, 225 | setupAnimation, 226 | }; 227 | -------------------------------------------------------------------------------- /templates/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap'); 2 | 3 | body { 4 | background-color: #b3d4f5 !important; 5 | font-family: "Montserrat", sans-serif !important; 6 | } 7 | 8 | .nav-pills .nav-item.show .nav-link, .nav-pills .nav-link.active { 9 | background-color: #ced9e4 !important; 10 | } 11 | .nav-item { 12 | width: 100%; 13 | margin-bottom: 10px; 14 | padding: 2px; 15 | } 16 | 17 | .nav-pills .nav-link{ 18 | background-color: #9fcaf5 !important; 19 | } 20 | h4 { 21 | color: #6f7882 !important; 22 | } 23 | 24 | @font-face { 25 | font-family: "Adobe NotDef"; 26 | src: url(https://cdn.jsdelivr.net/gh/adobe-fonts/adobe-notdef/AND-Regular.ttf); 27 | } 28 | 29 | .box-title { 30 | width: 100%; 31 | font-size: 8pt; 32 | font-weight: 700; 33 | border-top: 1px solid black; 34 | padding-top: 5px; 35 | margin-bottom: 10pt; 36 | display: block; 37 | } 38 | 39 | .box { 40 | margin-bottom: 20pt; 41 | width: 100%; 42 | float: left; 43 | } 44 | 45 | .box-text {} 46 | 47 | #ui-nav { 48 | position: fixed; 49 | right: 20px; 50 | z-index: 100; 51 | } 52 | 53 | #font-toggle {} 54 | 55 | .ui-nav-item { 56 | display: block; 57 | cursor: pointer; 58 | -webkit-user-select: none; 59 | /* Safari */ 60 | -moz-user-select: none; 61 | /* Firefox */ 62 | -ms-user-select: none; 63 | /* IE10+/Edge */ 64 | user-select: none; 65 | /* Standard */ 66 | text-align: center; 67 | color: white; 68 | background-color: black; 69 | display: block; 70 | font-size: 14pt; 71 | padding: 5px; 72 | margin: 2px; 73 | } 74 | 75 | /* Stuff needed for the table differ */ 76 | body { 77 | font-family: Helvetica; 78 | } 79 | 80 | .node { 81 | font-family: courier; 82 | cursor: pointer; 83 | position: relative; 84 | left: 30px; 85 | padding: 5px; 86 | display: block; 87 | border: 1px dashed grey; 88 | background-color: #e9e9ee; 89 | } 90 | 91 | .header { 92 | font-weight: bold; 93 | } 94 | 95 | .attr-before { 96 | color: green; 97 | cursor: text; 98 | } 99 | 100 | .attr-after { 101 | color: red; 102 | cursor: text; 103 | } 104 | 105 | .leaf { 106 | font-weight: bold; 107 | } 108 | 109 | .old .cell .new { 110 | display: none; 111 | } 112 | 113 | .new .cell .old { 114 | display: none; 115 | } 116 | 117 | .both .cell .old { 118 | opacity: 0.75; 119 | color: red; 120 | } 121 | 122 | .both .cell .new { 123 | opacity: 0.75; 124 | position: absolute; 125 | top: 0; 126 | color: green; 127 | } 128 | 129 | .spacer { 130 | display: block; 131 | float: left; 132 | width: 100%; 133 | } 134 | 135 | #ot-panel { 136 | max-height: 400px; 137 | overflow-y: scroll; 138 | background: white; 139 | padding: 10px; 140 | display: none; 141 | } 142 | 143 | .cell{ 144 | z-index: -100; 145 | float: left; 146 | display: block; 147 | text-align: center; 148 | padding: 5pt; 149 | margin: 5pt; 150 | width: 50pt; 151 | font-size: var(--node-pt-size); 152 | line-height: calc(var(--node-pt-size) * 1.5); 153 | } 154 | .cell-word{ 155 | z-index: -100; 156 | float: left; 157 | display: block; 158 | text-align: center; 159 | padding: 5pt; 160 | margin: 5pt; 161 | font-size: var(--node-pt-size); 162 | line-height: calc(var(--node-pt-size) * 1.5); 163 | box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px; 164 | } 165 | .cell-glyph{ 166 | z-index: -100; 167 | float: left; 168 | display: block; 169 | text-align: center; 170 | border-radius: 5pt; 171 | box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px; 172 | padding: 5pt; 173 | margin: 5pt; 174 | width: calc(var(--node-pt-size) * 2); 175 | min-height: calc(var(--node-pt-size) * 2); 176 | font-size: var(--node-pt-size); 177 | } 178 | .cell-glyph.font-before, .cell-word.font-before { 179 | background: #eff6f2; 180 | } 181 | .cell-glyph.font-after, .cell-word.font-after { 182 | background: #f7f4f4; 183 | } 184 | 185 | .cell-glyph:hover { 186 | box-shadow: none; 187 | background: #f0f0f5; 188 | } 189 | .cat-strings .cell { 190 | clear: both; 191 | position: static; 192 | float: none; 193 | text-align: left; 194 | } 195 | .codepoint { 196 | text-align: center; 197 | font-family: sans-serif; 198 | font-size: 10pt; 199 | line-height: 10pt; 200 | } 201 | .box-title { 202 | clear: both; 203 | } 204 | .node.closed::before { 205 | content: "+"; 206 | margin-right: 5pt; 207 | } 208 | .node.open::before { 209 | content: "-"; 210 | margin-right: 5pt; 211 | } 212 | 213 | .tooltip pre { 214 | background-color: #6f7882; 215 | padding: 5px; 216 | white-space: pre-wrap; 217 | word-break: break-all; 218 | } 219 | .tooltip { 220 | line-height: 1em; 221 | } 222 | .tooltip-inner { 223 | max-width: 300px; 224 | } 225 | 226 | h1,h2,h3,h4,h5 { 227 | clear: both; 228 | } 229 | 230 | #locationnav li { 231 | white-space: wrap; 232 | } 233 | 234 | #userdefined div { width: 100%; } 235 | 236 | /* The following rules are used in the WASM version, not in the CLI version. 237 | But we keep them here so we can share them between the two versions. */ 238 | 239 | .modal-dialog { 240 | max-width: 100% !important; 241 | margin: 0 !important; 242 | top: 0; 243 | bottom: 0; 244 | left: 0; 245 | right: 0; 246 | height: 100vh; 247 | display: flex; 248 | } 249 | 250 | .fontdrop { 251 | padding: 30px; 252 | border-radius: 20px; 253 | outline: 3px dashed #ffffffff; 254 | outline-offset: -20px; 255 | } 256 | 257 | #fontbefore { 258 | background-color: #ddffdddd; 259 | } 260 | 261 | #fontafter { 262 | background-color: #ffdddddd; 263 | } 264 | 265 | .dragging { 266 | background-image: linear-gradient(rgb(0 0 0/5%) 0 0); 267 | outline: 5px dashed #ffffffff; 268 | } 269 | 270 | #diffkerns { 271 | max-height: 500px; 272 | overflow-y: scroll; 273 | } -------------------------------------------------------------------------------- /ttj/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ttj" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | skrifa = { workspace = true } 8 | read-fonts = { workspace = true } 9 | serde_json = { workspace = true } 10 | clap = "*" 11 | indexmap = { workspace = true } 12 | -------------------------------------------------------------------------------- /ttj/src/bin/ttj.rs: -------------------------------------------------------------------------------- 1 | /// Dump a font file to json - useful for testing 2 | use clap::{Arg, Command}; 3 | use read_fonts::FontRef; 4 | use ttj::font_to_json; 5 | 6 | fn main() { 7 | let matches = Command::new("ttj") 8 | .about("dump a font file to json") 9 | .arg_required_else_help(true) 10 | .arg(Arg::new("font").help("Font file to dump")) 11 | .get_matches(); 12 | 13 | let name = matches.get_one::("font").expect("No font name?"); 14 | let font_binary = std::fs::read(name).expect("Couldn't open file"); 15 | let font = FontRef::new(&font_binary).expect("Can't parse"); 16 | let json = font_to_json(&font, None); 17 | println!("{:}", serde_json::to_string_pretty(&json).unwrap()); 18 | } 19 | -------------------------------------------------------------------------------- /ttj/src/context.rs: -------------------------------------------------------------------------------- 1 | use read_fonts::{types::F2Dot14, ReadError, TableProvider}; 2 | use skrifa::FontRef; 3 | 4 | use crate::monkeypatching::DenormalizeLocation; 5 | 6 | use super::namemap::NameMap; 7 | 8 | pub(crate) struct SerializationContext<'a> { 9 | pub(crate) font: &'a FontRef<'a>, 10 | pub(crate) names: NameMap, 11 | pub(crate) gdef_regions: Vec>, 12 | pub(crate) gdef_locations: Vec, 13 | } 14 | 15 | impl<'a> SerializationContext<'a> { 16 | pub fn new(font: &'a FontRef<'a>, names: NameMap) -> Result { 17 | let (gdef_regions, gdef_locations) = if let Ok(Some(ivs)) = font 18 | .gdef() 19 | .and_then(|gdef| gdef.item_var_store().transpose()) 20 | { 21 | let regions = ivs.variation_region_list()?.variation_regions(); 22 | 23 | // Find all the peaks 24 | let all_tuples: Vec> = regions 25 | .iter() 26 | .flatten() 27 | .map(|r| r.region_axes().iter().map(|x| x.peak_coord()).collect()) 28 | .collect(); 29 | // Let's turn these back to userspace 30 | let locations: Vec = all_tuples 31 | .iter() 32 | .map(|tuple| { 33 | let coords: Vec = tuple.iter().map(|x| x.to_f32()).collect(); 34 | if let Ok(location) = font.denormalize_location(&coords) { 35 | let mut loc_str: Vec = location 36 | .iter() 37 | .map(|setting| { 38 | setting.selector.to_string() + "=" + &setting.value.to_string() 39 | }) 40 | .collect(); 41 | loc_str.sort(); 42 | loc_str.join(",") 43 | } else { 44 | "Unknown".to_string() 45 | } 46 | }) 47 | .collect(); 48 | (all_tuples, locations) 49 | } else { 50 | (Vec::new(), Vec::new()) 51 | }; 52 | 53 | Ok(SerializationContext { 54 | font, 55 | names, 56 | gdef_regions, 57 | gdef_locations, 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ttj/src/gdef.rs: -------------------------------------------------------------------------------- 1 | use crate::layout::variable_scalars::{hashmap_to_value, serialize_all_deltas}; 2 | 3 | use super::context::SerializationContext; 4 | use read_fonts::tables::gdef::{ 5 | AttachList, CaretValue, ClassDef, GlyphClassDef, LigCaretList, MarkGlyphSets, 6 | }; 7 | use read_fonts::tables::gpos::DeviceOrVariationIndex::VariationIndex; 8 | use read_fonts::TableProvider; 9 | use serde_json::{json, Map, Value}; 10 | use skrifa::GlyphId16; 11 | 12 | pub(crate) fn serialize_gdef_table(context: &SerializationContext) -> Value { 13 | let mut map = Map::new(); 14 | if let Ok(gdef) = context.font.gdef() { 15 | if let Some(Ok(classdef)) = gdef.glyph_class_def() { 16 | map.insert( 17 | "glyph_classes".to_string(), 18 | Value::Object(serialize_classdefs(&classdef, context, true)), 19 | ); 20 | } 21 | if let Some(Ok(attachlist)) = gdef.attach_list() { 22 | serialize_attachlist(attachlist, context, &mut map); 23 | } 24 | if let Some(Ok(lig_caret_list)) = gdef.lig_caret_list() { 25 | serialize_ligcarets(lig_caret_list, context, &mut map); 26 | } 27 | if let Some(Ok(classdef)) = gdef.mark_attach_class_def() { 28 | map.insert( 29 | "mark_attach_classes".to_string(), 30 | Value::Object(serialize_classdefs(&classdef, context, false)), 31 | ); 32 | } 33 | 34 | if let Some(Ok(markglyphsets)) = gdef.mark_glyph_sets_def() { 35 | map.insert( 36 | "mark_glyph_sets".to_string(), 37 | Value::Object(serialize_markglyphs(&markglyphsets, context)), 38 | ); 39 | } 40 | } 41 | Value::Object(map) 42 | } 43 | 44 | fn serialize_ligcarets( 45 | lig_caret_list: LigCaretList, 46 | context: &SerializationContext, 47 | map: &mut Map, 48 | ) { 49 | let mut lig_carets = Map::new(); 50 | if let Ok(coverage) = lig_caret_list.coverage() { 51 | for (ligature, gid) in lig_caret_list.lig_glyphs().iter().zip(coverage.iter()) { 52 | if let Ok(ligature) = ligature { 53 | let name = context.names.get(gid); 54 | lig_carets.insert( 55 | name, 56 | Value::Array( 57 | ligature 58 | .caret_values() 59 | .iter() 60 | .flatten() 61 | .map(|x| match x { 62 | CaretValue::Format1(c) => { 63 | json!({"coordinate": c.coordinate() }) 64 | } 65 | CaretValue::Format2(c) => { 66 | json!({"point_index": c.caret_value_point_index() }) 67 | } 68 | CaretValue::Format3(c) => { 69 | if let Ok(VariationIndex(device)) = c.device() { 70 | json!({"coordinate": serialize_all_deltas(device, context, c.coordinate().into()) 71 | .map(hashmap_to_value) 72 | .unwrap_or_else(|_| Value::String(c.coordinate().to_string())) 73 | }) 74 | } else { 75 | json!({"variable_coordinate": c.coordinate() }) 76 | } 77 | } 78 | }) 79 | .collect::>(), 80 | ), 81 | ); 82 | } 83 | } 84 | } 85 | map.insert("lig_carets".to_string(), Value::Object(lig_carets)); 86 | } 87 | 88 | fn serialize_attachlist( 89 | attachlist: AttachList, 90 | context: &SerializationContext, 91 | map: &mut Map, 92 | ) { 93 | let mut attachments = Map::new(); 94 | if let Ok(coverage) = attachlist.coverage() { 95 | for (point, gid) in attachlist.attach_points().iter().zip(coverage.iter()) { 96 | if let Ok(point) = point { 97 | let name = context.names.get(gid); 98 | attachments.insert( 99 | name, 100 | Value::Array( 101 | point 102 | .point_indices() 103 | .iter() 104 | .map(|x| Value::Number(x.get().into())) 105 | .collect::>(), 106 | ), 107 | ); 108 | } 109 | } 110 | } 111 | map.insert("attach_points".to_string(), Value::Object(attachments)); 112 | } 113 | 114 | fn serialize_classdefs( 115 | classdef: &ClassDef<'_>, 116 | context: &SerializationContext, 117 | use_enum: bool, 118 | ) -> Map { 119 | let mut glyph_classes = Map::new(); 120 | for gid in 0..context.names.len() { 121 | let name = context.names.get(gid as u32); 122 | let class = classdef.get(GlyphId16::new(gid as u16)); 123 | if class == 0 { 124 | continue; 125 | } 126 | glyph_classes.insert( 127 | name, 128 | if use_enum { 129 | serde_json::value::to_value(GlyphClassDef::new(class)).unwrap_or_default() 130 | } else { 131 | Value::Number(class.into()) 132 | }, 133 | ); 134 | } 135 | glyph_classes 136 | } 137 | 138 | fn serialize_markglyphs( 139 | markglyphsets: &MarkGlyphSets<'_>, 140 | context: &SerializationContext, 141 | ) -> Map { 142 | markglyphsets 143 | .coverages() 144 | .iter() 145 | .enumerate() 146 | .map(|(index, coverage)| { 147 | ( 148 | format!("{}", index), 149 | if let Ok(coverage) = coverage { 150 | let glyphnames = coverage 151 | .iter() 152 | .map(|gid| context.names.get(gid)) 153 | .collect::>(); 154 | Value::Array(glyphnames.into_iter().map(Value::String).collect()) 155 | } else { 156 | Value::Null 157 | }, 158 | ) 159 | }) 160 | .collect() 161 | } 162 | -------------------------------------------------------------------------------- /ttj/src/jsondiff.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexSet; 2 | use serde_json::{json, Map, Value}; 3 | 4 | pub trait Substantial { 5 | fn is_something(&self) -> bool; 6 | } 7 | impl Substantial for Value { 8 | fn is_something(&self) -> bool { 9 | match self { 10 | Value::Null => false, 11 | Value::Bool(x) => *x, 12 | Value::Number(x) => x.as_f64().unwrap_or(0.0).abs() > f64::EPSILON, 13 | Value::String(x) => !x.is_empty(), 14 | Value::Array(x) => !x.is_empty(), 15 | Value::Object(x) => !x.is_empty(), 16 | } 17 | } 18 | } 19 | 20 | pub fn diff(this: &Value, other: &Value, max_changes: usize) -> Value { 21 | match (this, other) { 22 | (Value::Null, Value::Null) => Value::Null, 23 | (Value::Number(l), Value::Number(r)) => { 24 | if l == r { 25 | Value::Null 26 | } else { 27 | Value::Array(vec![this.clone(), other.clone()]) 28 | } 29 | } 30 | (Value::Bool(l), Value::Bool(r)) => { 31 | if l == r { 32 | Value::Null 33 | } else { 34 | Value::Array(vec![this.clone(), other.clone()]) 35 | } 36 | } 37 | (Value::String(l), Value::String(r)) => { 38 | if l == r { 39 | Value::Null 40 | } else { 41 | Value::Array(vec![this.clone(), other.clone()]) 42 | } 43 | } 44 | (Value::Array(l), Value::Array(r)) => { 45 | let mut res = Map::new(); 46 | for i in 0..(l.len().max(r.len())) { 47 | let difference = diff( 48 | l.get(i).unwrap_or(&Value::Null), 49 | r.get(i).unwrap_or(&Value::Null), 50 | max_changes, 51 | ); 52 | if difference.is_something() { 53 | res.insert(i.to_string(), difference); 54 | } 55 | } 56 | if res.len() > max_changes && max_changes > 0 { 57 | json!({ "error": format!("There are {} changes, check manually!", res.len()) }) 58 | } else { 59 | Value::Object(res) 60 | } 61 | } 62 | (Value::Object(l), Value::Object(r)) => { 63 | let mut res = Map::new(); 64 | let mut all_keys = IndexSet::new(); 65 | all_keys.extend(l.keys()); 66 | all_keys.extend(r.keys()); 67 | for key in all_keys { 68 | let difference = diff( 69 | l.get(key).unwrap_or(&Value::Null), 70 | r.get(key).unwrap_or(&Value::Null), 71 | max_changes, 72 | ); 73 | if difference.is_something() { 74 | res.insert(key.to_string(), difference); 75 | } 76 | } 77 | if res.is_empty() { 78 | Value::Null 79 | } else if res.len() > max_changes && max_changes > 0 { 80 | json!({ "error": format!("There are {} changes, check manually!", res.len()) }) 81 | } else { 82 | Value::Object(res) 83 | } 84 | } 85 | (_, _) => Value::Array(vec![this.clone(), other.clone()]), 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ttj/src/layout/gsub.rs: -------------------------------------------------------------------------------- 1 | use super::SerializeSubtable; 2 | use crate::context::SerializationContext; 3 | use read_fonts::tables::gsub::AlternateSubstFormat1; 4 | use read_fonts::tables::gsub::LigatureSubstFormat1; 5 | use read_fonts::tables::gsub::MultipleSubstFormat1; 6 | use read_fonts::tables::gsub::ReverseChainSingleSubstFormat1; 7 | use read_fonts::tables::gsub::SingleSubst; 8 | use read_fonts::tables::varc::CoverageTable; 9 | use read_fonts::ReadError; 10 | use serde_json::Map; 11 | use serde_json::Value; 12 | use skrifa::GlyphId16; 13 | 14 | impl SerializeSubtable for SingleSubst<'_> { 15 | fn serialize_subtable(&self, context: &SerializationContext) -> Result { 16 | let mut map = Map::new(); 17 | map.insert("type".to_string(), "single".into()); 18 | let coverage = match self { 19 | SingleSubst::Format1(s) => s.coverage()?, 20 | SingleSubst::Format2(s) => s.coverage()?, 21 | }; 22 | match self { 23 | SingleSubst::Format1(s) => { 24 | let delta = s.delta_glyph_id(); 25 | for glyph in coverage.iter() { 26 | let name_before = context.names.get(glyph); 27 | let name_after = context 28 | .names 29 | .get(GlyphId16::new((glyph.to_u16() as i16 + delta) as u16)); // Good heavens 30 | map.insert(name_before, Value::String(name_after)); 31 | } 32 | } 33 | SingleSubst::Format2(s) => { 34 | for (before, after) in coverage.iter().zip(s.substitute_glyph_ids()) { 35 | let name_before = context.names.get(before); 36 | let name_after = context.names.get(after.get()); 37 | map.insert(name_before, Value::String(name_after)); 38 | } 39 | } 40 | } 41 | Ok(Value::Object(map)) 42 | } 43 | } 44 | 45 | impl SerializeSubtable for MultipleSubstFormat1<'_> { 46 | fn serialize_subtable(&self, context: &SerializationContext) -> Result { 47 | let mut map = Map::new(); 48 | map.insert("type".to_string(), "multiple".into()); 49 | let coverage = self.coverage()?; 50 | for (before, after) in coverage.iter().zip(self.sequences().iter().flatten()) { 51 | let name_before = context.names.get(before); 52 | let names_after = after 53 | .substitute_glyph_ids() 54 | .iter() 55 | .map(|gid| Value::String(context.names.get(gid.get()))); 56 | map.insert(name_before, Value::Array(names_after.collect())); 57 | } 58 | Ok(Value::Object(map)) 59 | } 60 | } 61 | 62 | impl SerializeSubtable for AlternateSubstFormat1<'_> { 63 | fn serialize_subtable(&self, context: &SerializationContext) -> Result { 64 | let mut map = Map::new(); 65 | map.insert("type".to_string(), "alternate".into()); 66 | let coverage = self.coverage()?; 67 | for (before, after) in coverage.iter().zip(self.alternate_sets().iter().flatten()) { 68 | let name_before = context.names.get(before); 69 | let names_after = after 70 | .alternate_glyph_ids() 71 | .iter() 72 | .map(|gid| Value::String(context.names.get(gid.get()))); 73 | map.insert(name_before, Value::Array(names_after.collect())); 74 | } 75 | Ok(Value::Object(map)) 76 | } 77 | } 78 | 79 | impl SerializeSubtable for LigatureSubstFormat1<'_> { 80 | fn serialize_subtable(&self, context: &SerializationContext) -> Result { 81 | let mut map = Map::new(); 82 | map.insert("type".to_string(), "ligature".into()); 83 | let coverage = self.coverage()?; 84 | for (first_glyph, ligset) in coverage.iter().zip(self.ligature_sets().iter().flatten()) { 85 | let first_glyph_name = context.names.get(first_glyph); 86 | for ligature in ligset.ligatures().iter().flatten() { 87 | let mut before = vec![first_glyph_name.clone()]; 88 | 89 | before.extend( 90 | ligature 91 | .component_glyph_ids() 92 | .iter() 93 | .map(|gid| context.names.get(gid.get())), 94 | ); 95 | let before_sequence = before.join(" "); 96 | 97 | map.insert( 98 | before_sequence, 99 | Value::String(context.names.get(ligature.ligature_glyph())), 100 | ); 101 | } 102 | } 103 | Ok(Value::Object(map)) 104 | } 105 | } 106 | 107 | impl SerializeSubtable for ReverseChainSingleSubstFormat1<'_> { 108 | fn serialize_subtable(&self, context: &SerializationContext) -> Result { 109 | let mut map = Map::new(); 110 | map.insert("type".to_string(), "reverse".into()); 111 | let coverage_to_array = |coverage: CoverageTable<'_>| { 112 | Value::Array( 113 | coverage 114 | .iter() 115 | .map(|gid| Value::String(context.names.get(gid))) 116 | .collect::>(), 117 | ) 118 | }; 119 | let mut backtrack = self 120 | .backtrack_coverages() 121 | .iter() 122 | .flatten() 123 | .map(coverage_to_array) 124 | .collect::>(); 125 | backtrack.reverse(); 126 | map.insert("pre_context".to_string(), backtrack.into()); 127 | let lookahead = self 128 | .lookahead_coverages() 129 | .iter() 130 | .flatten() 131 | .map(coverage_to_array) 132 | .collect::>(); 133 | map.insert("post_context".to_string(), lookahead.into()); 134 | let coverage = self.coverage()?; 135 | for (before, after) in coverage.iter().zip(self.substitute_glyph_ids().iter()) { 136 | let name_before = context.names.get(before); 137 | let name_after = context.names.get(after.get()); 138 | map.insert(name_before, Value::String(name_after)); 139 | } 140 | Ok(Value::Object(map)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /ttj/src/layout/variable_scalars.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::context::SerializationContext; 4 | use read_fonts::tables::gpos::DeviceOrVariationIndex::VariationIndex; 5 | use read_fonts::tables::variations::DeltaSetIndex; 6 | use read_fonts::FontData; 7 | use read_fonts::ReadError; 8 | use read_fonts::TableProvider; 9 | use serde_json::Map; 10 | use serde_json::Value; 11 | 12 | pub(crate) trait SerializeValueRecordLike { 13 | fn serialize( 14 | &self, 15 | offset_data: FontData<'_>, 16 | context: &SerializationContext, 17 | ) -> Result; 18 | } 19 | 20 | pub(crate) fn hashmap_to_value(hashmap: HashMap) -> Value { 21 | let delta_map: Map = hashmap 22 | .iter() 23 | .map(|(k, v)| (k.clone(), Value::Number((*v).into()))) 24 | .collect(); 25 | Value::Object(delta_map) 26 | } 27 | impl SerializeValueRecordLike for read_fonts::tables::gpos::ValueRecord { 28 | fn serialize( 29 | &self, 30 | offset_data: FontData<'_>, 31 | context: &SerializationContext, 32 | ) -> Result { 33 | let mut vr = Map::new(); 34 | if let Some(x) = self.x_advance() { 35 | if let Some(Ok(VariationIndex(device))) = self.x_advance_device(offset_data) { 36 | vr.insert( 37 | "x".to_string(), 38 | hashmap_to_value(serialize_all_deltas(device, context, x.into())?), 39 | ); 40 | } else { 41 | vr.insert("x".to_string(), Value::Number(x.into())); 42 | } 43 | } 44 | 45 | if let Some(y) = self.y_advance() { 46 | if let Some(Ok(VariationIndex(device))) = self.x_advance_device(offset_data) { 47 | vr.insert( 48 | "y".to_string(), 49 | hashmap_to_value(serialize_all_deltas(device, context, y.into())?), 50 | ); 51 | } else { 52 | vr.insert("y".to_string(), Value::Number(y.into())); 53 | } 54 | } 55 | 56 | if let Some(x) = self.x_placement() { 57 | if let Some(Ok(VariationIndex(device))) = self.x_placement_device(offset_data) { 58 | vr.insert( 59 | "x_placement".to_string(), 60 | hashmap_to_value(serialize_all_deltas(device, context, x.into())?), 61 | ); 62 | } else { 63 | vr.insert("x_placement".to_string(), Value::Number(x.into())); 64 | } 65 | } 66 | 67 | if let Some(y) = self.y_placement() { 68 | if let Some(Ok(VariationIndex(device))) = self.y_placement_device(offset_data) { 69 | vr.insert( 70 | "y_placement".to_string(), 71 | hashmap_to_value(serialize_all_deltas(device, context, y.into())?), 72 | ); 73 | } else { 74 | vr.insert("y_placement".to_string(), Value::Number(y.into())); 75 | } 76 | } 77 | 78 | Ok(Value::Object(vr)) 79 | } 80 | } 81 | 82 | impl SerializeValueRecordLike for read_fonts::tables::gpos::AnchorTable<'_> { 83 | fn serialize( 84 | &self, 85 | _offset_data: FontData<'_>, 86 | context: &SerializationContext, 87 | ) -> Result { 88 | let mut vr = Map::new(); 89 | let x = self.x_coordinate(); 90 | if let Some(Ok(VariationIndex(device))) = self.x_device() { 91 | vr.insert( 92 | "x".to_string(), 93 | hashmap_to_value(serialize_all_deltas(device, context, x.into())?), 94 | ); 95 | } else { 96 | vr.insert("x".to_string(), Value::Number(x.into())); 97 | } 98 | let y = self.y_coordinate(); 99 | if let Some(Ok(VariationIndex(device))) = self.y_device() { 100 | vr.insert( 101 | "y".to_string(), 102 | hashmap_to_value(serialize_all_deltas(device, context, y.into())?), 103 | ); 104 | } else { 105 | vr.insert("y".to_string(), Value::Number(y.into())); 106 | } 107 | 108 | Ok(Value::Object(vr)) 109 | } 110 | } 111 | 112 | pub(crate) fn serialize_all_deltas( 113 | device: read_fonts::tables::layout::VariationIndex, 114 | context: &SerializationContext, 115 | current: i32, 116 | ) -> Result, ReadError> { 117 | let d: DeltaSetIndex = device.into(); 118 | let mut result = HashMap::new(); 119 | result.insert("default".to_string(), current); 120 | if let Some(Ok(ivs)) = context.font.gdef()?.item_var_store() { 121 | let deltas: Vec = context 122 | .gdef_regions 123 | .iter() 124 | .map(|coords| ivs.compute_delta(d, coords).unwrap_or(0)) 125 | .collect(); 126 | // println!("Deltas: {:?}", deltas); 127 | 128 | for (location, delta) in context.gdef_locations.iter().zip(deltas.iter()) { 129 | if *delta == 0 { 130 | continue; 131 | } 132 | result.insert(location.clone(), current + delta); 133 | } 134 | } 135 | Ok(result) 136 | } 137 | -------------------------------------------------------------------------------- /ttj/src/lib.rs: -------------------------------------------------------------------------------- 1 | /// Convert a font to a serialized JSON representation 2 | pub mod context; 3 | mod gdef; 4 | pub mod jsondiff; 5 | mod layout; 6 | pub mod monkeypatching; 7 | pub mod namemap; 8 | mod serializefont; 9 | 10 | use crate::jsondiff::diff; 11 | use crate::serializefont::ToValue; 12 | use context::SerializationContext; 13 | use namemap::NameMap; 14 | use read_fonts::traversal::SomeTable; 15 | use read_fonts::{FontRef, TableProvider}; 16 | use serde_json::{Map, Value}; 17 | use skrifa::charmap::Charmap; 18 | use skrifa::string::StringId; 19 | use skrifa::MetadataProvider; 20 | 21 | pub use layout::gpos::just_kerns; 22 | 23 | fn serialize_name_table<'a>(font: &(impl MetadataProvider<'a> + TableProvider<'a>)) -> Value { 24 | let mut map = Map::new(); 25 | if let Ok(name) = font.name() { 26 | let mut ids: Vec = name.name_record().iter().map(|x| x.name_id()).collect(); 27 | ids.sort_by_key(|id| id.to_u16()); 28 | for id in ids { 29 | let strings = font.localized_strings(id); 30 | if strings.clone().next().is_some() { 31 | let mut localized = Map::new(); 32 | for string in font.localized_strings(id) { 33 | localized.insert( 34 | string.language().unwrap_or("default").to_string(), 35 | Value::String(string.to_string()), 36 | ); 37 | } 38 | map.insert(id.to_string(), Value::Object(localized)); 39 | } 40 | } 41 | } 42 | Value::Object(map) 43 | } 44 | 45 | fn serialize_cmap_table<'a>(font: &impl TableProvider<'a>, names: &NameMap) -> Value { 46 | let charmap = Charmap::new(font); 47 | let mut map: Map = Map::new(); 48 | for (codepoint, gid) in charmap.mappings() { 49 | let name = names.get(gid); 50 | map.insert(format!("U+{:04X}", codepoint), Value::String(name)); 51 | } 52 | Value::Object(map) 53 | } 54 | 55 | fn serialize_hmtx_table<'a>(font: &impl TableProvider<'a>, names: &NameMap) -> Value { 56 | let mut map = Map::new(); 57 | if let Ok(hmtx) = font.hmtx() { 58 | let widths = hmtx.h_metrics(); 59 | let long_metrics = widths.len(); 60 | for gid in 0..font.maxp().unwrap().num_glyphs() { 61 | let name = names.get(gid); 62 | if gid < (long_metrics as u16) { 63 | if let Some((width, lsb)) = widths 64 | .get(gid as usize) 65 | .map(|lm| (lm.advance(), lm.side_bearing())) 66 | { 67 | map.insert( 68 | name, 69 | Value::Object( 70 | vec![ 71 | ("width".to_string(), Value::Number(width.into())), 72 | ("lsb".to_string(), Value::Number(lsb.into())), 73 | ] 74 | .into_iter() 75 | .collect(), 76 | ), 77 | ); 78 | } 79 | } else { 80 | // XXX 81 | } 82 | } 83 | } 84 | Value::Object(map) 85 | } 86 | 87 | /// Convert a font to a serialized JSON representation 88 | /// 89 | /// This function is used to serialize a font to a JSON representation which can be compared with 90 | /// another font. The JSON representation is a map of tables, where each table is represented as a 91 | /// map of fields and values. The user of this function can also provide a glyph map, which is a 92 | /// mapping from glyph IDs to glyph names. If the glyph map is not provided, the function will 93 | /// attempt to create one from the font itself. (You may want to specify a glyph map from another 94 | /// font to remove false positive differences if you are comparing two fonts which have the same glyph 95 | /// order but the glyph names have changed, e.g. when development names have changed to production names.) 96 | pub fn font_to_json(font: &FontRef, glyphmap: Option<&NameMap>) -> Value { 97 | let glyphmap = if let Some(glyphmap) = glyphmap { 98 | glyphmap 99 | } else { 100 | &NameMap::new(font) 101 | }; 102 | let mut map = Map::new(); 103 | // A serialization context bundles up all the information we need to serialize a font 104 | let context = SerializationContext::new(font, glyphmap.clone()).unwrap_or_else(|_| { 105 | panic!("Could not create serialization context for font"); 106 | }); 107 | 108 | // Some tables are serialized by using read_font's traversal feature; typically those which 109 | // are just a set of fields and values (or are so complicated we haven't yet been bothered 110 | // to write our own serializers for them...) 111 | for table in font.table_directory.table_records().iter() { 112 | let key = table.tag().to_string(); 113 | let value = match table.tag().into_bytes().as_ref() { 114 | b"head" => font.head().map(|t| ::serialize(&t)), 115 | b"hhea" => font.hhea().map(|t| ::serialize(&t)), 116 | b"vhea" => font.vhea().map(|t| ::serialize(&t)), 117 | b"vmtx" => font.vmtx().map(|t| ::serialize(&t)), 118 | b"fvar" => font.fvar().map(|t| ::serialize(&t)), 119 | b"avar" => font.avar().map(|t| ::serialize(&t)), 120 | b"HVAR" => font.hvar().map(|t| ::serialize(&t)), 121 | b"VVAR" => font.vvar().map(|t| ::serialize(&t)), 122 | b"MVAR" => font.mvar().map(|t| ::serialize(&t)), 123 | b"maxp" => font.maxp().map(|t| ::serialize(&t)), 124 | b"OS/2" => font.os2().map(|t| ::serialize(&t)), 125 | b"post" => font.post().map(|t| ::serialize(&t)), 126 | b"loca" => font.loca(None).map(|t| ::serialize(&t)), 127 | b"glyf" => font.glyf().map(|t| ::serialize(&t)), 128 | b"gvar" => font.gvar().map(|t| ::serialize(&t)), 129 | b"COLR" => font.colr().map(|t| ::serialize(&t)), 130 | b"CPAL" => font.cpal().map(|t| ::serialize(&t)), 131 | b"STAT" => font.stat().map(|t| ::serialize(&t)), 132 | _ => font.expect_data_for_tag(table.tag()).map(|tabledata| { 133 | Value::Array( 134 | tabledata 135 | .as_ref() 136 | .iter() 137 | .map(|&x| Value::Number(x.into())) 138 | .collect(), 139 | ) 140 | }), 141 | }; 142 | map.insert( 143 | key, 144 | value.unwrap_or_else(|_| Value::String("Could not parse".to_string())), 145 | ); 146 | } 147 | 148 | // Other tables require a bit of massaging to produce information which makes sense to diff. 149 | map.insert("name".to_string(), serialize_name_table(font)); 150 | map.insert("cmap".to_string(), serialize_cmap_table(font, glyphmap)); 151 | map.insert("hmtx".to_string(), serialize_hmtx_table(font, glyphmap)); 152 | map.insert("GDEF".to_string(), gdef::serialize_gdef_table(&context)); 153 | map.insert("GPOS".to_string(), layout::serialize_gpos_table(&context)); 154 | map.insert("GSUB".to_string(), layout::serialize_gsub_table(&context)); 155 | Value::Object(map) 156 | } 157 | 158 | /// Compare two fonts and return a JSON representation of the differences 159 | /// 160 | /// This function compares two fonts and returns a JSON representation of the differences between 161 | /// them. 162 | /// 163 | /// Arguments: 164 | /// 165 | /// * `font_a` - The first font to compare 166 | /// * `font_b` - The second font to compare 167 | /// * `max_changes` - The maximum number of changes to report before giving up 168 | /// * `no_match` - Don't try to match glyph names between fonts 169 | pub fn table_diff(font_a: &FontRef, font_b: &FontRef, max_changes: usize, no_match: bool) -> Value { 170 | let glyphmap_a = NameMap::new(font_a); 171 | let glyphmap_b = NameMap::new(font_b); 172 | let big_difference = !no_match && !glyphmap_a.compatible(&glyphmap_b); 173 | 174 | #[cfg(not(target_family = "wasm"))] 175 | if big_difference { 176 | println!("Glyph names differ dramatically between fonts, using font names from font A"); 177 | } 178 | 179 | diff( 180 | &font_to_json(font_a, Some(&glyphmap_a)), 181 | &font_to_json( 182 | font_b, 183 | Some(if big_difference { 184 | &glyphmap_a 185 | } else { 186 | &glyphmap_b 187 | }), 188 | ), 189 | max_changes, 190 | ) 191 | } 192 | 193 | /// Compare two fonts and return a JSON representation of the differences in kerning 194 | /// 195 | /// Arguments: 196 | /// 197 | /// * `font_a` - The first font to compare 198 | /// * `font_b` - The second font to compare 199 | /// * `max_changes` - The maximum number of changes to report before giving up 200 | /// * `no_match` - Don't try to match glyph names between fonts 201 | pub fn kern_diff(font_a: &FontRef, font_b: &FontRef, max_changes: usize, no_match: bool) -> Value { 202 | let glyphmap_a = NameMap::new(font_a); 203 | let glyphmap_b = NameMap::new(font_b); 204 | let big_difference = !no_match && !glyphmap_a.compatible(&glyphmap_b); 205 | 206 | #[cfg(not(target_family = "wasm"))] 207 | if big_difference { 208 | println!("Glyph names differ dramatically between fonts, using font names from font A"); 209 | } 210 | 211 | let kerns_a = just_kerns(font_to_json(font_a, None)); 212 | // println!("Font A flat kerning: {:#?}", kerns_a); 213 | let kerns_b = just_kerns(font_to_json( 214 | font_b, 215 | Some(if big_difference { 216 | &glyphmap_a 217 | } else { 218 | &glyphmap_b 219 | }), 220 | )); 221 | // println!("Font B flat kerning: {:#?}", kerns_b); 222 | 223 | diff(&kerns_a, &kerns_b, max_changes) 224 | } 225 | -------------------------------------------------------------------------------- /ttj/src/monkeypatching.rs: -------------------------------------------------------------------------------- 1 | /// Methods which other people's structs really should have but sadly don't. 2 | use std::collections::HashSet; 3 | 4 | use read_fonts::{ 5 | tables::{fvar::VariationAxisRecord, gsub::ClassDef, varc::CoverageTable}, 6 | ReadError, TableProvider, 7 | }; 8 | use skrifa::{setting::VariationSetting, FontRef, GlyphId16}; 9 | 10 | fn lerp(a: f32, b: f32, t: f32) -> f32 { 11 | a + (b - a) * t 12 | } 13 | fn poor_mans_denormalize(peak: f32, axis: &VariationAxisRecord) -> f32 { 14 | // Insert avar here 15 | if peak > 0.0 { 16 | lerp( 17 | axis.default_value().to_f32(), 18 | axis.max_value().to_f32(), 19 | peak, 20 | ) 21 | } else { 22 | lerp( 23 | axis.default_value().to_f32(), 24 | axis.min_value().to_f32(), 25 | -peak, 26 | ) 27 | } 28 | } 29 | 30 | pub trait DenormalizeLocation { 31 | /// Given a normalized location tuple, turn it back into a friendly representation in userspace 32 | fn denormalize_location(&self, tuple: &[f32]) -> Result, ReadError>; 33 | } 34 | 35 | impl DenormalizeLocation for FontRef<'_> { 36 | fn denormalize_location(&self, tuple: &[f32]) -> Result, ReadError> { 37 | let all_axes = self.fvar()?.axes()?; 38 | Ok(all_axes 39 | .iter() 40 | .zip(tuple) 41 | .filter(|&(_axis, peak)| *peak != 0.0) 42 | .map(|(axis, peak)| { 43 | let value = poor_mans_denormalize(*peak, axis); 44 | (axis.axis_tag().to_string().as_str(), value).into() 45 | }) 46 | .collect()) 47 | } 48 | } 49 | 50 | pub trait MonkeyPatchClassDef { 51 | /// Return a list of glyphs in this class 52 | fn class_glyphs(&self, class: u16, coverage: Option) -> Vec; 53 | } 54 | 55 | impl MonkeyPatchClassDef for ClassDef<'_> { 56 | fn class_glyphs(&self, class: u16, coverage: Option) -> Vec { 57 | if class == 0 { 58 | // let coverage_map = coverage.unwrap().coverage_map(); 59 | if let Some(coverage) = coverage { 60 | let all_glyphs: HashSet = coverage.iter().collect(); 61 | let in_a_class: HashSet = 62 | self.iter().map(|(gid, _a_class)| gid).collect(); 63 | // Remove all the glyphs in assigned class 64 | all_glyphs.difference(&in_a_class).copied().collect() 65 | } else { 66 | panic!("ClassDef has no coverage table and class=0 was requested"); 67 | } 68 | } else { 69 | self.iter() 70 | .filter(move |&(_gid, their_class)| their_class == class) 71 | .map(|(gid, _)| gid) 72 | .collect() 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ttj/src/namemap.rs: -------------------------------------------------------------------------------- 1 | use read_fonts::TableProvider; 2 | use skrifa::{FontRef, GlyphId, GlyphId16}; 3 | 4 | fn gid_to_name<'a>(font: &impl TableProvider<'a>, gid: GlyphId) -> String { 5 | if let Ok(gid16) = TryInto::::try_into(gid) { 6 | if let Ok(Some(name)) = font 7 | .post() 8 | .map(|post| post.glyph_name(gid16).map(|x| x.to_string())) 9 | { 10 | return name; 11 | } 12 | } 13 | format!("gid{:}", gid) 14 | } 15 | 16 | /// A map from glyph IDs to glyph names 17 | #[derive(Debug, Clone)] 18 | pub struct NameMap(Vec); 19 | 20 | impl NameMap { 21 | /// Generate a new NameMap from a font 22 | pub fn new(font: &FontRef) -> Self { 23 | let num_glyphs = font.maxp().unwrap().num_glyphs(); 24 | let mut mapping = Vec::with_capacity(num_glyphs as usize); 25 | for gid in 0..num_glyphs { 26 | mapping.push(gid_to_name(font, GlyphId::new(gid as u32))); 27 | } 28 | Self(mapping) 29 | } 30 | 31 | /// Get the name of a glyph 32 | pub fn get(&self, gid: impl Into) -> String { 33 | let gid: GlyphId = gid.into(); 34 | self.0 35 | .get(gid.to_u32() as usize) 36 | .map(|n| n.to_string()) 37 | .unwrap_or_else(|| format!("gid{}", gid)) 38 | } 39 | 40 | /// Check if two NameMaps are compatible 41 | /// 42 | /// Two NameMaps are compatible if they have the same names for most of the glyphs; 43 | /// that is, if less than 25% of the names are different. 44 | pub fn compatible(&self, other: &Self) -> bool { 45 | let count_glyphname_differences = self 46 | .0 47 | .iter() 48 | .zip(other.0.iter()) 49 | .filter(|(a, b)| a != b) 50 | .count(); 51 | count_glyphname_differences < self.0.len() / 4 52 | } 53 | 54 | pub fn len(&self) -> usize { 55 | self.0.len() 56 | } 57 | 58 | pub fn is_empty(&self) -> bool { 59 | self.0.is_empty() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ttj/src/serializefont.rs: -------------------------------------------------------------------------------- 1 | /// Additional routines to help with serialization of font data. 2 | /// 3 | /// This module provides a few helper functions to serialize font data into JSON, 4 | /// based on the back of the read_fonts traversal feature. 5 | use read_fonts::traversal::{FieldType, SomeArray, SomeTable}; 6 | use serde_json::{Map, Number, Value}; 7 | 8 | pub(crate) trait ToValue { 9 | fn serialize(&self) -> Value; 10 | } 11 | 12 | impl<'a> ToValue for FieldType<'a> { 13 | fn serialize(&self) -> Value { 14 | match self { 15 | Self::I8(arg0) => Value::Number((*arg0).into()), 16 | Self::U8(arg0) => Value::Number((*arg0).into()), 17 | Self::I16(arg0) => Value::Number((*arg0).into()), 18 | Self::I24(arg0) => Value::Number((Into::::into(*arg0)).into()), 19 | Self::U16(arg0) => Value::Number((*arg0).into()), 20 | Self::I32(arg0) => Value::Number((*arg0).into()), 21 | Self::U32(arg0) => Value::Number((*arg0).into()), 22 | Self::U24(arg0) => { 23 | let u: u32 = (*arg0).into(); 24 | Value::Number(u.into()) 25 | } 26 | Self::Tag(arg0) => Value::String(arg0.to_string()), 27 | Self::FWord(arg0) => Value::Number(arg0.to_i16().into()), 28 | Self::UfWord(arg0) => Value::Number(arg0.to_u16().into()), 29 | Self::MajorMinor(arg0) => Value::String(format!("{}.{}", arg0.major, arg0.minor)), 30 | Self::Version16Dot16(arg0) => Value::String(format!("{}", *arg0)), 31 | Self::F2Dot14(arg0) => Value::Number(Number::from_f64(arg0.to_f32() as f64).unwrap()), 32 | Self::Fixed(arg0) => Value::Number(Number::from(arg0.to_i32())), 33 | Self::LongDateTime(arg0) => Value::Number(arg0.as_secs().into()), 34 | Self::GlyphId16(arg0) => Value::String(format!("g{}", arg0.to_u16())), 35 | Self::NameId(arg0) => Value::String(arg0.to_string()), 36 | Self::StringOffset(string) => match &string.target { 37 | Ok(arg0) => Value::String(arg0.as_ref().iter_chars().collect()), 38 | Err(_) => Value::Null, 39 | }, 40 | Self::ArrayOffset(array) => match &array.target { 41 | Ok(arg0) => arg0.as_ref().serialize(), 42 | Err(_) => Value::Null, 43 | }, 44 | Self::BareOffset(arg0) => Value::String(format!("0x{:04X}", arg0.to_u32())), 45 | Self::ResolvedOffset(arg0) => { 46 | arg0.target.as_ref().map_or(Value::Null, |t| t.serialize()) 47 | } 48 | Self::Record(arg0) => (arg0 as &(dyn SomeTable<'a> + 'a)).serialize(), 49 | Self::Array(arg0) => arg0.serialize(), 50 | Self::Unknown => Value::String("no repr available".to_string()), 51 | } 52 | } 53 | } 54 | 55 | impl<'a> ToValue for dyn SomeArray<'a> + 'a { 56 | fn serialize(&self) -> Value { 57 | let mut out = vec![]; 58 | let mut idx = 0; 59 | while let Some(val) = self.get(idx) { 60 | out.push(val.serialize()); 61 | idx += 1; 62 | } 63 | Value::Array(out) 64 | } 65 | } 66 | 67 | impl<'a> ToValue for dyn SomeTable<'a> + 'a { 68 | fn serialize(&self) -> Value { 69 | let mut field_num = 0; 70 | let mut map = Map::new(); 71 | while let Some(field) = self.get_field(field_num) { 72 | map.insert(field.name.to_string(), field.value.serialize()); 73 | field_num += 1; 74 | } 75 | Value::Object(map) 76 | } 77 | } 78 | --------------------------------------------------------------------------------