| 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 |
|