| 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 | }); |
| 323 |
KarelWintersky / GoogleFontDownloader
Last active 2 months ago
Revision ea23af542b163ea365f518c04b65f1bf60c436f1
| 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 | } |
| 86 |