UNPKG

savgy

Version:

Get self-contained SVGs or bitmaps from SVG

752 lines (592 loc) 24.4 kB
import {dataURLToBlob, blobToBase64} from './savgy_blob.js'; /** * convert all external font references to * inlined base64 encoded dataURLs */ export async function externalFontsToBase64(css, assetCache = {}) { // Initialize fonts cache if not exists if (!assetCache.fonts) { assetCache.fonts = {}; } let stylesheet = new CSSStyleSheet(); stylesheet.replaceSync(css); let fontFaceRules = [...stylesheet.cssRules].filter((item) => item.type === 5); let urlArr = fontFaceRules.map(rule => { let src = rule.style.getPropertyValue('src'); return src ? src.split(',').map(val => val.trim()) : []; }); let urls = []; urlArr.forEach(fontSrc => { if (!fontSrc || !fontSrc.length) return; let fontArr = []; let fontSrcStr = []; let ruleStr = fontSrc.join(', '); fontSrc.forEach(url => { if (!url || url === 'undefined') return; let ext = url.split('/').slice(-1)[0].split('?')[0].split('.').slice(-1)[0]; if (ext === 'eot' || ext === 'svg') { css = css.replaceAll(url, ''); } else { fontSrcStr.push(url); let cleanUrl = url.replace(/url\(|\)|'|"/gi, '').split(' ')[0]; if (cleanUrl && cleanUrl !== 'undefined') { fontArr.push(cleanUrl); } } }); if (fontSrcStr.length) { let ruleStrNew = fontSrcStr.join(', '); css = css.replace(ruleStr, ruleStrNew); } if (fontArr[0]) { urls.push(fontArr[0]); } }); if (!urls.length) { return css; } const replacements = await Promise.all(urls.map(async (url) => { if (!url || url.startsWith('data:')) { return { url, base64: url }; } try { if (assetCache.fonts[url]) { return { url, base64: assetCache.fonts[url] }; } const res = await fetch(url); if (!res.ok) throw new Error(`Fetch failed with status ${res.status}`); const blob = await res.blob(); const base64 = await blobToBase64(blob); assetCache.fonts[url] = base64; return { url, base64 }; } catch (error) { console.error(`Error processing font ${url}:`, error); return { url, base64: url }; } })); let processedCss = css; for (const { url, base64 } of replacements) { if (url) { processedCss = processedCss.replaceAll(url, base64); } } return processedCss; } export async function externalFontsToBase64_0(css, assetCache = {}) { // create new CSS object for parsing let stylesheet = new CSSStyleSheet(); stylesheet.replaceSync(css); let fontFaceRules = [...stylesheet.cssRules].filter((item) => item.type === 5); /** * find external urls in css via regex * ignore legacy src (woff, t) */ let urlArr = fontFaceRules.map(rule => { return rule.style.getPropertyValue('src').split(',').map(val => val.trim()) }) let urls = []; /** * remove deprecated formats like * .eot or svg */ urlArr.forEach(fontSrc => { let fontArr = []; let fontSrcStr = []; // original src property string - containing alternative formats let ruleStr = fontSrc.join(', '); fontSrc.forEach(url => { let ext = url.split('/').slice(-1)[0].split('?')[0].split('.').slice(-1)[0]; if (ext === 'eot' || ext === 'svg') { css.replaceAll(url, '') } else { if(url) fontSrcStr.push(url); url = url ? url.replace(/url\(|\)|'|"/gi, '').split(' ')[0] : ''; //if(url && url!=='undefined') fontArr.push(url); if(url) fontArr.push(url); } }) // remove depricated formats from CSS let ruleStrNew = fontSrcStr[0]; css = css.replace(ruleStr, ruleStrNew) // add only first for embedding if(fontArr[0]) urls.push(fontArr[0]) }) if(!urls.length){ return css; } // Process all URLs in parallel const replacements = await Promise.all(urls.map(async (url) => { let base64; try { // is already cached if (assetCache.fonts[url]) { //console.log('!!!is cached', url); base64 = assetCache.fonts[url]; return { url, base64: base64 }; } // is already base64 if (url.startsWith('data:')) { //add to cache //assetCache[url] = url; return { url, base64: url }; } // fetch font files url = url || ''; let res = url ? await fetch(url) : {ok:false}; if (res.ok) { // fetch font file const blob = await res.blob(); // create base64 string base64 = await blobToBase64(blob); //add to cache assetCache.fonts[url] = base64; return { url, base64 }; } else { console.log(`Font data couldn't be fetched for ${url} – check CORS headers or file access permissions`); return { url, base64: url }; // Return original URL if fetch fails } } catch (error) { console.error(`Error processing font ${url}:`, error); return { url, base64: url }; // Return original URL if there's an error } })); // Apply all replacements let processedCss = css; for (const { url, base64 } of replacements) { processedCss = processedCss.replaceAll(url, base64); } return processedCss; } /** * collect all fonts and characters * required for SVG rendering * used to remove unnecessary font-face rules * and apply google font API subsetting if possible */ export function analizeSVGText(svg) { // collect used fonts let usedFonts = {} // collect subset strings for families let familyStrings = {}; // query text elements let textEls = svg.querySelectorAll('text, tspan, textPath, foreignObject *') let allText = [... new Set([...[...textEls].map(el => el.textContent).join('').split('')])].join('') // if no text elements are present if (!textEls.length) return usedFonts; for (let i = 0, len = textEls.length; len && i < len; i++) { let text = textEls[i]; //check if element has any text nodes let textNodes = [...text.childNodes].filter(node => node.nodeType === 3 && node.textContent.trim()) if (!textNodes.length) continue; let style = window.getComputedStyle(text); let [fontFamily, fontWeight, fontStyle, fontStretch] = [style.getPropertyValue('font-family'), style.getPropertyValue('font-weight'), style.getPropertyValue('font-style'), style.getPropertyValue('font-stretch')||'100'] fontFamily = fontFamily.replace(/"|'| |%/g, ''); // subset string let subsetChars = textNodes.map(node => node.textContent).join('').trim(); let charsUnique = [...new Set([...subsetChars.split('')])].join('').replaceAll('\n', '') // check unicode range/language let language = detectLanguageSet(unicodeRangeFromString(charsUnique)) // change PUA to latin for icon fonts if (language === 'PUA') language = 'latin' // create unique key for font style let key = [fontFamily, fontWeight, fontStyle, fontStretch, language].join('_').replace(/"|'| |%/g, ''); if (!usedFonts[key]) { usedFonts[key] = [] } if (!familyStrings[fontFamily]) { familyStrings[fontFamily] = [] } familyStrings[fontFamily].push(charsUnique); } //flatten substring array for (let family in usedFonts) { let familyName = family.split('_')[0]; usedFonts[family] = [...new Set(familyStrings[familyName].join('').split(''))].join('') } usedFonts.allText = allText; return usedFonts; } /** * detect unnecessary * font face rules */ export function checkFontCoverage(sheet, svgFontInfo = {}, isGoogleFont = false, subsets = [], subset = '', hasMulti = false, availableFonts={}) { let fontsToSubset = []; let exclude = []; // compare fonts with required for (let i = 0, len = sheet.cssRules.length; len && i < len; i++) { let rule = sheet.cssRules[i]; let type = rule.type; // is fontface if (type === 5) { let [fontFamily, fontWeight, fontStyle, fontStretch] = [ rule.style.getPropertyValue('font-family').replace(/"|'| /g, ''), rule.style.getPropertyValue('font-weight') || '400', rule.style.getPropertyValue('font-style') || 'normal', rule.style.getPropertyValue('font-stretch')||'100', ]; let subsetCurrent = subset ? subset : (subsets[i] ? subsets[i] : 'latin'); fontWeight = fontWeight.split(' ').map(val => convertFontValues(val, 'weight')); fontStretch = fontStretch.split(' ').map(val => convertFontValues(val, 'stretch')); let font_key = [fontFamily, fontWeight[0], fontStyle, fontStretch[0], subsetCurrent].join('_').replace(/"|'| /g, '').trim() let isVF = fontWeight.length > 1 || fontStretch.length > 1 ? true : false; if (isVF) { for (let key in svgFontInfo) { if (key !== 'allText') { let [family, weight, style, stretch, subset] = key.split('_') if (family === fontFamily && style === fontStyle && (+weight >= fontWeight[0]) && (+stretch >= fontStretch[0]) && (subset === subsetCurrent) ) { if (isGoogleFont) fontsToSubset.push(font_key) } } } } // exact static match else if (svgFontInfo[font_key]) { //console.log('has font subset', font_key); if (isGoogleFont) fontsToSubset.push(font_key) } // no match try to adjust non existent weights or widths else { exclude.push(i) } } } return { fontsToSubset, exclude } } /** * colllect all style info from * current stylesheet */ export function checkAvailableFonts(sheet, subsets = [], subset = '') { let fontDataLoaded = {} // compare fonts with required for (let i = 0, len = sheet.cssRules.length; len && i < len; i++) { let rule = sheet.cssRules[i]; let type = rule.type; // is fontface if (type === 5) { let [fontFamily, fontWeight, fontStyle, fontStretch] = [ rule.style.getPropertyValue('font-family').replace(/"|'| /g, ''), rule.style.getPropertyValue('font-weight') || '400', rule.style.getPropertyValue('font-style') || 'normal', rule.style.getPropertyValue('font-stretch') || '100', ]; let subsetCurrent = subset ? subset : (subsets[i] ? subsets[i] : 'latin'); // collect all available weights and styles if (!fontDataLoaded[fontFamily]) { fontDataLoaded[fontFamily] = { weights: new Set([]), widths: new Set([]), styles: new Set([]), isVF: false, subsets: new Set([]), keys: new Set() } } // normalize weights and widths string literals fontWeight = fontWeight.split(' ').map(val => convertFontValues(val, 'weight')); fontStretch = fontStretch.split(' ').map(val => convertFontValues(val, 'stretch')).filter(Boolean); let isVF = fontWeight.length > 1 || fontStretch.length > 1 ? true : false; // add weights fontWeight.forEach(wght => fontDataLoaded[fontFamily].weights.add(wght)) fontStretch.forEach(wdth => fontDataLoaded[fontFamily].widths.add(wdth)) fontDataLoaded[fontFamily].isVF = isVF; fontDataLoaded[fontFamily].subsets.add(subsetCurrent); fontDataLoaded[fontFamily].styles.add(fontStyle); } } return fontDataLoaded } /** * adjust font info to available * weights and styles */ export function updateFontInfo(availableFonts, svgFontInfo) { for (let key in svgFontInfo) { if (key !== 'allText') { let [family, weight, style, stretch, subset] = key.split('_'); [weight, stretch] = [weight, stretch].map(Number); let fontItem = availableFonts[family]; if (!fontItem) { continue; } if ( fontItem.styles.has(style) && fontItem.subsets.has(subset) ) { let stretchNew = stretch, weightNew = weight; // 1. check weights if (!fontItem.weights.has(weight)) { let weights = [...fontItem.weights] let weightMin = Math.min(...weights) let weightMax = Math.max(...weights) //console.log('fontItem match:', family, weight, weightMin, weightMax, fontItem); // too bold if (weight > weightMax) { //console.log('too bold', family, weight); weightNew = weightMax } // too light if (weight < weightMin) { //console.log('too light', family, weight); weightNew = weightMin } } // 2. check stretch if (!fontItem.widths.has(stretch)) { let widths = [...fontItem.widths] let widthMin = Math.min(...widths) let widthMax = Math.max(...widths) // too condensed if (stretch < widthMin) { //console.log('too condensed', family, weight); stretchNew = widthMin } // too expanded if (stretch > widthMax) { //console.log('too expanded', family, weight); stretchNew = widthMax } } //update if(weightNew!=weight || stretchNew!==stretch){ let keyNew = [family, weightNew, style, stretchNew, subset].join('_') svgFontInfo[keyNew] = svgFontInfo[key] delete svgFontInfo[key]; } } } } //console.log('svgFontInfo new', svgFontInfo); return svgFontInfo } /** * convert string literal font values * to numeric */ export function convertFontValues(value, type = 'weight') { if (!isNaN(value)) return parseFloat(value); value = value.trim().toLowerCase(); if (type === 'stretch') { if (value.includes('%')) return parseFloat(value); const fontWidths = { 'ultra-condensed': 50, 'extra-condensed': 62.5, 'condensed': 75, 'semi-condensed': 87.5, 'normal': 100, 'semi-expanded': 112.5, 'expanded': 125, 'extra-expanded': 150, 'ultra-expanded': 200, }; return fontWidths[value] || 100; // default to normal if unknown } if (type === 'weight') { const fontWeights = { 'thin': 100, 'extra-light': 200, 'ultra-light': 200, 'light': 300, 'normal': 400, 'regular': 400, 'medium': 500, 'semi-bold': 600, 'demi-bold': 600, 'bold': 700, 'extra-bold': 800, 'ultra-bold': 800, 'black': 900, 'heavy': 900, }; return fontWeights[value] || 400; // default to normal if unknown } }; /** * detect unicode range */ export function detectLanguageSet(unicodeRangeStr) { // Define known ranges (based on Unicode standards) let knownRanges = { 'latin': [ [0x0020, 0x007f], // Basic Latin ], 'latin-ext': [ [0x00a0, 0x00ff], // Latin-1 Supplement [0x0100, 0x017f], // Latin Extended-A //[0x0180, 0x024f] // Latin Extended-B ], 'cyrillic': [ [0x0400, 0x04ff], // Cyrillic //[0x0500, 0x052f], // Cyrillic Supplement //[0x2de0, 0x2dff], // Cyrillic Extended-A //[0xa640, 0xa69f] // Cyrillic Extended-B ], 'cyrillic-ext': [ [0x0500, 0x052f], // Cyrillic Supplement [0x2de0, 0x2dff], // Cyrillic Extended-A [0xa640, 0xa69f] // Cyrillic Extended-B ], 'greek': [ [0x0370, 0x03ff], // Greek and Coptic //[0x1f00, 0x1fff] // Greek Extended ], 'greek-ext': [ [0x0370, 0x03ff], // Greek and Coptic [0x1f00, 0x1fff] // Greek Extended ], 'vietnamese': [ [0x0102, 0x0103], // Vietnamese letters (Latin Extended-A subset) //[0x0110, 0x0111], //[0x0128, 0x0129], //[0x0168, 0x0169], //[0x01a0, 0x01a1], //[0x01af, 0x01b0], //[0x1ea0, 0x1ef9] // Vietnamese-specific Latin range ], 'arabic': [ [0x0600, 0x06ff], // Arabic //[0x0750, 0x077f], // Arabic Supplement //[0x08a0, 0x08ff], // Arabic Extended-A //[0xfb50, 0xfdff], // Arabic Presentation Forms-A //[0xfe70, 0xfeff] // Arabic Presentation Forms-B ], 'hebrew': [ [0x0590, 0x05ff], // Hebrew //[0xfb1d, 0xfb4f] // Hebrew Presentation Forms ], 'PUA': [ [0xE000, 0xF8FF], [0xF0000, 0xFFFFD], [0x100000, 0x10FFFD], ], /* 'math': [ [0x0020, 0x007f], // Basic Latin [0x0393, 0x25CA], ] */ // Define more language ranges as needed }; const parseUnicodeRange = (range) => { return range.split(",").map((part) => { const [start, end] = part.trim().replace("U+", "").split("-"); const startCode = parseInt(start, 16); const endCode = end ? parseInt(end, 16) : startCode; return [startCode, endCode]; }); }; const calculateAbsoluteOverlap = (userRanges, knownRange) => { let overlapCount = 0; knownRange.forEach(([knownStart, knownEnd]) => { userRanges.forEach(([userStart, userEnd]) => { const start = Math.max(knownStart, userStart); const end = Math.min(knownEnd, userEnd); if (start <= end) overlapCount += end - start + 1; }); }); return overlapCount; }; // Parse user-specified unicode ranges const userRanges = parseUnicodeRange(unicodeRangeStr); // Calculate absolute overlaps for each language set const detectedSets = []; for (const [lang, ranges] of Object.entries(knownRanges)) { const overlapCount = calculateAbsoluteOverlap(userRanges, ranges); if (overlapCount > 0) { detectedSets.push({ language: lang, overlap: overlapCount }); } } let bestMatch = detectedSets.sort((a, b) => b.overlap - a.overlap); bestMatch = bestMatch.length ? bestMatch[0].language : ''; //console.log('bestMatch', bestMatch); // Return the best-matching language(s), sorted by overlap percentage return bestMatch; } export function gfontRangeToString(rangesStr) { // collect all characters let allChars = []; //sanitize rangesStr = rangesStr .replaceAll("unicode-range:", "") .replaceAll(";", "") .trim(); ranges = rangesStr.split(", "); ranges.forEach((range) => { range = range.replaceAll("U+", "").split("-"); let ch0 = range[0]; //console.log(ch0) let ind0 = hex2Dec(ch0); allChars.push(ind0); if (range.length > 1) { let ch1 = range[1]; let ind1 = hex2Dec(ch1); allChars.push(ind1); // get intermediate codepoints in range let diff = ind1 - ind0; for (let i = 0; i < diff; i++) { let indI = ind0 + i; allChars.push(indI); } } }); //deduplicate //allChars = [...new Set(allChars)]; let charArr = allChars .map((val) => { let char = String.fromCharCode(val); let invisible = containsInvisibleCharacters(char) return !invisible ? char : ''; }).filter(Boolean); return charArr; } export function toUnicodeRange(codePoints) { //alert('oi') // Sort code points in ascending order codePoints.sort((a, b) => a - b); // Helper to format a single code point as U+XXXX let formatCodePoint = (point, addPrefix = true) => { let prefix = addPrefix ? 'U+' : '' return prefix + point.toString(16).toUpperCase().padStart(4, '0'); } // Array to store ranges let ranges = []; let start = codePoints[0]; let end = start; for (let i = 1; i < codePoints.length; i++) { if (codePoints[i] === end + 1) { // Continue the range if the next code point is consecutive end = codePoints[i]; } else { // Add the current range to the list ranges.push(start === end ? formatCodePoint(start) : `${formatCodePoint(start)}-${formatCodePoint(end, false)}`); // Start a new range start = codePoints[i]; end = start; } } // Add the final range ranges.push(start === end ? formatCodePoint(start) : `${formatCodePoint(start)}-${formatCodePoint(end, false)}`); // Join all ranges with commas return ranges.join(", "); } export function toHexaDecimal(codePoint) { return "U+" + codePoint.toString(16).toUpperCase().padStart(4, '0'); } export function hex2Dec(hex) { return parseInt(hex, 16); } export function charToHex(str) { // Get the Unicode code point of the character and convert it to hexadecimal const codePoint = str.charCodeAt(0); // Convert the code point to a hexadecimal string and pad with zeros if necessary return "U+" + codePoint.toString(16).toUpperCase().padStart(4, "0"); } export function unicodeRangeFromString(str) { let chars = [... new Set(str.split('').filter(Boolean))] let codePoints = chars.map(char => { return char.charCodeAt(0) }) let range = toUnicodeRange(codePoints); return range } export function containsInvisibleCharacters(str) { // Regular expression to match most invisible characters let invisibleCharsRegex = /[\x00-\x1F\u00A0\u200B\u200C\u200D\u2060\uFEFF]/; // Test if the string contains any invisible character return invisibleCharsRegex.test(str); }