KarelWintersky revised this gist . Go to revision
2 files changed, 407 insertions
google-font-downloader.js(file created)
| @@ -0,0 +1,322 @@ | |||
| 1 | + | #!/usr/bin/env node | |
| 2 | + | ||
| 3 | + | /* | |
| 4 | + | based on https://github.com/Bloggify/google-font-downloader | |
| 5 | + | with deepseek fixes | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | "use strict"; | |
| 9 | + | ||
| 10 | + | const Tilda = require("tilda") | |
| 11 | + | , WritableStream = require("streamp").writable | |
| 12 | + | , tinyreq = require("tinyreq") | |
| 13 | + | , matchAll = require("match-all") | |
| 14 | + | , path = require("path") | |
| 15 | + | , crypto = require("crypto") | |
| 16 | + | ; | |
| 17 | + | ||
| 18 | + | const USER_AGENT = "User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36" | |
| 19 | + | ||
| 20 | + | new Tilda(`${__dirname}/package.json`, { | |
| 21 | + | args: [ | |
| 22 | + | { | |
| 23 | + | name: "url" | |
| 24 | + | , desc: "The Google APIs url." | |
| 25 | + | , required: true | |
| 26 | + | } | |
| 27 | + | ], | |
| 28 | + | examples: [ | |
| 29 | + | "google-font-downloader https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700,700i", | |
| 30 | + | "google-font-downloader https://fonts.googleapis.com/css?family=Roboto:300,400,500 --debug" | |
| 31 | + | ] | |
| 32 | + | }) | |
| 33 | + | .option([ | |
| 34 | + | { | |
| 35 | + | opts: ["debug"] | |
| 36 | + | , desc: "Save original downloaded CSS for debugging." | |
| 37 | + | , name: "debug" | |
| 38 | + | , type: Boolean | |
| 39 | + | , default: false | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | opts: ["directory", "d"], | |
| 43 | + | desc: "Directory where files are stored", | |
| 44 | + | name: "directory", | |
| 45 | + | default: "./fonts", | |
| 46 | + | }, { | |
| 47 | + | opts: ["timestamp", "t"], | |
| 48 | + | desc: "Add a timestamp to the stylesheet file (default: 1)", | |
| 49 | + | name: "timestamp", | |
| 50 | + | default: 1, | |
| 51 | + | }, { | |
| 52 | + | opts: ["scss", "s"], | |
| 53 | + | desc: "Use a scss-extension for the stylesheet for inclusion in a scss-project (default: 0)", | |
| 54 | + | name: "scss", | |
| 55 | + | default: 0, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | ]) | |
| 59 | + | .main(action => { | |
| 60 | + | const url = action.args.url; | |
| 61 | + | const debug = action.options.debug.value; | |
| 62 | + | const data = {}; | |
| 63 | + | ||
| 64 | + | const font_directory = action.options.directory.value; | |
| 65 | + | const timestamp = action.options.timestamp.value; | |
| 66 | + | const scss = action.options.scss.value; | |
| 67 | + | ||
| 68 | + | console.log(`Getting the external CSS: ${url}`); | |
| 69 | + | ||
| 70 | + | if (debug) { | |
| 71 | + | console.log("Debug mode: ON - original CSS will be saved") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Функция для определения набора символов на основе комментария и unicode-range | |
| 75 | + | function determineCharSet(comment, unicodeRange) { | |
| 76 | + | // Сначала проверяем комментарий | |
| 77 | + | const commentLower = comment.toLowerCase(); | |
| 78 | + | ||
| 79 | + | if (commentLower.includes('vietnamese')) return 'vietnamese'; | |
| 80 | + | if (commentLower.includes('cyrillic-ext')) return 'cyrillic-ext'; | |
| 81 | + | if (commentLower.includes('cyrillic')) return 'cyrillic'; | |
| 82 | + | if (commentLower.includes('greek-ext')) return 'greek-ext'; | |
| 83 | + | if (commentLower.includes('greek')) return 'greek'; | |
| 84 | + | if (commentLower.includes('latin-ext')) return 'latin-ext'; | |
| 85 | + | if (commentLower.includes('latin')) return 'latin'; | |
| 86 | + | if (commentLower.includes('arabic')) return 'arabic'; | |
| 87 | + | if (commentLower.includes('hebrew')) return 'hebrew'; | |
| 88 | + | if (commentLower.includes('thai')) return 'thai'; | |
| 89 | + | if (commentLower.includes('devanagari')) return 'devanagari'; | |
| 90 | + | if (commentLower.includes('bengali')) return 'bengali'; | |
| 91 | + | if (commentLower.includes('tamil')) return 'tamil'; | |
| 92 | + | if (commentLower.includes('telugu')) return 'telugu'; | |
| 93 | + | if (commentLower.includes('kannada')) return 'kannada'; | |
| 94 | + | if (commentLower.includes('malayalam')) return 'malayalam'; | |
| 95 | + | if (commentLower.includes('gujarati')) return 'gujarati'; | |
| 96 | + | if (commentLower.includes('oriya')) return 'oriya'; | |
| 97 | + | if (commentLower.includes('gurmukhi')) return 'gurmukhi'; | |
| 98 | + | ||
| 99 | + | // Если в комментарии нет информации, анализируем unicode-range | |
| 100 | + | if (unicodeRange) { | |
| 101 | + | if (unicodeRange.includes('U+0102-0103') || unicodeRange.includes('U+1EA0-1EF9')) return 'vietnamese'; | |
| 102 | + | if (unicodeRange.includes('U+0460-052F') || unicodeRange.includes('U+20B4') || unicodeRange.includes('U+2DE0-2DFF') || unicodeRange.includes('U+A640-A69F')) return 'cyrillic-ext'; | |
| 103 | + | if (unicodeRange.includes('U+0400-04FF') || unicodeRange.includes('U+0500-052F')) return 'cyrillic'; | |
| 104 | + | if (unicodeRange.includes('U+1F00-1FFF')) return 'greek-ext'; | |
| 105 | + | if (unicodeRange.includes('U+0370-03FF')) return 'greek'; | |
| 106 | + | if (unicodeRange.includes('U+0100-024F') || unicodeRange.includes('U+0259') || unicodeRange.includes('U+1E00-1EFF') || unicodeRange.includes('U+2020') || unicodeRange.includes('U+20A0-20AB') || unicodeRange.includes('U+20AD-20CF') || unicodeRange.includes('U+2113') || unicodeRange.includes('U+2C60-2C7F') || unicodeRange.includes('U+A720-A7FF')) return 'latin-ext'; | |
| 107 | + | if (unicodeRange.includes('U+0000-00FF') || unicodeRange.includes('U+0131') || unicodeRange.includes('U+0152-0153') || unicodeRange.includes('U+02BB-02BC') || unicodeRange.includes('U+02C6') || unicodeRange.includes('U+02DA') || unicodeRange.includes('U+02DC') || unicodeRange.includes('U+2000-206F') || unicodeRange.includes('U+2074') || unicodeRange.includes('U+20AC') || unicodeRange.includes('U+2122') || unicodeRange.includes('U+2191') || unicodeRange.includes('U+2193') || unicodeRange.includes('U+2212') || unicodeRange.includes('U+2215') || unicodeRange.includes('U+FEFF') || unicodeRange.includes('U+FFFD')) return 'latin'; | |
| 108 | + | if (unicodeRange.includes('U+0600-06FF') || unicodeRange.includes('U+0750-077F') || unicodeRange.includes('U+08A0-08FF') || unicodeRange.includes('U+FB50-FDFF') || unicodeRange.includes('U+FE70-FEFF') || unicodeRange.includes('U+1EE00-1EEFF')) return 'arabic'; | |
| 109 | + | if (unicodeRange.includes('U+0590-05FF') || unicodeRange.includes('U+FB1D-FB4F')) return 'hebrew'; | |
| 110 | + | if (unicodeRange.includes('U+0E00-0E7F')) return 'thai'; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | return 'latin'; // fallback | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Функция для добавления src-original в CSS | |
| 117 | + | function addSrcOriginal(css, fontUrl, localPath) { | |
| 118 | + | // Ищем блок @font-face, который содержит этот URL | |
| 119 | + | const fontFaceRegex = new RegExp(`(@font-face\\s*\\{[^}]*?url\\([^)]*?${fontUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^)]*?\\)[^}]*?\\})`, 'g'); | |
| 120 | + | ||
| 121 | + | return css.replace(fontFaceRegex, (fontFaceBlock) => { | |
| 122 | + | // Добавляем src-original после src | |
| 123 | + | return fontFaceBlock.replace(/(src:[^;]+;)/, `$1\n src-original: url(${fontUrl});`); | |
| 124 | + | }); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | tinyreq({ | |
| 128 | + | url, | |
| 129 | + | headers: { | |
| 130 | + | "user-agent": USER_AGENT | |
| 131 | + | } | |
| 132 | + | }).then(body => { | |
| 133 | + | const matchFontFilesRegex = /url\((https\:\/\/fonts\.gstatic\.com\/.*)\) format/gm | |
| 134 | + | // Регулярное выражение для извлечения информации о шрифте из CSS | |
| 135 | + | const fontBlockRegex = /\/\*\s*(.*?)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g | |
| 136 | + | const fontFamilyRegex = /font-family:\s*['"]?(.*?)['"]?;/i | |
| 137 | + | const fontStyleRegex = /font-style:\s*(\w+);/i | |
| 138 | + | const fontWeightRegex = /font-weight:\s*(\d+);/i | |
| 139 | + | const unicodeRangeRegex = /unicode-range:\s*([^;]+);/i | |
| 140 | + | ||
| 141 | + | data.original_stylesheet = body | |
| 142 | + | data.local_stylesheet = body | |
| 143 | + | data.font_urls = matchAll(body, matchFontFilesRegex).toArray() | |
| 144 | + | ||
| 145 | + | // Сохраняем оригинальный CSS если включен debug режим | |
| 146 | + | if (debug) { | |
| 147 | + | const originalCssFileName = `google-fonts-original-${Date.now()}.css` | |
| 148 | + | const originalCssStream = new WritableStream(originalCssFileName) | |
| 149 | + | console.log(`Debug: Saving original CSS to ${originalCssFileName}`) | |
| 150 | + | originalCssStream.end(data.original_stylesheet) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Создаем массив для хранения информации о шрифтах | |
| 154 | + | data.fonts = [] | |
| 155 | + | ||
| 156 | + | // Извлекаем информацию о каждом шрифте из CSS блоков @font-face | |
| 157 | + | let fontBlockMatch | |
| 158 | + | let fontUrlIndex = 0 | |
| 159 | + | ||
| 160 | + | while ((fontBlockMatch = fontBlockRegex.exec(body)) !== null) { | |
| 161 | + | if (fontUrlIndex >= data.font_urls.length) break; | |
| 162 | + | ||
| 163 | + | const comment = fontBlockMatch[1] || "" | |
| 164 | + | const fontFaceContent = fontBlockMatch[2] | |
| 165 | + | ||
| 166 | + | const familyMatch = fontFaceContent.match(fontFamilyRegex) | |
| 167 | + | const styleMatch = fontFaceContent.match(fontStyleRegex) | |
| 168 | + | const weightMatch = fontFaceContent.match(fontWeightRegex) | |
| 169 | + | const unicodeMatch = fontFaceContent.match(unicodeRangeRegex) | |
| 170 | + | ||
| 171 | + | if (familyMatch && data.font_urls[fontUrlIndex]) { | |
| 172 | + | const fontFamily = familyMatch[1].replace(/\s+/g, '_').toLowerCase() | |
| 173 | + | const fontStyle = (styleMatch && styleMatch[1]) ? styleMatch[1] : 'normal' | |
| 174 | + | const fontWeight = (weightMatch && weightMatch[1]) ? weightMatch[1] : '400' | |
| 175 | + | const unicodeRange = unicodeMatch ? unicodeMatch[1] : null | |
| 176 | + | ||
| 177 | + | // Определяем набор символов используя улучшенную функцию | |
| 178 | + | const charSet = determineCharSet(comment, unicodeRange) | |
| 179 | + | ||
| 180 | + | // Формируем понятное имя файла в новом формате | |
| 181 | + | const fileExtension = path.extname(data.font_urls[fontUrlIndex].split('?')[0]) || '.woff2' | |
| 182 | + | const fileName = `${fontFamily}.${fontWeight}.${charSet}.${fontStyle}${fileExtension}` | |
| 183 | + | const localPath = `fonts/${fileName}` | |
| 184 | + | ||
| 185 | + | data.fonts.push({ | |
| 186 | + | remote: data.font_urls[fontUrlIndex], | |
| 187 | + | local: localPath, | |
| 188 | + | family: fontFamily, | |
| 189 | + | weight: fontWeight, | |
| 190 | + | style: fontStyle, | |
| 191 | + | charSet: charSet, | |
| 192 | + | comment: comment, | |
| 193 | + | unicodeRange: unicodeRange | |
| 194 | + | }) | |
| 195 | + | ||
| 196 | + | if (debug) { | |
| 197 | + | console.log(`Debug: Font ${fontUrlIndex + 1} - Comment: "${comment}", CharSet: ${charSet}`) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fontUrlIndex++ | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Если не удалось извлечь информацию через CSS блоки, используем альтернативный метод | |
| 205 | + | if (data.fonts.length === 0) { | |
| 206 | + | console.log("Using alternative font naming method...") | |
| 207 | + | data.fonts = data.font_urls.map((url, index) => { | |
| 208 | + | // Пытаемся извлесть информацию из URL | |
| 209 | + | const urlParts = url.split('/') | |
| 210 | + | const fontFileName = urlParts[urlParts.length - 1].split('?')[0] | |
| 211 | + | ||
| 212 | + | // Разбираем имя файла на компоненты | |
| 213 | + | const nameParts = fontFileName.replace('.woff2', '').split('-') | |
| 214 | + | let fontFamily = 'font' | |
| 215 | + | let fontWeight = '400' | |
| 216 | + | let fontStyle = 'normal' | |
| 217 | + | let charSet = 'latin' | |
| 218 | + | ||
| 219 | + | if (nameParts.length >= 2) { | |
| 220 | + | fontFamily = nameParts[0] | |
| 221 | + | const styleWeight = nameParts[1] | |
| 222 | + | ||
| 223 | + | // Пытаемся определить вес и стиль | |
| 224 | + | if (styleWeight.includes('italic')) { | |
| 225 | + | fontStyle = 'italic' | |
| 226 | + | fontWeight = styleWeight.replace('italic', '') || '400' | |
| 227 | + | } else { | |
| 228 | + | fontWeight = styleWeight | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Пытаемся определить набор символов из имени файла | |
| 232 | + | if (nameParts.some(part => part.includes('vietnamese'))) charSet = 'vietnamese' | |
| 233 | + | else if (nameParts.some(part => part.includes('cyrillic'))) charSet = 'cyrillic' | |
| 234 | + | else if (nameParts.some(part => part.includes('greek'))) charSet = 'greek' | |
| 235 | + | else if (nameParts.some(part => part.includes('latin'))) charSet = 'latin' | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | const fileExtension = path.extname(url.split('?')[0]) || '.woff2' | |
| 239 | + | const fileName = `${fontFamily}.${fontWeight}.${charSet}.${fontStyle}${fileExtension}` | |
| 240 | + | const localPath = `${font_directory}/${fileName}` | |
| 241 | + | ||
| 242 | + | return { | |
| 243 | + | remote: url, | |
| 244 | + | local: localPath, | |
| 245 | + | family: fontFamily, | |
| 246 | + | weight: fontWeight, | |
| 247 | + | style: fontStyle, | |
| 248 | + | charSet: charSet | |
| 249 | + | } | |
| 250 | + | }) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | console.log(`Detected ${data.fonts.length} font files to download.`) | |
| 254 | + | ||
| 255 | + | // Проверяем уникальность имен файлов | |
| 256 | + | const fileNames = new Set() | |
| 257 | + | const duplicates = [] | |
| 258 | + | ||
| 259 | + | data.fonts.forEach(font => { | |
| 260 | + | if (fileNames.has(font.local)) { | |
| 261 | + | duplicates.push(font.local) | |
| 262 | + | } | |
| 263 | + | fileNames.add(font.local) | |
| 264 | + | }) | |
| 265 | + | ||
| 266 | + | if (duplicates.length > 0) { | |
| 267 | + | console.log(`Warning: Found ${duplicates.length} duplicate file names. Adding unique identifiers.`) | |
| 268 | + | // Добавляем суффиксы к дублирующимся файлам | |
| 269 | + | const nameCount = {} | |
| 270 | + | data.fonts.forEach(font => { | |
| 271 | + | if (nameCount[font.local]) { | |
| 272 | + | nameCount[font.local]++ | |
| 273 | + | const newLocal = font.local.replace(/\.(woff2|woff|ttf)$/, `.${nameCount[font.local]}.$1`) | |
| 274 | + | data.local_stylesheet = data.local_stylesheet.replace(font.local, newLocal) | |
| 275 | + | font.local = newLocal | |
| 276 | + | } else { | |
| 277 | + | nameCount[font.local] = 1 | |
| 278 | + | } | |
| 279 | + | }) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | return Promise.all(data.fonts.map(c => { | |
| 283 | + | // Сначала добавляем src-original | |
| 284 | + | data.local_stylesheet = addSrcOriginal(data.local_stylesheet, c.remote, c.local) | |
| 285 | + | // Затем заменяем URL на локальный путь | |
| 286 | + | data.local_stylesheet = data.local_stylesheet.replace(c.remote, c.local) | |
| 287 | + | ||
| 288 | + | return new Promise(res => { | |
| 289 | + | const req = tinyreq({ url: c.remote, encoding: null, headers: { "user-agent": USER_AGENT } }) | |
| 290 | + | , stream = new WritableStream(c.local) | |
| 291 | + | ||
| 292 | + | req.on("data", data => { | |
| 293 | + | stream.write(data) | |
| 294 | + | }).on("error", e => { | |
| 295 | + | console.error("Failed to download " + c.remote) | |
| 296 | + | console.error(e) | |
| 297 | + | res() | |
| 298 | + | }).on("end", () => { | |
| 299 | + | console.log(`Downloaded ${c.remote} as ${c.local}`) | |
| 300 | + | stream.end() | |
| 301 | + | res() | |
| 302 | + | }) | |
| 303 | + | }) | |
| 304 | + | })) | |
| 305 | + | }).then(() => { | |
| 306 | + | // const ts = timestamp ? `-${Date.now()}` : ''; | |
| 307 | + | const fileName = `google-fonts${ timestamp ? `-${Date.now()}` : '' }.css` | |
| 308 | + | , cssStream = new WritableStream(fileName) | |
| 309 | + | ||
| 310 | + | console.log(`Writting the CSS into ${fileName}`) | |
| 311 | + | cssStream.end(data.local_stylesheet) | |
| 312 | + | ||
| 313 | + | if (debug) { | |
| 314 | + | console.log("Debug: Process completed. Original CSS and modified CSS have been saved.") | |
| 315 | + | } | |
| 316 | + | }).catch(error => { | |
| 317 | + | console.error("Error during font download process:", error) | |
| 318 | + | if (debug) { | |
| 319 | + | console.log("Debug: Error occurred. Check the original CSS file for debugging.") | |
| 320 | + | } | |
| 321 | + | }) | |
| 322 | + | }); | |
package.json(file created)
| @@ -0,0 +1,85 @@ | |||
| 1 | + | { | |
| 2 | + | "bin": { | |
| 3 | + | "google-font-downloader": "bin/google-font-downloader.js" | |
| 4 | + | }, | |
| 5 | + | "name": "google-font-downloader", | |
| 6 | + | "description": "Download Google fonts by providing the url", | |
| 7 | + | "keywords": [ | |
| 8 | + | "google", | |
| 9 | + | "font", | |
| 10 | + | "downloader", | |
| 11 | + | "download", | |
| 12 | + | "fonts", | |
| 13 | + | "by", | |
| 14 | + | "providing", | |
| 15 | + | "the", | |
| 16 | + | "url" | |
| 17 | + | ], | |
| 18 | + | "license": "MIT", | |
| 19 | + | "version": "1.0.6", | |
| 20 | + | "main": "lib/index.js", | |
| 21 | + | "scripts": { | |
| 22 | + | "test": "echo \"Error: no test specified\" && exit 1" | |
| 23 | + | }, | |
| 24 | + | "author": "Bloggify <support@bloggify.org> (https://bloggify.org)", | |
| 25 | + | "homepage": "https://github.com/Bloggify/google-font-downloader#readme", | |
| 26 | + | "files": [ | |
| 27 | + | "bin/", | |
| 28 | + | "app/", | |
| 29 | + | "lib/", | |
| 30 | + | "dist/", | |
| 31 | + | "src/", | |
| 32 | + | "scripts/", | |
| 33 | + | "resources/", | |
| 34 | + | "menu/", | |
| 35 | + | "cli.js", | |
| 36 | + | "index.js", | |
| 37 | + | "bloggify.js", | |
| 38 | + | "bloggify.json" | |
| 39 | + | ], | |
| 40 | + | "repository": { | |
| 41 | + | "type": "git", | |
| 42 | + | "url": "git+ssh://git@github.com/Bloggify/google-font-downloader.git" | |
| 43 | + | }, | |
| 44 | + | "bugs": { | |
| 45 | + | "url": "https://github.com/Bloggify/google-font-downloader/issues" | |
| 46 | + | }, | |
| 47 | + | "dependencies": { | |
| 48 | + | "match-all": "^1.2.4", | |
| 49 | + | "streamp": "^2.2.8", | |
| 50 | + | "tilda": "^4.4.13", | |
| 51 | + | "tinyreq": "^3.4.0" | |
| 52 | + | }, | |
| 53 | + | "blah": { | |
| 54 | + | "h_img": "https://i.imgur.com/arpGZH6.png", | |
| 55 | + | "description": [ | |
| 56 | + | { | |
| 57 | + | "h4": "Usage" | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | "p": "You can use this tool to download Google Fonts for offline use, just by providing the Google APIs url." | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | "p": ":bulb: **Note**: It's not clear yet if Google Fonts are EU GDPR compliant (see [this issue](https://github.com/google/fonts/issues/1495)). This may be a good reason to download the Google Fonts you use on your server." | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | "h4": "How it works" | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | "p": "You need to provide the url to the Google APIs endpoint (e.g. `https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700,700i`) and you will get the following files/directories in the current working directory:" | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | "ul": [ | |
| 73 | + | "A file named `google-fonts-<timestamp>.css`—this will contain the CSS snippets that you need to copy in your app. You may need to update the paths to the font files.", | |
| 74 | + | "A directory structure looking like this: `fonts/<font-name>/<version>/<font-file>`" | |
| 75 | + | ] | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | "img": { | |
| 79 | + | "title": "Example", | |
| 80 | + | "source": "https://i.imgur.com/yGcOPKg.gif" | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ] | |
| 84 | + | } | |
| 85 | + | } | |
Newer
Older