savgy
Version:
Get self-contained SVGs or bitmaps from SVG
1,278 lines (991 loc) • 78.1 kB
JavaScript
(function (exports) {
'use strict';
/**
* create cache object to avoid
* unnecessary recurring requests
*/
const assetCache = { fonts: {}, images: {}, css: {}, svg: {} };
const icons = {
download: `<svg viewBox="0 0 100 100" class="icn-svg icn-download"><path stroke="currentColor" d="M49.4 67.3v-62.3m-40 80h80m-0.4 -53.7l-39.6 36l-39.6 -36"/></svg>`,
copy: `<svg viewBox="0 0 86 100" class="icn-svg icn-copy "><path stroke="currentColor" d="M75.7 85h-47v-62.5h25l22 22zm-66 -5v-75h40"></path><path stroke="currentColor" stroke-linejoin="round" stroke-miterlimit="3" d="M75.7 44.5h-22v-22z"/></svg>`,
file: `<svg viewBox="0 0 71 100" class="icn-svg icn-file"><path stroke="currentColor" d="M60.4 85h-50.8v-70h28.7l22 22z" /><path stroke="currentColor" stroke-linejoin="round" stroke-miterlimit="3" d="M60.4 37h-22v-22z" /></svg>`,
spinner: `<svg class="icn-svg icn-spinner" viewBox="0 0 100 100">
<path stroke="currentColor" stroke-linejoin="round" fill="none" d="M 50 10 a 40 40 0 1 1 -40 40">
<animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 50 50;360 50 50" repeatCount="indefinite"></animateTransform>
</path>
</svg>`,
checkbox_filled: `<svg viewBox="0 0 105 100" class="icn-svg icn-checkbox-filled"><path d="M18.3 9h62.2c4.9 0 8.9 4 8.9 8.9v62.2c0 4.9 -4 8.9 -8.9 8.9h-62.2c-4.9 0 -8.9 -4 -8.9 -8.9v-62.2c0 -4.9 4 -8.9 8.9 -8.9z" stroke="none" style="fill:var(--icon-bg, white)" /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M36.1 44.6l13.3 13.3l44.4 -44.4m-4.4 35.5v31.1c0 4.9 -4 8.9 -8.9 8.9h-62.2c-4.9 0 -8.9 -4 -8.9 -8.9v-62.2c0 -4.9 4 -8.9 8.9 -8.9h48.9" /></svg>`,
checkbox: `<svg viewBox="0 0 100 100" class="icn-svg icn-checkbox"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M18.5 9h62.2c4.9 0 8.9 4 8.9 8.9v62.2c0 4.9 -4 8.9 -8.9 8.9h-62.2c-4.9 0 -8.9 -4 -8.9 -8.9v-62.2c0.1 -4.9 4 -8.9 8.9 -8.9z" style="fill:var(--icon-bg, white)"></path></svg>`,
chevron_down: `<svg viewBox="0 0 95 100" class="icn-svg icn-chevron-down"><path stroke="currentColor" d="M84.4 32l-37.5 37.5l-37.5 -37.5" /></svg>`,
close: `<svg viewBox="0 0 100 100" class="icn-svg icn-close"><path stroke="currentColor" d="M89.1 10l-80 80m80 0l-80 -80" /></svg>`
};
function getToolbar(icons) {
let toolbarMarkup =
`<div class="savgy__toolbar">
<div class="savgy__toolbar__inner">
<div class="savgy__toolbar__inputwrap --savgy__toolbar__grd">
<div class="savgy__toolbar__row savgy__toolbar__row--2">
<div class="savgy__toolbar__col savgy__grd-label">
<label class="savgy__toolbar__label savgy__toolbar__label--w">W:</label>
<input class="savgy__toolbar__input savgy__toolbar__input--w" type="number" value="640">
</div>
<div class="savgy__toolbar__col savgy__grd-label">
<label class="savgy__toolbar__label savgy__toolbar__label--h">H:</label>
<input class="savgy__toolbar__input savgy__toolbar__input--h" type="number" value="480">
</div>
</div>
<div class="savgy__toolbar__row">
<div class="savgy__toolbar__col savgy__grd-label">
<label class="savgy__toolbar__label">Quality:</label>
<input class="savgy__toolbar__input savgy__toolbar__input--quality" type="number" value="0.9" min="0.1" max="1" step="0.1">
</div>
</div>
</div>
<div class="savgy__toolbar__inputwrap">
<div class="savgy__toolbar__row">
<div class="savgy__toolbar__col">
<select class="savgy__toolbar__select savgy__toolbar__select--format">
<option class="savgy__toolbar__option" value="png">PNG </option>
<option class="savgy__toolbar__option" value="webp">WebP </option>
<option class="savgy__toolbar__option" value="jpg">Jpeg </option>
<option class="savgy__toolbar__option" value="svg">SVG (original)</option>
<option class="savgy__toolbar__option" value="svg_self">SVG (self-contained)</option>
</select>
</div>
</div>
<div class="savgy__toolbar__row">
<div class="savgy__toolbar__col">
<select class="savgy__toolbar__select savgy__toolbar__select--format--img">
<option class="savgy__toolbar__option" value="original">SVG img format </option>
<option class="savgy__toolbar__option" value="png">PNG </option>
<option class="savgy__toolbar__option" value="webp">WebP </option>
<option class="savgy__toolbar__option" value="jpg">Jpeg </option>
</select>
</div>
</div>
<div class="savgy__toolbar__row">
<div class="savgy__toolbar__col">
<label class="savgy__toolbar__label savgy__toolbar__label--inline">
<input type="checkbox" value="1" class="savgy__toolbar__check savgy__toolbar__check--preview">
Show Preview
</label>
<label class="savgy__toolbar__label savgy__toolbar__label--inline">
<input type="checkbox" value="1" class="savgy__toolbar__check savgy__toolbar__check--intrinsic">
Intrinsic width/height
</label>
<label class="savgy__toolbar__label savgy__toolbar__label--inline">
<input type="checkbox" value="1" class="savgy__toolbar__check savgy__toolbar__check--crop">
Crop to content
</label>
<label class="savgy__toolbar__label savgy__toolbar__label--inline">
<input type="checkbox" value="1" class="savgy__toolbar__check savgy__toolbar__check--flatten">
Flatten transparency
</label>
<label class="savgy__toolbar__label savgy__toolbar__label--inline">
<input type="checkbox" value="1" class="savgy__toolbar__check savgy__toolbar__check--scaleImg">
Scale SVG images
</label>
</div>
</div>
</div>
<div class="savgy__toolbar__row savgy__toolbar__row--2">
<div class="savgy__toolbar__col">
<button type="button" class="savgy__button savgy__toolbar__button savgy__toolbar__button--copy"><span class="savgy__toolbar__button--save--download icn-svg savgy__icon">${icons.copy} </span> Copy</button>
</div>
<div class="savgy__toolbar__col">
<button type="button" class="savgy__button--ready savgy__button savgy__toolbar__button savgy__toolbar__button--save"><span class="savgy__toolbar__button--save--download icn-svg savgy__icon savgy__icon--download">${icons.download} </span> <span class="savgy__toolbar__button--save--download icn-svg savgy__icon savgy__icon--spinner">${icons.spinner} </span> Save file</button>
<a class="savgy__toolbar__a savgy__toolbar__button--a savgy__hidden" href="" download=""></a>
</div>
</div>
<div class="saveg__filesize__wrp">
<p class="saveg__filesize">Filesize: 0 KB</p>
</div>
</div>
<div class="savgy__toolbar__toggle">
<label class="savgy__toolbar__toggle__label">
<span class="savgy__toolbar__toggle__icon savgy__toolbar__toggle__icon--download savgy__icon savgy__icon--download">${icons.download}</span>
<span class=" savgy__toolbar__toggle__icon savgy__toolbar__toggle__icon--close savgy__icon savgy__icon--close">${icons.close}</span>
<input class="savgy__toolbar__toggle__input savgy__hidden" type="checkbox" value="1">
</label>
</div>
</div>
`;
let toolbar = new DOMParser().parseFromString(toolbarMarkup, 'text/html').querySelector('div');
let inpW = toolbar.querySelector('.savgy__toolbar__input--w');
let inpH = toolbar.querySelector('.savgy__toolbar__input--h');
let inpQ = toolbar.querySelector('.savgy__toolbar__input--quality');
let inpF = toolbar.querySelector('.savgy__toolbar__select--format');
let inpPreview = toolbar.querySelector('.savgy__toolbar__check--preview');
//preferred format for images in SVG
let inpFSVG = toolbar.querySelector('.savgy__toolbar__select--format--img');
let btnCopy = toolbar.querySelector('.savgy__toolbar__button--copy');
let btnSave = toolbar.querySelector('.savgy__toolbar__button--save');
let linkFile = toolbar.querySelector('.savgy__toolbar__button--a');
let inputToggleLabel = toolbar.querySelector('.savgy__toolbar__toggle__label');
let inputToggle = toolbar.querySelector('.savgy__toolbar__toggle__input');
let inputIntrinsic = toolbar.querySelector('.savgy__toolbar__check--intrinsic');
let inputCrop = toolbar.querySelector('.savgy__toolbar__check--crop');
let inputFlatten = toolbar.querySelector('.savgy__toolbar__check--flatten');
let inputScaleImages = toolbar.querySelector('.savgy__toolbar__check--scaleImg');
let selectSVGIMGFormat = toolbar.querySelector('.savgy__toolbar__select--format--img');
let pFilesize = toolbar.querySelector('.saveg__filesize');
return { toolbar, inpW, inpH, inpQ, inpF, inpFSVG, btnCopy, btnSave, linkFile, inpPreview, inputToggleLabel, inputToggle, inputIntrinsic, inputCrop, selectSVGIMGFormat, pFilesize, inputFlatten, inputScaleImages }
}
/**
* create blob from
* data URL
*/
function dataURLToBlob(dataUrl) {
// Check if the data URL is valid
if (!dataUrl.startsWith('data:')) {
throw new Error('Invalid data URL format');
}
// Split into metadata and data parts
const [metaPart, dataPart] = dataUrl.split(',');
if (!metaPart || !dataPart) {
throw new Error('Invalid data URL format');
}
// Extract MIME type (handle cases like `charset=utf8`)
const mimeMatch = metaPart.match(/^data:([^;]+)/);
const mimeString = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
let byteString;
if (metaPart.includes('base64')) {
// Handle base64-encoded data
byteString = atob(dataPart);
} else {
// Handle URL-encoded data (e.g., SVG with %3C, %20, etc.)
byteString = decodeURIComponent(dataPart);
}
// Convert to ArrayBuffer → Blob
const buffer = new ArrayBuffer(byteString.length);
const uintArray = new Uint8Array(buffer);
for (let i = 0; i < byteString.length; i++) {
uintArray[i] = byteString.charCodeAt(i);
}
return new Blob([buffer], { type: mimeString });
}
/**
* convert blob to
* base64 data URL
*/
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = (err) => reject(err);
reader.readAsDataURL(blob);
});
}
/**
* inline references for patterns, masks,
* clipaths etc
*/
function inlineRefs(svg, assetCache={}) {
let els = svg.querySelectorAll('path, polyline, polygon, rect, circle, ellipse, line, text, tspan');
//attributes that may reference an external asset e.g a gradient, pattern, mask etc
let props = ['fill', 'stroke', 'clip-path', 'mask', 'href', 'xlink:href'];
// create defs elements
let defs = svg.querySelector('defs');
if (!defs) {
defs = document.createElementNS(ns, 'defs');
svg.insertBefore(defs, svg.children[0]);
}
for (let i = 0; i < els.length; i++) {
let el = els[i];
let styles = window.getComputedStyle(el);
let refID, refEl, refEl_clone;
for (let p = 0; p < props.length; p++) {
let prop = props[p];
let att = el.getAttribute(prop);
let value = att ? att : styles.getPropertyValue(prop);
let isHref = prop === 'href' || prop === 'xlink:href' ? true : false;
/**
* check props to find
* patterns, gradients, masks etc
*/
if (value.includes('url') || isHref) {
//url = getUrls(value)
refID = isHref ? value.substring(1) : getUrls(value).substring(1);
//console.log('has ref', prop, url, refID);
refEl = refID ? svg.getElementById(refID) : null;
// already present in svg
if (refEl) {
//console.log('exists');
continue;
}
// not in parent SVG - copy
refEl = refID ? document.getElementById(refID) : null;
if (refEl) {
refEl_clone = refEl.cloneNode(true);
//svg.insertBefore(refEl_clone, svg.children[0]);
defs.append(refEl_clone);
}
}
}
}
function getUrls(str) {
let regex = /url\((['"]?)([^'"()]+)\1\)/gi;
let match = str.match(regex) ? str.match(regex)[0].split(/\(|\)/)[1].replace(/"|'/g, '') : '';
return match
}
}
async function inlineUseRefs(svg, assetCache = {}) {
let useEls = svg.querySelectorAll('use');
if (!useEls.length) return;
const ns = "http://www.w3.org/2000/svg";
const nsXlink = "http://www.w3.org/1999/xlink";
// create defs elements
let defs = svg.querySelector('defs');
if (!defs) {
defs = document.createElementNS(ns, 'defs');
svg.insertBefore(defs, svg.children[0]);
}
for (let i = 0; i < useEls.length; i++) {
let use = useEls[i];
let href = use.getAttributeNS(nsXlink, 'href') ? use.getAttributeNS(nsXlink, 'href') : use.getAttribute('href');
// xlink sucks - we normalize it to href
use.removeAttribute('xlink:href');
// find external use references
let hrefArr = href.split('#').filter(Boolean);
let [url, id] = hrefArr;
let isExternal = hrefArr.length > 1;
let inlineRef, inlineDef, defId;
// use definition is in document
let inlinedSymbol = document.getElementById(hrefArr[0]);
if (inlinedSymbol) {
inlineDef = inlinedSymbol.cloneNode(true);
//svg.insertBefore(inlineDef, svg.children[0]);
defs.append(inlineDef);
inlineRef = `#${hrefArr[0]}`;
use.setAttribute('href', inlineRef);
}
// fetch external ref
else if (isExternal) {
let spriteName = url.replace(/\./g, '_');
// is cached
if (assetCache[url] && assetCache[url][id]) {
//console.log('is cached', id);
inlineDef = assetCache[url][id].cloneNode(true);
} else {
// fetch and cache
let res = await fetch(url);
if (res.ok) {
let spriteMarkup = await res.text();
let sprite = new DOMParser().parseFromString(spriteMarkup, 'text/html').querySelector('svg');
let symbol = sprite.getElementById(id);
// cache
assetCache[url] = {};
assetCache[url][id] = symbol;
inlineDef = symbol.cloneNode(true);
}
}
defId = `${spriteName}_${id}`;
inlineDef.id = defId;
// add to svg if not already done
if (!svg.getElementById(defId)) {
//svg.insertBefore(inlineDef, svg.children[0]);
defs.append(inlineDef);
}
// new reference
use.setAttribute('href', `#${defId}`);
}
//console.log(assetCache);
}
}
async function optimizeSVGImgs(svgClone, assetCache = {}, addGlobalStyles = true, width = null, height = null, format = 'original', quality = 1, scaleImages = true, bgColor='transparent') {
let svgOpt = svgClone.cloneNode(true);
let images = svgOpt.querySelectorAll("image, img");
let nsXlink = "http://www.w3.org/1999/xlink";
if (!images.length || format === 'original') return svgOpt;
// normalize format identifiers
//console.log('format', format, assetCache);
format = format ? format.replace(/image\//g, '').replace(/jpg/g, 'jpeg') : 'original';
await Promise.all([...images].map(async (img) => {
let type = img.nodeName.toLowerCase();
let src = img.getAttributeNS(nsXlink, "href")
? img.getAttributeNS(nsXlink, "href")
: (type === 'image' ? img.getAttribute("href") : img.getAttribute("src"));
// all image src are already base64
let base64 = src;
/**
* convert to other format
* helps to optimize size for
* self-contained SVGs
*/
if (format !== 'original') {
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
let imgTmp = new Image();
imgTmp.crossOrigin = "anonymous";
imgTmp.src = base64;
// wait for image decoding
await imgTmp.decode();
// intrinsic dimensions
let [width, height] = [imgTmp.naturalWidth, imgTmp.naturalHeight];
// element dimension
let [w, h] = [+img.getAttribute('width') || width, +img.getAttribute('height') || height];
// scale down to layout size
if (scaleImages) [width, height] = [w, h];
imgTmp.width = width;
imgTmp.height = height;
canvas.width = width;
canvas.height = height;
// add white background for jpegs to flatten transparency
if (format === 'jpeg') {
let svgBG = window.getComputedStyle(svgOpt).backgroundColor;
if ((svgBG === 'rgba(0, 0, 0, 0)' || svgBG !== 'transparent')) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
}
await ctx.drawImage(imgTmp, 0, 0, width, height);
base64 = canvas.toDataURL(`image/${format}`, quality);
canvas.remove();
}
if (type === 'image') {
img.setAttribute("href", base64);
} else {
img.setAttribute("src", base64);
}
}));
//console.log('svgOpt', svgOpt);
return svgOpt;
}
/**
* inline external
* image sources
*/
async function inlineImages(svg, assetCache = {}) {
let images = svg.querySelectorAll("image, img");
let nsXlink = "http://www.w3.org/1999/xlink";
await Promise.all([...images].map(async (img) => {
let type = img.nodeName.toLowerCase();
let src = img.getAttributeNS(nsXlink, "href")
? img.getAttributeNS(nsXlink, "href")
: (type === 'image' ? img.getAttribute("href") : img.getAttribute("src"));
let base64;
// use cache
if (assetCache.images[src]) {
//console.log('is cached!', src);
base64 = assetCache.images[src];
} else {
// check if src is already data URL
if (src.startsWith('data:')) {
//cache_key = `${src}_original_1`;
base64 = src;
} else {
let blob = await (await fetch(src)).blob();
base64 = await blobToBase64(blob);
// add to cache
assetCache.images[src] = base64;
}
}
if (type === 'image') {
img.setAttribute("href", base64);
} else {
img.setAttribute("src", base64);
}
}));
}
async function svg2Canvas2DataUrl(svg, width = null, height = null, format = 'png', quality = '1', bgColor='transparent', flattenTransparency=false, canvas = null) {
let isString = typeof svg === 'string';
let noDimensions = !width || !height;
let nsAtt = 'xmlns="http://www.w3.org/2000/svg"';
let hasNS = isString ? svg.includes(nsAtt) : svg.getAttribute('xmlns');
if (!hasNS && isString) {
svg = svg.replace('<svg ', `<svg ${nsAtt} `);
}
// Detect dimensions if not specified
if (noDimensions) {
// parse to retrieve width/height and viewBox
if (isString) {
svg = new DOMParser().parseFromString(svg, 'text/html').querySelector('svg');
let viewBox = svg.getAttribute('viewBox') || '0 0 300 150';
let [, , w, h] = viewBox.split(/,| /);
[width, height] = +svg.getAttribute("width") && +svg.getAttribute("height") ?
[svg.width, svg.height]
: [w, h];
// force serializing to add missing namespace
isString = false;
}
// set explicit svg dimensions for better compatibility
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
let svgMarkup = isString ? svg : new XMLSerializer().serializeToString(svg);
let dataUrl = 'data:image/svg+xml; charset=utf8, ' + encodeURIComponent(svgMarkup);
let img = new Image();
img.crossOrigin = "anonymous";
img.src = dataUrl;
img.width = width;
img.height = height;
/**
* create canvas
* if not existent
*/
if (!canvas) {
canvas = document.createElement("canvas");
}
canvas.width = width;
canvas.height = height;
let ctx = canvas.getContext("2d");
// normalize format identifiers
format = format.replace(/image\//g, '').replace(/jpg/g, 'jpeg');
// add white background for jpegs to flatten transparency
if (format === 'jpeg' || flattenTransparency) {
//console.log('flatten', flattenTransparency);
bgColor = bgColor!=='transparent' ? bgColor : '#fff';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
}
//wait for image to decode
await img.decode();
await ctx.drawImage(img, 0, 0, width, height);
return canvas.toDataURL(`image/${format}`, quality);
}
/**
* convert all external font references to
* inlined base64 encoded dataURLs
*/
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;
}
/**
* collect all fonts and characters
* required for SVG rendering
* used to remove unnecessary font-face rules
* and apply google font API subsetting if possible
*/
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
*/
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
*/
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
*/
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
*/
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
*/
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;
}
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(", ");
}
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
}
/**
* inline global stylesheets for
* self-contained SVG
*/
async function inlineGlobalStyles(svg, svgFontInfo = {}, assetCache = {}) {
// Initialize CSS cache if not exists
if (!assetCache.css) {
assetCache.css = {};
}
let globalCSS = '';
let styleSheets = document.styleSheets;
for (let sheet of styleSheets) {
// skip inline SVG styles
const ownerNode = sheet.ownerNode;
if (ownerNode && ownerNode.closest('svg')) continue;
// Process external stylesheets
if (sheet.href) {
let url = sheet.href;
let isGoogleFont = url.includes('fonts.google');
let css, subsets = [];
let hasMulti = false;
// Check cache first
if (assetCache.css[url]) {
css = assetCache.css[url].css;
subsets = assetCache.css[url].subsets || [];
hasMulti = assetCache.css[url].hasMulti || false;
} else {
// Fetch if not in cache
try {
let res = await fetch(url);
if (res.ok) {
css = await res.text();
// Remove imports
let reg = /@import\s+(?:url\()?["']?([^"')]+)["']?\)?[^;]*;?/gi;
let imports = css.match(reg) || [];
imports.forEach(imp => {
css = css.replace(imp, '');
});
// Fix relative URLs
css = relativeToAbsoluteUrls(css, url);
if (isGoogleFont) {
// Retrieve subsets from comments
let comments = [...css.matchAll(/\/\*\s*(.*?)\s*\*\//g)];
subsets = comments ? comments.map(match => match[1]) : [];
let families = url.split('family=').slice(1);
hasMulti = families.length > 1;
}
// Cache the response
assetCache.css[url] = {
css,
subsets,
hasMulti,
isGoogleFont
};
}
} catch (error) {
console.error(`Error fetching stylesheet ${url}:`, error);
continue;
}
}
if (!css) continue;
let sheetInline = new CSSStyleSheet();
sheetInline.replaceSync(css);
// Check all available fonts
let availableFonts = checkAvailableFonts(sheetInline, subsets);
// Update fontinfo
updateFontInfo(availableFonts, svgFontInfo);
let { exclude, fontsToSubset } = checkFontCoverage(
sheetInline, svgFontInfo, isGoogleFont, subsets, '', hasMulti, availableFonts
);
// Handle Google Font subsetting
if (fontsToSubset.length && isGoogleFont) {
let key = fontsToSubset[0];
let subsetStr = hasMulti ? svgFontInfo.allText : svgFontInfo[key];
let subsetQuery = url + '&text=' + encodeURIComponent(subsetStr);
// Check cache for subset
if (!assetCache.css[subsetQuery]) {
try {
let res = await fetch(subsetQuery);
if (res.ok) {
let subsetCss = await res.text();
assetCache.css[subsetQuery] = {
css: subsetCss,
isSubset: true
};
css = subsetCss;
sheetInline = new CSSStyleSheet();
sheetInline.replaceSync(css);
let subset = 'latin';
({ exclude, fontsToSubset } = checkFontCoverage(
sheetInline, svgFontInfo, isGoogleFont, subsets, subset, hasMulti, availableFonts
));
}