├── .gitignore ├── .gitmodules ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── build └── html.js ├── dist └── .gitkeep ├── ffi.cc ├── firebase.json ├── html └── index.html ├── package.json ├── public ├── favicon.ico ├── icon_144.png ├── icon_512.png └── style.css ├── src ├── app.ts ├── convert.ts ├── convertworker.ts ├── format.ts ├── otf.ts ├── reader.ts ├── sfnt.ts ├── tag.ts ├── woff.ts ├── woff2.ts ├── worker.ts └── writer.ts ├── test ├── data │ └── ahem │ │ ├── AHEM____.TTF │ │ ├── AHEM____.woff │ │ ├── AHEM____.woff2 │ │ └── COPYING └── test.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .firebaserc 3 | .vscode 4 | out/ 5 | *.a 6 | *.o 7 | node_modules/ 8 | dist/*.js 9 | dist/*.map 10 | dist/*.wasm 11 | tmp/ 12 | public/*.html 13 | public/*.js 14 | public/*.wasm -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "woff2"] 2 | path = woff2 3 | url = https://github.com/google/woff2.git 4 | ignore = dirty 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | singleQuote: true 3 | parser: typescript 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CC := emcc 2 | CXX := emcc 3 | 4 | CXXFLAGS += -std=c++11 -Oz 5 | 6 | OUTDIR := out 7 | 8 | WOFF2_DIR := woff2 9 | 10 | OBJDIR := $(OUTDIR)/obj 11 | DISTDIR := dist/ 12 | 13 | .DEFAULT_GOAL := wasm 14 | 15 | # brotli 16 | 17 | BROTLI_DIR := $(WOFF2_DIR)/brotli 18 | BROTLI_LIB_A := $(BROTLI_DIR)/libbrotli.a 19 | 20 | $(BROTLI_LIB_A): 21 | CC=$(CC) $(MAKE) -C $(BROTLI_DIR) lib 22 | 23 | # woff2 24 | 25 | WOFF2_SRC_DIR = $(WOFF2_DIR)/src 26 | WOFF2_SRC_FILES := font.cc \ 27 | glyph.cc \ 28 | normalize.cc \ 29 | table_tags.cc \ 30 | transform.cc \ 31 | variable_length.cc \ 32 | woff2_common.cc \ 33 | woff2_dec.cc \ 34 | woff2_enc.cc \ 35 | woff2_out.cc 36 | 37 | WOFF2_SRCS := $(addprefix $(WOFF2_SRC_DIR)/, $(WOFF2_SRC_FILES)) 38 | WOFF2_OBJS := $(addprefix $(OBJDIR)/, $(notdir $(WOFF2_SRCS:%.cc=%.o))) 39 | WOFF2_DEPS := $(WOFF2_OBJS:%.o=%.d) 40 | WOFF2_LIB_A := $(OBJDIR)/libwoff2.a 41 | 42 | -include $(WOFF2_DEPS) 43 | 44 | $(WOFF2_LIB_A): dirs $(WOFF2_OBJS) 45 | emar crs $(WOFF2_LIB_A) $(WOFF2_OBJS) 46 | 47 | $(OBJDIR)/%.o: $(WOFF2_DIR)/src/%.cc 48 | emcc -c -MMD $(CXXFLAGS) -I$(BROTLI_DIR)/c/include -I$(WOFF2_DIR)/include -o $@ $< 49 | 50 | # wasm 51 | 52 | FFI_JS := $(DISTDIR)/ffi.js 53 | FFI_OBJS := $(FFI_SRCS:%.cc=%.o) 54 | 55 | wasm: $(WOFF2_LIB_A) $(BROTLI_LIB_A) ffi.cc 56 | emcc $(CXXFLAGS) -I$(WOFF2_DIR)/include ffi.cc -o $(FFI_JS) $(WOFF2_LIB_A) $(BROTLI_LIB_A) \ 57 | -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 \ 58 | -s EXPORTED_FUNCTIONS='["_malloc", "_free"]' \ 59 | -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' 60 | 61 | $(OBJDIR)/%.o: %.cc 62 | emcc -c -MMD $(CXXFLAGS) -I$(BROTLI_DIR)/c/include -I$(WOFF2_DIR)/include -o $@ $< 63 | 64 | # others 65 | 66 | .PHONY: clean 67 | 68 | dirs: 69 | @mkdir -p $(OBJDIR) 70 | 71 | clean: 72 | $(MAKE) -C $(BROTLI_DIR) clean 73 | @rm -rf $(OUTDIR) 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kombu 2 | 3 | A web app that converts a font from/to ttf, otf, woff and woff2. 4 | 5 | https://kombu.kanejaku.org/ 6 | 7 | The whole process of conversion happens only on browsers. Once the app is loaded, no server interaction occurs. 8 | 9 | This app uses WebAssembly and Web Workers. You need a modern browser to run this app. Major browsers like Firefox, Google Chrome, Safari and Edge support them. 10 | 11 | ## Build 12 | 13 | You need [emscripten](https://emscripten.org/docs/getting_started) to build. 14 | 15 | ```sh 16 | $ git clone --recursive https://github.com/bashi/kombu.git 17 | # Install dependencies 18 | $ yarn 19 | # Build wasm for woff2 support 20 | $ yarn make-wasm 21 | # Build web app 22 | $ yarn build 23 | # optional: Launch http server for local development 24 | $ http-server -p 4001 -c-0 25 | ``` 26 | 27 | The webapp will be generated under `public` directory. Copy `public` directory to your server. 28 | -------------------------------------------------------------------------------- /build/html.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const GA_CODE = ``; 7 | 8 | function getVersion() { 9 | const packageJsonPath = path.resolve(__dirname, '..', 'package.json'); 10 | const content = fs.readFileSync(packageJsonPath, { encoding: 'utf-8' }); 11 | const packageInfo = JSON.parse(content); 12 | return packageInfo.version; 13 | } 14 | 15 | function setVersion(html) { 16 | const version = getVersion(); 17 | if (!version) return html; 18 | const replaced = html.replace('__VERSION__', 'v' + version); 19 | return replaced; 20 | } 21 | 22 | function getGoogleAnalyticsId() { 23 | if (process.env.NODE_ENV !== 'production') return undefined; 24 | require('dotenv').config(); 25 | return process.env.GOOGLE_ANALYTICS_ID; 26 | } 27 | 28 | function setGoogleAnalyticsId(html) { 29 | const gaId = getGoogleAnalyticsId(); 30 | if (!gaId) return html; 31 | const replaced = html.replace('', GA_CODE.replace('__ID__', gaId) + '\n'); 32 | return replaced; 33 | } 34 | 35 | function main() { 36 | const htmlPath = path.resolve(__dirname, '..', 'html'); 37 | const publicPath = path.resolve(__dirname, '..', 'public'); 38 | 39 | const srcIndexHtmlPath = path.join(htmlPath, 'index.html'); 40 | const destIndexHtmlPath = path.join(publicPath, 'index.html'); 41 | 42 | let html = fs.readFileSync(srcIndexHtmlPath, { encoding: 'utf-8' }); 43 | html = setVersion(html); 44 | html = setGoogleAnalyticsId(html); 45 | fs.writeFileSync(destIndexHtmlPath, html); 46 | } 47 | 48 | main(); 49 | -------------------------------------------------------------------------------- /dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/dist/.gitkeep -------------------------------------------------------------------------------- /ffi.cc: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "woff2/decode.h" 4 | #include "woff2/encode.h" 5 | 6 | extern "C" { 7 | 8 | EMSCRIPTEN_KEEPALIVE 9 | size_t get_max_compressed_size(const uint8_t* data, size_t length) { 10 | return woff2::MaxWOFF2CompressedSize(data, length); 11 | } 12 | 13 | EMSCRIPTEN_KEEPALIVE 14 | int32_t ttf_to_woff2(const uint8_t* data, size_t length, uint8_t* result, size_t result_length) { 15 | size_t out_length = result_length; 16 | if (!woff2::ConvertTTFToWOFF2(data, length, result, &out_length)) 17 | return -1; 18 | return out_length; 19 | } 20 | 21 | EMSCRIPTEN_KEEPALIVE 22 | size_t get_uncompressed_size(const uint8_t* data, size_t length) { 23 | return woff2::ComputeWOFF2FinalSize(data, length); 24 | } 25 | 26 | EMSCRIPTEN_KEEPALIVE 27 | int32_t woff2_to_ttf(uint8_t* result, size_t result_length, const uint8_t* data, size_t length) { 28 | if (!woff2::ConvertWOFF2ToTTF(result, result_length, data, length)) 29 | return -1; 30 | return result_length; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OTF/WOFF/WOFF2 Converter 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 23 | 24 |
25 |

OTF/WOFF/WOFF2 Converter

26 |
27 | Convert fonts from/to ttf, otf, woff and woff2 without server interaction. 28 |
29 |
30 | 31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 |
40 | 41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 |
56 | Converting 57 | 58 | 61 | 62 | 64 | 65 | 66 | 68 | 69 | 70 | 72 | 73 | 74 |
75 | 76 |
It may take several minutes when you convert a large font.
77 |
78 |
79 |
80 |
81 | 82 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kombu", 3 | "version": "0.1.9", 4 | "description": "OpenType/WOFF/WOFF2 converter", 5 | "scripts": { 6 | "build:production": "webpack --mode production && NODE_ENV=production yarn html", 7 | "build": "webpack && yarn html", 8 | "html": "node build/html.js", 9 | "make-wasm": "emmake make && yarn copy-wasm", 10 | "copy-wasm": "cp dist/ffi.wasm public/", 11 | "test": "mocha --require ts-node/register test/test.ts" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/bashi/kombu.git" 16 | }, 17 | "keywords": [ 18 | "opentype", 19 | "woff", 20 | "woff2" 21 | ], 22 | "author": "Kenichi Ishibashi ", 23 | "license": "Apache-2.0", 24 | "devDependencies": { 25 | "@types/chai": "^4.1.4", 26 | "@types/mocha": "^5.2.4", 27 | "chai": "^4.1.2", 28 | "dotenv": "^6.0.0", 29 | "mocha": "^5.2.0", 30 | "ts-loader": "^4.4.2", 31 | "typescript": "^3.0.1", 32 | "webpack": "^4.12.0", 33 | "webpack-cli": "^3.0.3", 34 | "workbox-webpack-plugin": "^3.3.0" 35 | }, 36 | "dependencies": { 37 | "@types/node": "^10.5.2", 38 | "ts-node": "^7.0.0", 39 | "zlibjs": "^0.3.1" 40 | } 41 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/public/favicon.ico -------------------------------------------------------------------------------- /public/icon_144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/public/icon_144.png -------------------------------------------------------------------------------- /public/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/public/icon_512.png -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 768px; 3 | --primary-color: #0067b8; 4 | } 5 | 6 | html, body { 7 | font-family: 'Open Sans', sans-serif; 8 | } 9 | 10 | button { 11 | font-size: 1rem; 12 | display: inline-block; 13 | padding: 0.4rem 1rem; 14 | text-decoration: none; 15 | color: var(--primary-color); 16 | background: transparent; 17 | border: solid 2px var(--primary-color); 18 | border-radius: 2px; 19 | } 20 | 21 | button:focus { 22 | outline:0; 23 | } 24 | 25 | button:hover { 26 | cursor: pointer; 27 | background: var(--primary-color); 28 | color: white; 29 | } 30 | 31 | button[disabled] { 32 | color: #ccc; 33 | border: solid 2px #ccc; 34 | } 35 | 36 | button[disabled]:hover { 37 | cursor: not-allowed; 38 | background: white; 39 | } 40 | 41 | a { 42 | color: var(--primary-color); 43 | text-decoration: none; 44 | } 45 | 46 | a:hover { 47 | text-decoration: underline; 48 | } 49 | 50 | .input-radio { 51 | display: none; 52 | } 53 | 54 | .input-radio + label { 55 | position: relative; 56 | padding-left: 20px; 57 | padding-right: 10px; 58 | } 59 | 60 | .input-radio + label:before { 61 | content: ""; 62 | display: block; 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | width: 14px; 67 | height: 14px; 68 | border: 1px solid var(--primary-color); 69 | border-radius: 50%; 70 | } 71 | 72 | .input-radio:checked + label { 73 | color: var(--primary-color); 74 | } 75 | 76 | .input-radio:checked + label::after { 77 | content: ""; 78 | display: block; 79 | position: absolute; 80 | top: 3px; 81 | left: 3px; 82 | width: 10px; 83 | height: 10px; 84 | background: var(--primary-color); 85 | border-radius: 50%; 86 | } 87 | 88 | .container { 89 | padding: 0 2rem 0 2rem; 90 | max-width: var(--max-width); 91 | margin-left: auto; 92 | margin-right: auto; 93 | } 94 | 95 | header { 96 | color: #555; 97 | padding: 0 2rem 0 2rem; 98 | max-width: var(--max-width); 99 | margin-left: auto; 100 | margin-right: auto; 101 | margin-bottom: 2rem; 102 | } 103 | 104 | header h1 a { 105 | color: #555; 106 | } 107 | 108 | header h1 a:hover { 109 | text-decoration: none; 110 | } 111 | 112 | footer { 113 | margin: 2rem 0 1rem 0; 114 | font-size: 0.9rem; 115 | text-align: center; 116 | } 117 | 118 | #version { 119 | color: #ccc; 120 | } 121 | 122 | #input-select-zone { 123 | display: flex; 124 | justify-content: center; 125 | margin-bottom: 1rem; 126 | } 127 | 128 | #format-select-container { 129 | display: flex; 130 | justify-content: center; 131 | margin-top: 1rem; 132 | margin-bottom: 1rem; 133 | } 134 | 135 | .convert-summary { 136 | margin-bottom: 1rem; 137 | } 138 | 139 | #convert-result-container { 140 | display: flex; 141 | flex-direction: column; 142 | align-items: center; 143 | } 144 | 145 | #convert-button-container { 146 | display: flex; 147 | justify-content: center; 148 | margin-top: 1rem; 149 | margin-bottom: 1rem; 150 | } 151 | 152 | .spinner-off { 153 | display: none; 154 | } 155 | 156 | #spinner-icon { 157 | margin-left: 6px; 158 | } 159 | 160 | #error-message-container { 161 | font-weight: bold; 162 | color: red; 163 | } 164 | 165 | .error-message-off { 166 | display: none; 167 | } 168 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Format, isValidFormat, getFilenameSuffix } from './format'; 2 | import { convertOnWorker } from './convertworker'; 3 | 4 | async function fileToUint8Array(file: File): Promise { 5 | const fileReader = new FileReader(); 6 | const promise = new Promise((resolve, reject) => { 7 | fileReader.addEventListener('load', () => { 8 | const result = fileReader.result; 9 | if (result instanceof ArrayBuffer) { 10 | resolve(new Uint8Array(result)); 11 | } else { 12 | throw new Error('readAsArrayBuffer() returns non ArrayBuffer result'); 13 | } 14 | }); 15 | fileReader.addEventListener('error', (e) => reject(e)); 16 | }); 17 | fileReader.readAsArrayBuffer(file); 18 | return promise; 19 | } 20 | 21 | function createDownloadLink(basename: string, data: Uint8Array): HTMLAnchorElement { 22 | const blob = new Blob([data]); 23 | const url = URL.createObjectURL(blob); 24 | const link = document.createElement('a'); 25 | link.href = url; 26 | const suffix = getFilenameSuffix(data); 27 | link.download = `${basename}.${suffix}`; 28 | link.innerHTML = `Download ${basename}.${suffix}`; 29 | return link; 30 | } 31 | 32 | function getBasename(filename: string): string { 33 | const suffixPos = filename.lastIndexOf('.'); 34 | if (suffixPos === -1) return filename; 35 | return filename.substr(0, suffixPos); 36 | } 37 | 38 | const BYTE_SUFFIXES = [' B', ' kB', ' MB']; 39 | const BYTE_MARGIN = 1024; 40 | 41 | function formatFilesize(amount: number): string { 42 | let index = 0; 43 | while (amount > 1000 + BYTE_MARGIN && index < BYTE_SUFFIXES.length) { 44 | amount /= 1000; 45 | index += 1; 46 | } 47 | const suffix = BYTE_SUFFIXES[index]; 48 | if (amount > 100) { 49 | return amount.toFixed(0) + suffix; 50 | } else { 51 | return amount.toFixed(1) + suffix; 52 | } 53 | } 54 | 55 | function formatProcessTime(t: number): string { 56 | if (t < 1000) { 57 | return t.toFixed(0) + 'ms'; 58 | } 59 | const sec = t / 1000; 60 | return sec.toFixed(1) + 's'; 61 | } 62 | 63 | function formatConversionRatio(before: number, after: number): string { 64 | const el = document.createElement('span'); 65 | const ratio = (after / before) * 100; 66 | el.innerText = ratio.toFixed(1); 67 | if (ratio < 100) { 68 | el.style.color = 'green'; 69 | el.style.fontWeight = 'bold'; 70 | } else if (ratio > 100) { 71 | el.style.color = 'red'; 72 | el.style.fontWeight = 'bold'; 73 | } 74 | return el.outerHTML; 75 | } 76 | 77 | // TODO: Avoid a god object. 78 | class App { 79 | inputFileEl: HTMLInputElement; 80 | selectFileButton: HTMLButtonElement; 81 | convertResultEl: Element; 82 | selectedFontInfoEl: Element; 83 | convertButton: HTMLButtonElement; 84 | spinnerEl: Element; 85 | errorMessageEl: Element; 86 | 87 | selectedFiles: FileList | undefined; 88 | 89 | constructor() { 90 | const inputFileEl = document.querySelector('#input-file'); 91 | if (!(inputFileEl instanceof HTMLInputElement)) { 92 | throw new Error('No input-file element'); 93 | } 94 | const selectFileButton = document.querySelector('#select-file-button'); 95 | if (!(selectFileButton instanceof HTMLButtonElement)) { 96 | throw new Error('No select-file-button element'); 97 | } 98 | const convertResultEl = document.querySelector('#convert-result-container'); 99 | if (!convertResultEl) { 100 | throw new Error('No convert result container'); 101 | } 102 | const selectedFontInfoEl = document.querySelector('#selected-font-info'); 103 | if (!selectedFontInfoEl) { 104 | throw new Error('No selected font info element'); 105 | } 106 | const convertButton = document.querySelector('#convert-button'); 107 | if (!(convertButton instanceof HTMLButtonElement)) { 108 | throw new Error('No convert button element'); 109 | } 110 | const spinnerEl = document.querySelector('#spinner'); 111 | if (!spinnerEl) { 112 | throw new Error('No spinner element'); 113 | } 114 | const errorMessageEl = document.querySelector('#error-message-container'); 115 | if (!errorMessageEl) { 116 | throw new Error('No error message container'); 117 | } 118 | 119 | this.inputFileEl = inputFileEl; 120 | this.selectFileButton = selectFileButton; 121 | this.convertResultEl = convertResultEl; 122 | this.selectedFontInfoEl = selectedFontInfoEl; 123 | this.convertButton = convertButton; 124 | this.spinnerEl = spinnerEl; 125 | this.errorMessageEl = errorMessageEl; 126 | 127 | this.convertButton.disabled = true; 128 | 129 | this.selectedFiles = undefined; 130 | 131 | this.selectFileButton.addEventListener('click', async () => { 132 | const files = await this.chooseFiles(); 133 | this.onFilesSelected(files); 134 | }); 135 | 136 | this.convertButton.addEventListener('click', () => { 137 | this.startConversions(); 138 | }); 139 | } 140 | 141 | private async chooseFiles(): Promise { 142 | return new Promise((resolve, reject) => { 143 | const listener = () => { 144 | this.inputFileEl.removeEventListener('change', listener); 145 | if (this.inputFileEl.files === null || this.inputFileEl.files.length === 0) { 146 | reject('No file specified'); 147 | return; 148 | } 149 | resolve(this.inputFileEl.files); 150 | }; 151 | this.inputFileEl.addEventListener('change', listener); 152 | this.inputFileEl.click(); 153 | }); 154 | } 155 | 156 | private onFilesSelected(files: FileList) { 157 | this.selectedFontInfoEl.innerHTML = ''; 158 | 159 | for (let file of files) { 160 | const fileSize = formatFilesize(file.size); 161 | let el = document.createElement('div'); 162 | el.innerHTML = `${file.name} (${fileSize})`; 163 | this.selectedFontInfoEl.appendChild(el); 164 | } 165 | 166 | this.selectedFiles = files; 167 | this.convertButton.disabled = false; 168 | } 169 | 170 | private async startConversions() { 171 | if (this.selectedFiles === undefined) return; 172 | 173 | const outputFormatEl = document.querySelector('input[name=output-format]:checked'); 174 | if (!(outputFormatEl instanceof HTMLInputElement)) { 175 | throw new Error('No output format element'); 176 | } 177 | 178 | const format = outputFormatEl.value; 179 | if (!isValidFormat(format)) { 180 | throw new Error(`Invalid font format: ${format}`); 181 | } 182 | 183 | this.convertButton.disabled = true; 184 | 185 | // Clear conversion status. 186 | this.convertResultEl.innerHTML = ''; 187 | this.errorMessageEl.innerHTML = ''; 188 | this.errorMessageEl.classList.add('error-message-off'); 189 | this.spinnerEl.classList.remove('spinner-off'); 190 | 191 | try { 192 | for (let file of this.selectedFiles) { 193 | await this.convertSingleFile(file, format); 194 | } 195 | } catch (exception) { 196 | console.error(exception); 197 | this.errorMessageEl.innerHTML = exception.message; 198 | this.errorMessageEl.classList.remove('error-message-off'); 199 | this.convertResultEl.innerHTML = ''; 200 | } finally { 201 | this.spinnerEl.classList.add('spinner-off'); 202 | this.convertButton.disabled = false; 203 | } 204 | } 205 | 206 | private async convertSingleFile(file: File, format: Format) { 207 | const data = await fileToUint8Array(file); 208 | const originalByteLength = data.byteLength; 209 | const result = await convertOnWorker(data, format); 210 | const output = result.output; 211 | 212 | const originalFileSize = formatFilesize(originalByteLength); 213 | const convertedFileSize = formatFilesize(output.byteLength); 214 | const processTime = formatProcessTime(result.processTime); 215 | const ratio = formatConversionRatio(originalByteLength, output.byteLength); 216 | 217 | const summaryEl = document.createElement('div'); 218 | summaryEl.classList.add('convert-summary'); 219 | 220 | summaryEl.innerHTML = ` 221 |
Size comparison: ${originalFileSize} → ${convertedFileSize} (${ratio}%)
222 |
Process time: ${processTime}
223 | `; 224 | this.convertResultEl.appendChild(summaryEl); 225 | 226 | const basename = getBasename(file.name); 227 | const link = createDownloadLink(basename, output); 228 | this.convertResultEl.appendChild(link); 229 | } 230 | } 231 | 232 | document.addEventListener('DOMContentLoaded', () => { 233 | new App(); 234 | }); 235 | -------------------------------------------------------------------------------- /src/convert.ts: -------------------------------------------------------------------------------- 1 | import { Sfnt } from './sfnt'; 2 | import { Reader } from './reader'; 3 | import { OtfBuilder, OtfReader } from './otf'; 4 | import { WoffBuilder, WoffReader } from './woff'; 5 | import { Woff2 } from './woff2'; 6 | import { Format, getFontFormat } from './format'; 7 | 8 | interface FontReader { 9 | read(): Sfnt; 10 | } 11 | 12 | function createReader(dataReader: Reader, format: Format): FontReader { 13 | if (format === Format.OTF) { 14 | return new OtfReader(dataReader); 15 | } else if (format === Format.WOFF) { 16 | return new WoffReader(dataReader); 17 | } 18 | throw new Error(`Unsupported format: ${format}`); 19 | } 20 | 21 | export function readAsSfnt(data: Uint8Array): Sfnt { 22 | const format = getFontFormat(data); 23 | const reader = new Reader(data); 24 | const fontReader = createReader(reader, format); 25 | return fontReader.read(); 26 | } 27 | 28 | export class Converter { 29 | private woff2: Woff2; 30 | 31 | constructor(woff2: Woff2) { 32 | this.woff2 = woff2; 33 | } 34 | 35 | toOtf(data: Uint8Array): Uint8Array { 36 | const format = getFontFormat(data); 37 | if (format === Format.OTF) return data; 38 | if (format === Format.WOFF2) { 39 | const uncompressed = this.woff2.uncompress(data); 40 | return uncompressed; 41 | } 42 | if (format === Format.WOFF) { 43 | const sfnt = readAsSfnt(data); 44 | const builder = new OtfBuilder(sfnt); 45 | return builder.build(); 46 | } 47 | throw new Error(`Unsupported format: ${format}`); 48 | } 49 | 50 | toWoff(data: Uint8Array): Uint8Array { 51 | const format = getFontFormat(data); 52 | if (format === Format.WOFF) return data; 53 | if (format === Format.OTF) { 54 | const sfnt = readAsSfnt(data); 55 | const builder = new WoffBuilder(sfnt); 56 | return builder.build(); 57 | } 58 | if (format === Format.WOFF2) { 59 | const uncompressed = this.woff2.uncompress(data); 60 | const sfnt = readAsSfnt(uncompressed); 61 | const builder = new WoffBuilder(sfnt); 62 | return builder.build(); 63 | } 64 | throw new Error(`Unsupported format: ${format}`); 65 | } 66 | 67 | toWoff2(data: Uint8Array): Uint8Array { 68 | const format = getFontFormat(data); 69 | if (format === Format.WOFF2) return data; 70 | if (format === Format.OTF) { 71 | return this.woff2.compress(data); 72 | } 73 | if (format === Format.WOFF) { 74 | const sfnt = readAsSfnt(data); 75 | const builder = new OtfBuilder(sfnt); 76 | const otf = builder.build(); 77 | return this.woff2.compress(otf); 78 | } 79 | throw new Error(`Unsupported format: ${format}`); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/convertworker.ts: -------------------------------------------------------------------------------- 1 | import { Format } from './format'; 2 | 3 | interface Pending { 4 | resolve: (res: Uint8Array) => void; 5 | reject: (res: any) => void; 6 | } 7 | 8 | class ConvertWorker { 9 | private worker: Worker; 10 | private messageId: number; 11 | private pendings: Map; 12 | private timeouts: Map; 13 | 14 | constructor(worker: Worker) { 15 | this.worker = worker; 16 | this.messageId = 0; 17 | this.pendings = new Map(); 18 | this.timeouts = new Map(); 19 | 20 | this.worker.addEventListener('message', e => { 21 | const messageId = e.data.messageId; 22 | if (typeof messageId !== 'number') { 23 | console.warn(`Received invalid message from worker: ${e}`); 24 | return; 25 | } 26 | const timeout = this.timeouts.get(messageId); 27 | if (timeout) { 28 | clearTimeout(timeout); 29 | this.timeouts.delete(messageId); 30 | } 31 | 32 | const pending = this.pendings.get(messageId); 33 | if (pending) { 34 | if (e.data.error) { 35 | pending.reject(new Error(e.data.error)); 36 | } else { 37 | // TODO: Make sure response has |output|. 38 | pending.resolve(e.data.response.output); 39 | } 40 | this.pendings.delete(messageId); 41 | return; 42 | } 43 | 44 | if (e.data.error) { 45 | this.terminate(); 46 | throw new Error(e.data.error); 47 | } 48 | }); 49 | } 50 | 51 | async convert(data: Uint8Array, format: Format, timeout?: number): Promise { 52 | const promise = new Promise((resolve, reject) => { 53 | this.worker.postMessage( 54 | { 55 | messageId: this.messageId, 56 | action: 'convert', 57 | input: data, 58 | format: format 59 | }, 60 | [data.buffer] 61 | ); 62 | this.pendings.set(this.messageId, { resolve: resolve, reject: reject }); 63 | if (timeout) { 64 | this.timeouts.set( 65 | this.messageId, 66 | setTimeout(() => { 67 | this.pendings.delete(this.messageId); 68 | reject(new Error('Convert time out')); 69 | }, timeout) 70 | ); 71 | } 72 | this.messageId += 1; 73 | }); 74 | 75 | return promise; 76 | } 77 | 78 | terminate() { 79 | this.worker.terminate(); 80 | } 81 | } 82 | 83 | const WORKER_INIT_TIMEOUT_MS = 15000; 84 | 85 | function createConvertWorker(): Promise { 86 | return new Promise((resolve, reject) => { 87 | const worker = new Worker('worker.js'); 88 | const timeout = setTimeout(() => reject(new Error('Worker time out')), WORKER_INIT_TIMEOUT_MS); 89 | worker.postMessage('init'); 90 | const listener = (e: MessageEvent) => { 91 | if (e.data === 'initialized') { 92 | clearTimeout(timeout); 93 | worker.removeEventListener('message', listener); 94 | resolve(new ConvertWorker(worker)); 95 | } else if (e.data.name === 'error') { 96 | reject(new Error(e.data.message)); 97 | } 98 | }; 99 | worker.addEventListener('message', listener); 100 | }); 101 | } 102 | 103 | export interface ConvertResult { 104 | output: Uint8Array; 105 | processTime: number; 106 | } 107 | 108 | let defaultWorker: ConvertWorker | null = null; 109 | async function getDefaultWorker(): Promise { 110 | if (defaultWorker) return defaultWorker; 111 | defaultWorker = await createConvertWorker(); 112 | return defaultWorker; 113 | } 114 | 115 | /** 116 | * Convert a font on a worker. 117 | * @param data font data. This will be transferred to worker. 118 | * @param format output format. 119 | */ 120 | export async function convertOnWorker(data: Uint8Array, format: Format): Promise { 121 | const t0 = performance.now(); 122 | const worker = await getDefaultWorker(); 123 | const output = await worker.convert(data, format); 124 | const t1 = performance.now(); 125 | return { 126 | output: output, 127 | processTime: t1 - t0 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | const SFNT_VERSION_OTTO = 0x4f54544f; // OTTO 2 | const SFNT_VERSION_TYP1 = 0x74797031; // typ1 3 | const SFNT_VERSION_TRUE = 0x74727565; // true 4 | const SFNT_VERSION_V1 = 0x00010000; 5 | 6 | export function isOtfFont(version: number): boolean { 7 | return ( 8 | version === SFNT_VERSION_OTTO || 9 | version === SFNT_VERSION_TYP1 || 10 | version === SFNT_VERSION_TRUE || 11 | version === SFNT_VERSION_V1 12 | ); 13 | } 14 | 15 | export function getOtfFilenameSuffix(version: number): string { 16 | if (version === SFNT_VERSION_OTTO) return 'otf'; 17 | if (version === SFNT_VERSION_TYP1 || version === SFNT_VERSION_TRUE || SFNT_VERSION_V1) 18 | return 'ttf'; 19 | throw new Error(`Invalid font version: ${version}`); 20 | } 21 | 22 | export const WOFF_SIGNATURE = 0x774f4646; // 'wOFF' 23 | 24 | export function isWoffFont(version: number): boolean { 25 | return version === WOFF_SIGNATURE; 26 | } 27 | 28 | const WOFF2_SIGNATURE = 0x774f4632; // wOF2 29 | 30 | export function isWoff2Font(version: number): boolean { 31 | return version === WOFF2_SIGNATURE; 32 | } 33 | 34 | export const enum Format { 35 | OTF = 'otf', 36 | WOFF = 'woff', 37 | WOFF2 = 'woff2', 38 | UNSUPPORTED = 'unsupported' 39 | } 40 | 41 | function getVersion(data: Uint8Array): number { 42 | if (data.byteLength < 4) return 0; // invalid 43 | const version = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]; 44 | return version; 45 | } 46 | 47 | export function getFontFormat(data: Uint8Array): Format { 48 | const version = getVersion(data); 49 | if (isOtfFont(version)) return Format.OTF; 50 | if (isWoffFont(version)) return Format.WOFF; 51 | if (isWoff2Font(version)) return Format.WOFF2; 52 | return Format.UNSUPPORTED; 53 | } 54 | 55 | export function isValidFormat(s: string): s is Format { 56 | return s === 'otf' || s === 'woff' || s === 'woff2'; 57 | } 58 | 59 | export function getFilenameSuffix(data: Uint8Array): string { 60 | const version = getVersion(data); 61 | if (isWoffFont(version)) return 'woff'; 62 | if (isWoff2Font(version)) return 'woff2'; 63 | if (isOtfFont(version)) return getOtfFilenameSuffix(version); 64 | throw new Error(`Invalid font version: ${version}`); 65 | } 66 | -------------------------------------------------------------------------------- /src/otf.ts: -------------------------------------------------------------------------------- 1 | import { Sfnt } from './sfnt'; 2 | import { Reader } from './reader'; 3 | import { Writer } from './writer'; 4 | import { tagToString, stringToTag, calculateTableChecksum } from './tag'; 5 | 6 | export const SFNT_HEADER_SIZE = 12; 7 | export const SFNT_TABLE_ENTRY_SIZE = 16; 8 | 9 | interface OffsetTable { 10 | sfntVersion: number; 11 | numTables: number; 12 | searchRange: number; 13 | entrySelector: number; 14 | rangeShift: number; 15 | } 16 | 17 | interface TableRecord { 18 | tag: number; 19 | checksum: number; 20 | offset: number; 21 | length: number; 22 | } 23 | 24 | export class OtfReader { 25 | private reader: Reader; 26 | 27 | constructor(reader: Reader) { 28 | this.reader = reader; 29 | } 30 | 31 | read(): Sfnt { 32 | const offsetTable = this.readOffsetTable(); 33 | const records = this.readTableRecords(offsetTable.numTables); 34 | const sfnt = new Sfnt(offsetTable.sfntVersion); 35 | this.readTables(sfnt, records); 36 | return sfnt; 37 | } 38 | 39 | private readOffsetTable(): OffsetTable { 40 | const offsetTable = { 41 | sfntVersion: this.reader.readULong(), 42 | numTables: this.reader.readUShort(), 43 | searchRange: this.reader.readUShort(), 44 | entrySelector: this.reader.readUShort(), 45 | rangeShift: this.reader.readUShort() 46 | }; 47 | return offsetTable; 48 | } 49 | 50 | private readTableRecords(numTables: number): Array { 51 | const records = []; 52 | for (let i = 0; i < numTables; i++) { 53 | const r = { 54 | tag: this.reader.readULong(), 55 | checksum: this.reader.readULong(), 56 | offset: this.reader.readULong(), 57 | length: this.reader.readULong() 58 | }; 59 | records.push(r); 60 | } 61 | return records; 62 | } 63 | 64 | private readTables(sfnt: Sfnt, records: Array) { 65 | for (let i = 0; i < records.length; i++) { 66 | const r = records[i]; 67 | const tagStr = tagToString(r.tag); 68 | if (r.offset % 4 !== 0) 69 | throw new Error('Offset must be four-bytes aligned: ' + tagStr + ' ' + r.offset); 70 | 71 | const data = this.reader.uint8ArrayFor(r.offset, r.length); 72 | const head = stringToTag('head'); 73 | if (r.tag !== head) { 74 | var checksum = calculateTableChecksum(data); 75 | if (r.checksum !== checksum) 76 | throw new Error('Checksum mismatch: ' + tagStr + ' ' + checksum + ' != ' + r.checksum); 77 | } 78 | sfnt.addTable(r.tag, data, r.checksum); 79 | } 80 | } 81 | } 82 | 83 | export class OtfBuilder { 84 | private sfnt: Sfnt; 85 | private writer: Writer; 86 | 87 | constructor(sfnt: Sfnt) { 88 | this.sfnt = sfnt; 89 | this.writer = new Writer(); 90 | } 91 | 92 | private writeHeader() { 93 | const numTables = this.sfnt.numTables(); 94 | let entrySelector = 0; 95 | while (1 << (1 + entrySelector) <= numTables) { 96 | entrySelector += 1; 97 | } 98 | const searchRange = 1 << (entrySelector + 4); 99 | const rangeShift = numTables * 16 - searchRange; 100 | this.writer.writeULong(this.sfnt.getSfntVersion()); 101 | this.writer.writeUShort(numTables); 102 | this.writer.writeUShort(searchRange); 103 | this.writer.writeUShort(entrySelector); 104 | this.writer.writeUShort(rangeShift); 105 | } 106 | 107 | private writeTableRecords() { 108 | const tags = this.sfnt.getTags(); 109 | let tableOffset = SFNT_HEADER_SIZE + SFNT_TABLE_ENTRY_SIZE * this.sfnt.numTables(); 110 | for (let i = 0; i < tags.length; i++) { 111 | const tag = tags[i]; 112 | const table = this.sfnt.getTableByTag(tag)!; 113 | this.writer.writeULong(tag); 114 | this.writer.writeULong(table.checksum); 115 | this.writer.writeULong(tableOffset); 116 | this.writer.writeULong(table.length()); 117 | tableOffset += table.paddedLength(); 118 | } 119 | } 120 | 121 | private writeTables() { 122 | const tags = this.sfnt.getTags(); 123 | for (let i = 0; i < tags.length; i++) { 124 | const tag = tags[i]; 125 | const table = this.sfnt.getTableByTag(tag)!; 126 | this.writer.writeBytes(table.data); 127 | const padLen = table.paddedLength() - table.length(); 128 | this.writer.pad(padLen); 129 | } 130 | } 131 | 132 | build(): Uint8Array { 133 | this.writeHeader(); 134 | this.writeTableRecords(); 135 | this.writeTables(); 136 | return this.writer.result(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/reader.ts: -------------------------------------------------------------------------------- 1 | export class Reader { 2 | private view: DataView; 3 | private position: number; 4 | 5 | constructor(data: Uint8Array) { 6 | this.view = new DataView(data.buffer); 7 | this.position = 0; 8 | } 9 | 10 | private checkBoundary() { 11 | if (this.position < 0 || this.position > this.view.byteLength) 12 | throw new Error('Out of position: ' + this.position); 13 | } 14 | 15 | length(): number { 16 | return this.view.byteLength; 17 | } 18 | 19 | readByte(): number { 20 | const value = this.view.getUint8(this.position); 21 | this.position += 1; 22 | this.checkBoundary(); 23 | return value; 24 | } 25 | 26 | readShort(): number { 27 | const value = this.view.getInt16(this.position, false); 28 | this.position += 2; 29 | this.checkBoundary(); 30 | return value; 31 | } 32 | 33 | readUShort(): number { 34 | const value = this.view.getUint16(this.position, false); 35 | this.position += 2; 36 | this.checkBoundary(); 37 | return value; 38 | } 39 | 40 | readLong(): number { 41 | const value = this.view.getInt32(this.position, false); 42 | this.position += 4; 43 | this.checkBoundary(); 44 | return value; 45 | } 46 | 47 | readULong(): number { 48 | const value = this.view.getUint32(this.position, false); 49 | this.position += 4; 50 | this.checkBoundary(); 51 | return value; 52 | } 53 | 54 | uint8ArrayFor(offset: number, length: number): Uint8Array { 55 | if (offset + length > this.view.byteLength) { 56 | throw new Error('Out of buffer: ' + offset + length); 57 | } 58 | return new Uint8Array(this.view.buffer, this.view.byteOffset + offset, length); 59 | } 60 | 61 | seek(position: number) { 62 | this.position = position; 63 | this.checkBoundary(); 64 | } 65 | 66 | skip(amount: number) { 67 | this.position += amount; 68 | this.checkBoundary(); 69 | } 70 | 71 | getPosition(): number { 72 | return this.position; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/sfnt.ts: -------------------------------------------------------------------------------- 1 | class Table { 2 | data: Uint8Array; 3 | checksum: number; 4 | 5 | constructor(data: Uint8Array, checksum: number) { 6 | this.data = data; 7 | this.checksum = checksum; 8 | } 9 | 10 | length(): number { 11 | return this.data.byteLength; 12 | } 13 | 14 | paddedLength(): number { 15 | return (this.data.byteLength + 3) & ~3; 16 | } 17 | } 18 | 19 | export class Sfnt { 20 | private sfntVersion: number; 21 | private tables: Map; 22 | private tableTags: Array; 23 | 24 | constructor(sfntVersion: number) { 25 | this.sfntVersion = sfntVersion; 26 | this.tables = new Map(); 27 | this.tableTags = []; 28 | } 29 | 30 | getSfntVersion() { 31 | return this.sfntVersion; 32 | } 33 | 34 | numTables(): number { 35 | return this.tableTags.length; 36 | } 37 | 38 | addTable(tag: number, data: Uint8Array, checksum: number) { 39 | const tagExists = tag in this.tables; 40 | this.tables.set(tag, new Table(data, checksum)); 41 | if (tagExists) return; 42 | 43 | let index = 0; 44 | while (index < this.tableTags.length && tag > this.tableTags[index]) index++; 45 | this.tableTags.splice(index, 0, tag); 46 | } 47 | 48 | getTableByTag(tag: number): Table | undefined { 49 | return this.tables.get(tag); 50 | } 51 | 52 | getTags(): Array { 53 | return this.tableTags.slice(0); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tag.ts: -------------------------------------------------------------------------------- 1 | export function stringToTag(s: string): number { 2 | if (s.length !== 4) { 3 | throw new Error(`Invalid tag: ${s}`); 4 | } 5 | return ( 6 | (((s.charCodeAt(0) & 0xff) << 24) | 7 | ((s.charCodeAt(1) & 0xff) << 16) | 8 | ((s.charCodeAt(2) & 0xff) << 8) | 9 | (s.charCodeAt(3) & 0xff)) >>> 10 | 0 11 | ); 12 | } 13 | 14 | export function tagToString(tag: number): string { 15 | return String.fromCharCode( 16 | (tag >>> 24) & 0xff, 17 | (tag >>> 16) & 0xff, 18 | (tag >>> 8) & 0xff, 19 | tag & 0xff 20 | ); 21 | } 22 | 23 | export function calculateTableChecksum(table: Uint8Array): number { 24 | let sum = 0; 25 | let value; 26 | let i; 27 | for (i = 0; i < table.byteLength - 4; i += 4) { 28 | value = ((table[i] << 24) | (table[i + 1] << 16) | (table[i + 2] << 8) | table[i + 3]) >>> 0; 29 | sum = ((sum + value) & 0xffffffff) >>> 0; 30 | } 31 | value = 0; 32 | const remaining = table.byteLength - i; 33 | for (let j = 0; j < remaining; j++) value = ((value << 8) >>> 0) | table[i + j]; 34 | value = (value << (8 * (4 - remaining))) >>> 0; 35 | return ((sum + value) & 0xffffffff) >>> 0; 36 | } 37 | -------------------------------------------------------------------------------- /src/woff.ts: -------------------------------------------------------------------------------- 1 | import { Sfnt } from './sfnt'; 2 | import { Reader } from './reader'; 3 | import { Writer } from './writer'; 4 | import { stringToTag, calculateTableChecksum } from './tag'; 5 | import { SFNT_HEADER_SIZE, SFNT_TABLE_ENTRY_SIZE } from './otf'; 6 | import { WOFF_SIGNATURE, isOtfFont } from './format'; 7 | 8 | // @ts-ignore 9 | import * as Zlib from 'zlibjs'; 10 | 11 | export const WOFF_HEADER_SIZE = 44; 12 | export const WOFF_TABLE_ENTRY_SIZE = 20; 13 | 14 | const TAG_HEAD = stringToTag('head'); 15 | 16 | function compressTable(data: Uint8Array): Uint8Array { 17 | const res = Zlib.deflateSync(data); 18 | return res; 19 | } 20 | 21 | function uncompressTable(data: Uint8Array): Uint8Array { 22 | const res = Zlib.inflateSync(data); 23 | return res; 24 | } 25 | 26 | interface Header { 27 | signature: number; 28 | flavor: number; 29 | length: number; 30 | numTables: number; 31 | totalSfntSize: number; 32 | } 33 | 34 | interface TableEntry { 35 | tag: number; 36 | offset: number; 37 | compLength: number; 38 | origLength: number; 39 | origChecksum: number; 40 | } 41 | 42 | export class WoffReader { 43 | private reader: Reader; 44 | 45 | constructor(reader: Reader) { 46 | this.reader = reader; 47 | } 48 | 49 | read(): Sfnt { 50 | const header = this.readHeader(); 51 | const entries = this.readTableEntries(header.numTables); 52 | const sfnt = new Sfnt(header.flavor); 53 | for (let entry of entries) { 54 | const table = this.readTable(entry); 55 | sfnt.addTable(entry.tag, table, entry.origChecksum); 56 | } 57 | return sfnt; 58 | } 59 | 60 | readHeader(): Header { 61 | const signature = this.reader.readULong(); 62 | if (signature !== WOFF_SIGNATURE) { 63 | throw new Error('Invalid WOFF signature: ' + signature); 64 | } 65 | const flavor = this.reader.readULong(); 66 | if (!isOtfFont(flavor)) { 67 | throw new Error('Unknown flavor: ' + flavor); 68 | } 69 | const length = this.reader.readULong(); 70 | if (this.reader.length() !== length) { 71 | throw new Error('Invalid length in header: ' + length); 72 | } 73 | const numTables = this.reader.readUShort(); 74 | this.reader.skip(2); // reserved 75 | const totalSfntSize = this.reader.readULong(); 76 | this.reader.skip(24); // skip version, metadata and private fields 77 | return { 78 | signature: signature, 79 | flavor: flavor, 80 | length: length, 81 | numTables: numTables, 82 | totalSfntSize: totalSfntSize 83 | }; 84 | } 85 | 86 | readTableEntries(numTables: number): Array { 87 | const entries = []; 88 | for (let i = 0; i < numTables; i++) { 89 | const tag = this.reader.readULong(); 90 | const offset = this.reader.readULong(); 91 | const compLength = this.reader.readULong(); 92 | const origLength = this.reader.readULong(); 93 | const origChecksum = this.reader.readULong(); 94 | entries.push({ 95 | tag: tag, 96 | offset: offset, 97 | compLength: compLength, 98 | origLength: origLength, 99 | origChecksum: origChecksum 100 | }); 101 | } 102 | return entries; 103 | } 104 | 105 | private readTableData(entry: TableEntry): Uint8Array { 106 | const tableData = this.reader.uint8ArrayFor(entry.offset, entry.compLength); 107 | if (entry.compLength === entry.origLength) { 108 | return tableData; 109 | } 110 | const uncompressed = uncompressTable(tableData); 111 | if (uncompressed.byteLength !== entry.origLength) { 112 | throw new Error( 113 | 'uncompressed size mismatch: ' + tableData.byteLength + ' != ' + entry.origLength 114 | ); 115 | } 116 | return uncompressed; 117 | } 118 | 119 | private readTable(entry: TableEntry): Uint8Array { 120 | const tableData = this.readTableData(entry); 121 | if (entry.tag !== TAG_HEAD) { 122 | const checksum = calculateTableChecksum(tableData); 123 | if (checksum !== entry.origChecksum) { 124 | throw new Error(`checksum mismatch: ${checksum} != ${entry.origChecksum}`); 125 | } 126 | } 127 | return tableData; 128 | } 129 | } 130 | 131 | interface WoffTableEntry { 132 | tag: number; 133 | offset: number; 134 | compLength: number; 135 | origTableLength: number; 136 | origChecksum: number; 137 | } 138 | 139 | interface WriteTablesResult { 140 | totalSize: number; 141 | entries: Array; 142 | } 143 | 144 | export class WoffBuilder { 145 | private sfnt: Sfnt; 146 | private writer: Writer; 147 | 148 | constructor(sfnt: Sfnt) { 149 | this.sfnt = sfnt; 150 | this.writer = new Writer(); 151 | } 152 | 153 | private writeHeader(numTables: number, totalLength: number, totalSfntSize: number) { 154 | this.writer.writeULong(WOFF_SIGNATURE); 155 | this.writer.writeULong(this.sfnt.getSfntVersion()); 156 | this.writer.writeULong(totalLength); 157 | this.writer.writeUShort(numTables); 158 | this.writer.writeUShort(0); // reserved 159 | this.writer.writeULong(totalSfntSize); 160 | this.writer.writeULong(0); // major and minor version (don't care) 161 | this.writer.writeULong(0); // metaOffset 162 | this.writer.writeULong(0); // metaLength 163 | this.writer.writeULong(0); // metaOrigLength 164 | this.writer.writeULong(0); // privOffset 165 | this.writer.writeULong(0); // privLength 166 | } 167 | 168 | private writeTablesAndBuildEntries(): WriteTablesResult { 169 | let totalSize = 0; 170 | const entries = []; 171 | const tags = this.sfnt.getTags(); 172 | for (var i = 0; i < tags.length; i++) { 173 | const tag = tags[i]; 174 | const offset = this.writer.getPosition(); 175 | const origTable = this.sfnt.getTableByTag(tag)!; 176 | const table = this.maybeCompressTable(origTable.data); 177 | const compLength = 178 | table.byteLength < origTable.length() ? table.byteLength : origTable.length(); 179 | var entry = { 180 | tag: tag, 181 | offset: offset, 182 | compLength: compLength, 183 | origTableLength: origTable.length(), 184 | origChecksum: origTable.checksum 185 | }; 186 | entries.push(entry); 187 | totalSize += origTable.paddedLength(); 188 | this.writeTable(table); 189 | } 190 | return { 191 | totalSize: totalSize, 192 | entries: entries 193 | }; 194 | } 195 | 196 | private maybeCompressTable(orig: Uint8Array): Uint8Array { 197 | const compressed = compressTable(orig); 198 | if (compressed.byteLength < orig.byteLength) { 199 | return compressed; 200 | } 201 | return orig; 202 | } 203 | 204 | private writeTable(table: Uint8Array) { 205 | this.writer.writeBytes(table); 206 | const padLength = 4 - (table.byteLength & 3); 207 | if (padLength > 0) { 208 | this.writer.pad(padLength); 209 | } 210 | } 211 | 212 | build(): Uint8Array { 213 | const numTables = this.sfnt.numTables(); 214 | const totalSfntSize = SFNT_HEADER_SIZE + SFNT_TABLE_ENTRY_SIZE * numTables; 215 | const tableStartPosition = WOFF_HEADER_SIZE + WOFF_TABLE_ENTRY_SIZE * numTables; 216 | this.writer.seek(tableStartPosition); 217 | const res = this.writeTablesAndBuildEntries(); 218 | 219 | // TODO: Support metadata and private data. 220 | 221 | this.writer.seek(0); 222 | this.writeHeader(numTables, res.totalSize, totalSfntSize); 223 | this.writeTablesAndBuildEntries(); 224 | return this.writer.result(); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/woff2.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as Module from '../dist/ffi.js'; 3 | 4 | // TODO: More error handling? 5 | 6 | export class Woff2 { 7 | private mod: any; // |mod| is an Emscripten Module. 8 | 9 | constructor(mod: any) { 10 | this.mod = mod; 11 | } 12 | 13 | compress(data: Uint8Array): Uint8Array { 14 | const inSize = data.byteLength; 15 | const inOffset = this.mod._malloc(inSize); 16 | this.mod.HEAPU8.set(data, inOffset); 17 | 18 | const maxOutSize = this.mod.ccall( 19 | 'get_max_compressed_size', 20 | 'number', 21 | ['number, number'], 22 | [inOffset, inSize] 23 | ); 24 | 25 | const output = new Uint8Array(maxOutSize); 26 | const outOffset = this.mod._malloc(maxOutSize); 27 | this.mod.HEAPU8.set(output, outOffset); 28 | const outSize = this.mod.ccall( 29 | 'ttf_to_woff2', 30 | 'number', 31 | ['number', 'number', 'number', 'number'], 32 | [inOffset, inSize, outOffset, maxOutSize] 33 | ); 34 | 35 | if (outSize === 0) { 36 | throw new Error('woff2: Failed to compress'); 37 | } 38 | const res = this.mod.HEAPU8.subarray(outOffset, outOffset + outSize).slice(0); 39 | 40 | this.mod._free(inOffset); 41 | this.mod._free(outOffset); 42 | return res; 43 | } 44 | 45 | uncompress(data: Uint8Array): Uint8Array { 46 | const inSize = data.byteLength; 47 | const inOffset = this.mod._malloc(inSize); 48 | this.mod.HEAPU8.set(data, inOffset); 49 | 50 | const uncompressSize = this.mod.ccall( 51 | 'get_uncompressed_size', 52 | 'number', 53 | ['number, number'], 54 | [inOffset, inSize] 55 | ); 56 | const output = new Uint8Array(uncompressSize); 57 | const outOffset = this.mod._malloc(uncompressSize); 58 | this.mod.HEAPU8.set(output, outOffset); 59 | const outSize = this.mod.ccall( 60 | 'woff2_to_ttf', 61 | 'number', 62 | ['number', 'number', 'number', 'number'], 63 | [outOffset, uncompressSize, inOffset, inSize] 64 | ); 65 | 66 | if (outSize === 0) { 67 | throw new Error('woff2: Failed to uncompress'); 68 | } 69 | const res = this.mod.HEAPU8.subarray(outOffset, outOffset + outSize).slice(0); 70 | 71 | this.mod._free(inOffset); 72 | this.mod._free(outOffset); 73 | return res; 74 | } 75 | } 76 | 77 | export function createWoff2(wasmBinary: Uint8Array): Promise { 78 | return new Promise((resolve, reject) => { 79 | let mod: any = null; 80 | const args = { 81 | wasmBinary: wasmBinary, 82 | onRuntimeInitialized: () => { 83 | resolve(new Woff2(mod)); 84 | } 85 | }; 86 | mod = new Module(args); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import { Format, getFontFormat, isValidFormat } from './format'; 2 | import { Converter } from './convert'; 3 | import { Woff2, createWoff2 } from './woff2'; 4 | 5 | async function loadWoff2Wasm(): Promise { 6 | return fetch('ffi.wasm') 7 | .then(response => response.arrayBuffer()) 8 | .then(buffer => { 9 | const wasmBinary = new Uint8Array(buffer); 10 | return createWoff2(wasmBinary); 11 | }); 12 | } 13 | 14 | function convert(converter: Converter, data: Uint8Array, format: Format): Uint8Array { 15 | if (format === Format.OTF) { 16 | return converter.toOtf(data); 17 | } else if (format === Format.WOFF) { 18 | return converter.toWoff(data); 19 | } else if (format === Format.WOFF2) { 20 | return converter.toWoff2(data); 21 | } else { 22 | throw new Error('Unsupported output file format'); 23 | } 24 | } 25 | 26 | let converter: Converter | null = null; 27 | 28 | function handleMessage(messageId: number, e: MessageEvent) { 29 | if (!converter) { 30 | throw new Error('Worker not initialized'); 31 | } 32 | 33 | const action = e.data.action; 34 | if (action === 'convert') { 35 | const format = e.data.format; 36 | if (!isValidFormat(format)) { 37 | throw new Error(`Invalid output font format: ${format}`); 38 | } 39 | const input = e.data.input; 40 | const inputFormat = getFontFormat(input); 41 | if (inputFormat === Format.UNSUPPORTED) { 42 | throw new Error(`Unsupported font`); 43 | } 44 | const output = convert(converter, input, format); 45 | const response = { 46 | output: output 47 | }; 48 | // TODO: Figure out why transferring doesn't work other than Chrome. 49 | // Transferring doesn't work only when converting to woff2, so emscripten's 50 | // memory system may be related. 51 | // @ts-ignore: self is DedicatedWorkerGlobalScope 52 | self.postMessage({ messageId: messageId, response: response }); 53 | } 54 | } 55 | 56 | self.addEventListener('message', async e => { 57 | // Special case for initialization. 58 | if (e.data === 'init') { 59 | const wasmBinary = await loadWoff2Wasm(); 60 | converter = new Converter(wasmBinary); 61 | // @ts-ignore: self is DedicatedWorkerGlobalScope 62 | self.postMessage('initialized'); 63 | return; 64 | } 65 | 66 | const messageId = e.data.messageId; 67 | if (typeof messageId !== 'number') { 68 | // @ts-ignore: self is DedicatedWorkerGlobalScope 69 | self.postMessage({ error: 'Received a message without messageId' }); 70 | return; 71 | } 72 | 73 | try { 74 | handleMessage(messageId, e); 75 | } catch (exception) { 76 | console.error(exception); 77 | // @ts-ignore: self is DedicatedWorkerGlobalScope 78 | self.postMessage({ messageId: messageId, error: exception.message }); 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /src/writer.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_INITIAL_BUFSIZE = 128 * 1024; 2 | 3 | export class Writer { 4 | private position: number; 5 | private length: number; 6 | private array: Uint8Array; 7 | private view: DataView; 8 | 9 | constructor(bufsize: number = DEFAULT_INITIAL_BUFSIZE) { 10 | this.position = 0; 11 | this.length = 0; 12 | this.array = new Uint8Array(bufsize); 13 | this.view = new DataView(this.array.buffer); 14 | } 15 | 16 | private expandBufferIfNeeded(addingSize: number) { 17 | while (this.array.byteLength < addingSize + this.position) { 18 | const newArray = new Uint8Array(this.array.byteLength * 2); 19 | newArray.set(this.array); 20 | this.array = newArray; 21 | this.view = new DataView(newArray.buffer); 22 | } 23 | } 24 | 25 | private advanceLengthIfNeeded() { 26 | if (this.length < this.position) { 27 | this.length = this.position; 28 | } 29 | } 30 | 31 | writeByte(value: number) { 32 | this.expandBufferIfNeeded(1); 33 | this.view.setUint8(this.position, value); 34 | this.position += 1; 35 | this.advanceLengthIfNeeded(); 36 | } 37 | 38 | writeBytes(uint8values: Uint8Array) { 39 | this.expandBufferIfNeeded(uint8values.byteLength); 40 | this.array.set(uint8values, this.position); 41 | this.position += uint8values.byteLength; 42 | this.advanceLengthIfNeeded(); 43 | } 44 | 45 | writeUShort(value: number) { 46 | this.expandBufferIfNeeded(2); 47 | this.view.setUint16(this.position, value, false); 48 | this.position += 2; 49 | this.advanceLengthIfNeeded(); 50 | } 51 | 52 | writeShort(value: number) { 53 | this.expandBufferIfNeeded(2); 54 | this.view.setInt16(this.position, value, false); 55 | this.position += 2; 56 | this.advanceLengthIfNeeded(); 57 | } 58 | 59 | writeULong(value: number) { 60 | this.expandBufferIfNeeded(4); 61 | this.view.setUint32(this.position, value, false); 62 | this.position += 4; 63 | this.advanceLengthIfNeeded(); 64 | } 65 | 66 | writeLong(value: number) { 67 | this.expandBufferIfNeeded(4); 68 | this.view.setInt32(this.position, value, false); 69 | this.position += 4; 70 | this.advanceLengthIfNeeded(); 71 | } 72 | 73 | pad(length: number) { 74 | this.expandBufferIfNeeded(length); 75 | for (let i = 0; i < length; i++) this.view.setUint8(this.position++, 0); 76 | this.advanceLengthIfNeeded(); 77 | } 78 | 79 | seek(position: number) { 80 | this.position = position; 81 | } 82 | 83 | getPosition(): number { 84 | return this.position; 85 | } 86 | 87 | dataView(): DataView { 88 | return new DataView(this.array.buffer, 0, this.length); 89 | } 90 | 91 | result(): Uint8Array { 92 | const data = this.array.subarray(0, this.length); 93 | return data; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /test/data/ahem/AHEM____.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/test/data/ahem/AHEM____.TTF -------------------------------------------------------------------------------- /test/data/ahem/AHEM____.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/test/data/ahem/AHEM____.woff -------------------------------------------------------------------------------- /test/data/ahem/AHEM____.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bashi/kombu/4f3d000fc67e64fc70e8640b63720fda238b3935/test/data/ahem/AHEM____.woff2 -------------------------------------------------------------------------------- /test/data/ahem/COPYING: -------------------------------------------------------------------------------- 1 | The Ahem font in this directory belongs to the public domain. In 2 | jurisdictions that do not recognize public domain ownership of these 3 | files, the following Creative Commons Zero declaration applies: 4 | 5 | 6 | 7 | which is quoted below: 8 | 9 | The person who has associated a work with this document (the "Work") 10 | affirms that he or she (the "Affirmer") is the/an author or owner of 11 | the Work. The Work may be any work of authorship, including a 12 | database. 13 | 14 | The Affirmer hereby fully, permanently and irrevocably waives and 15 | relinquishes all of her or his copyright and related or neighboring 16 | legal rights in the Work available under any federal or state law, 17 | treaty or contract, including but not limited to moral rights, 18 | publicity and privacy rights, rights protecting against unfair 19 | competition and any rights protecting the extraction, dissemination 20 | and reuse of data, whether such rights are present or future, vested 21 | or contingent (the "Waiver"). The Affirmer makes the Waiver for the 22 | benefit of the public at large and to the detriment of the Affirmer's 23 | heirs or successors. 24 | 25 | The Affirmer understands and intends that the Waiver has the effect 26 | of eliminating and entirely removing from the Affirmer's control all 27 | the copyright and related or neighboring legal rights previously held 28 | by the Affirmer in the Work, to that extent making the Work freely 29 | available to the public for any and all uses and purposes without 30 | restriction of any kind, including commercial use and uses in media 31 | and formats or by methods that have not yet been invented or 32 | conceived. Should the Waiver for any reason be judged legally 33 | ineffective in any jurisdiction, the Affirmer hereby grants a free, 34 | full, permanent, irrevocable, nonexclusive and worldwide license for 35 | all her or his copyright and related or neighboring legal rights in 36 | the Work. 37 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as mocha from 'mocha'; 4 | import { assert } from 'chai'; 5 | 6 | import { getFontFormat } from '../src/format'; 7 | import { Converter } from '../src/convert'; 8 | import { createWoff2 } from '../src/woff2'; 9 | 10 | function readAsUint8Array(pathname: string): Uint8Array { 11 | const buffer = fs.readFileSync(pathname); 12 | return new Uint8Array(buffer); 13 | } 14 | 15 | describe('Converter', () => { 16 | let converter: Converter; 17 | 18 | before(async () => { 19 | const wasmPath = path.resolve(__dirname, '..', 'dist', 'ffi.wasm'); 20 | const arr = readAsUint8Array(wasmPath); 21 | const woff2Obj = await createWoff2(arr); 22 | converter = new Converter(woff2Obj); 23 | }); 24 | 25 | it('otf to woff', async () => { 26 | const inputPath = path.resolve(__dirname, 'data', 'ahem', 'AHEM____.TTF'); 27 | const inputData = readAsUint8Array(inputPath); 28 | const output = converter.toWoff(inputData); 29 | assert(output instanceof Uint8Array); 30 | assert.equal(getFontFormat(output), 'woff'); 31 | }); 32 | 33 | it('otf to woff2', async () => { 34 | const inputPath = path.resolve(__dirname, 'data', 'ahem', 'AHEM____.TTF'); 35 | const inputData = readAsUint8Array(inputPath); 36 | const output = converter.toWoff2(inputData); 37 | assert(output instanceof Uint8Array); 38 | assert.equal(getFontFormat(output), 'woff2'); 39 | }); 40 | 41 | it('woff to otf', async () => { 42 | const inputPath = path.resolve(__dirname, 'data', 'ahem', 'AHEM____.woff'); 43 | const inputData = readAsUint8Array(inputPath); 44 | const output = converter.toOtf(inputData); 45 | assert(output instanceof Uint8Array); 46 | assert.equal(getFontFormat(output), 'otf'); 47 | }); 48 | 49 | it('woff to woff2', async () => { 50 | const inputPath = path.resolve(__dirname, 'data', 'ahem', 'AHEM____.woff'); 51 | const inputData = readAsUint8Array(inputPath); 52 | const output = converter.toWoff2(inputData); 53 | assert(output instanceof Uint8Array); 54 | assert.equal(getFontFormat(output), 'woff2'); 55 | }); 56 | 57 | it('woff2 to otf', async () => { 58 | const inputPath = path.resolve(__dirname, 'data', 'ahem', 'AHEM____.woff2'); 59 | const inputData = readAsUint8Array(inputPath); 60 | const output = converter.toOtf(inputData); 61 | assert(output instanceof Uint8Array); 62 | assert.equal(getFontFormat(output), 'otf'); 63 | }); 64 | 65 | it('woff2 to woff', async () => { 66 | const inputPath = path.resolve(__dirname, 'data', 'ahem', 'AHEM____.woff2'); 67 | const inputData = readAsUint8Array(inputPath); 68 | const output = converter.toWoff(inputData); 69 | assert(output instanceof Uint8Array); 70 | assert.equal(getFontFormat(output), 'woff'); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "outDir": "./dist/", 5 | "strict": true, 6 | "module": "commonjs", 7 | "allowJs": false, 8 | "target": "es2015" 9 | }, 10 | "exclude": [ 11 | "woff2", "node_modules" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const WorkboxPlugin = require('workbox-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: { 7 | app: './src/app.ts', 8 | worker: './src/worker.ts' 9 | }, 10 | output: { 11 | path: path.resolve(__dirname, 'public'), 12 | filename: '[name].js' 13 | }, 14 | resolve: { 15 | extensions: ['.ts', 'js'] 16 | }, 17 | module: { 18 | rules: [{ test: /\.ts$/, loader: 'ts-loader' }] 19 | }, 20 | plugins: [ 21 | new WorkboxPlugin.GenerateSW({ 22 | swDest: path.resolve(__dirname, 'public/service-worker.js'), 23 | runtimeCaching: [ 24 | { 25 | urlPattern: /\.(?:wasm|js|html|css)$/, 26 | handler: 'networkOnly' 27 | } 28 | ] 29 | }) 30 | ], 31 | node: { 32 | fs: 'empty' 33 | } 34 | }; 35 | --------------------------------------------------------------------------------