const devideTTC = async (_ttcFontPath) => { const buf = fs.readFileSync(_ttcFontPath); const ttf_count = unpack('!L', buf, 0x08)[0]; const ttf_offset_array = unpack('!' + ttf_count + 'L', buf, 0x0C); for (let i = 0; i < ttf_count; i++) { const table_header_offset = ttf_offset_array[i]; const table_count = unpack('!H', buf, table_header_offset + 0x04)[0]; const header_length = 0x0C + table_count * 0x10; let table_length = 0; for (let j = 0; j < table_count; j++) { const length = unpack('!L', buf, table_header_offset + 0x0C + 0x0C + j * 0x10)[0]; table_length += ceil4(length); } const total_length = header_length + table_length; const new_buf = new Buffer(total_length); const header = unpack(header_length + 'c', buf, table_header_offset); packTo(header_length + 'c', new_buf, 0, header); let current_offset = header_length; for (let j = 0; j < table_count; j++) { const offset = unpack('!L', buf, table_header_offset + 0x0C + 0x08 + j * 0x10)[0]; const length = unpack('!L', buf, table_header_offset + 0x0C + 0x0C + j * 0x10)[0]; packTo('!L', new_buf, 0x0C + 0x08 + j * 0x10, [current_offset]); const current_table = unpack(length + 'c', buf, offset); packTo(length + 'c', new_buf, current_offset, current_table); current_offset += ceil4(length); } const ttfFont = fontkit.create(new_buf); const postscriptName = ttfFont.postscriptName.toString(); const ttfPath = path.join(tmpFontCachePath, `${getHashCode(_ttcFontPath, postscriptName)}.ttf`); console.log('fullName: ', ttfFont.fullName.toString()); console.log('\tpostscriptName: ', postscriptName); console.log('\tttfPath: ', ttfPath); fs.writeFileSync(ttfPath, new_buf); } };
function getMetricsFromFont(entry, charsListOnPage) { var deferred = Q.defer(); try { var startTime = Date.now(); var font = fontkit.create(entry.weightCheck.bodyBuffer); var result = { name: font.fullName || font.postscriptName || font.familyName, numGlyphs: font.numGlyphs, averageGlyphComplexity: getAverageGlyphComplexity(font), compressedWeight: entry.weightCheck.afterCompression || entry.weightCheck.bodySize, unicodeRanges: readUnicodeRanges(font.characterSet, charsListOnPage), numGlyphsInCommonWithPageContent: countPossiblyUsedGlyphs(getCharacterSetAsString(font.characterSet), charsListOnPage) }; var endTime = Date.now(); debug('Font analysis took %dms', endTime - startTime); // Mark fonts that are not used on the page (#224) var fontIsUsed = false; for (var range in result.unicodeRanges) { if (result.unicodeRanges[range].numGlyphsInCommonWithPageContent > 0) { fontIsUsed = true; break; } } result.isUsed = fontIsUsed; deferred.resolve(result); } catch(error) { deferred.reject(error); } return deferred.promise; }
return async function subsetFonts(assetGraph) { const htmlAssetTextsWithProps = []; const subsetUrl = urltools.ensureTrailingSlash( assetGraph.root + subsetPath ); await assetGraph.populate({ followRelations: { $or: [ { to: { url: googleFontsCssUrlRegex } }, { type: 'CssFontFaceSrc', from: { url: googleFontsCssUrlRegex } } ] } }); // Collect texts by page const memoizedGetCssRulesByProperty = memoizeSync(getCssRulesByProperty); const htmlAssets = assetGraph.findAssets({ type: 'Html', isInline: false }); const traversalRelationQuery = { $or: [ { type: { $in: ['HtmlStyle', 'CssImport'] } }, { to: { type: 'Html', isInline: true } } ] }; // Keep track of the injected CSS assets that should eventually be minified // Minifying them along the way currently doesn't work because some of the // manipulation is sensitive to the exact text contents. We should fix that. const subsetFontsToBeMinified = new Set(); for (const htmlAsset of htmlAssets) { const accumulatedFontFaceDeclarations = []; assetGraph.eachAssetPreOrder(htmlAsset, traversalRelationQuery, asset => { if (asset.type === 'Css' && asset.isLoaded) { const seenNodes = new Set(); const fontRelations = asset.outgoingRelations.filter( relation => relation.type === 'CssFontFaceSrc' ); for (const fontRelation of fontRelations) { const node = fontRelation.node; if (!seenNodes.has(node)) { seenNodes.add(node); const fontFaceDeclaration = { relations: fontRelations.filter(r => r.node === node), ...initialValueByProp }; node.walkDecls(declaration => { const propName = declaration.prop.toLowerCase(); if (propName === 'font-family') { fontFaceDeclaration[propName] = unquote(declaration.value); } else { fontFaceDeclaration[propName] = declaration.value; } }); accumulatedFontFaceDeclarations.push(fontFaceDeclaration); } } } }); if (accumulatedFontFaceDeclarations.length > 0) { const seenFontFaceCombos = new Set(); for (const fontFace of accumulatedFontFaceDeclarations) { const key = `${fontFace['font-family']}/${fontFace['font-style']}/${ fontFace['font-weight'] }`; if (seenFontFaceCombos.has(key)) { throw new Error( `Multiple @font-face with the same font-family/font-style/font-weight (maybe with different unicode-range?) is not supported yet: ${key}` ); } seenFontFaceCombos.add(key); } const textByProps = fontTracer( htmlAsset.parseTree, gatherStylesheetsWithPredicates(htmlAsset.assetGraph, htmlAsset), memoizedGetCssRulesByProperty, htmlAsset ); htmlAssetTextsWithProps.push({ htmlAsset, fontUsages: groupTextsByFontFamilyProps( textByProps, accumulatedFontFaceDeclarations ), accumulatedFontFaceDeclarations }); } } if (htmlAssetTextsWithProps.length <= 1) { subsetPerPage = false; } if (!subsetPerPage) { const globalFontUsage = {}; // Gather all texts for (const htmlAssetTextWithProps of htmlAssetTextsWithProps) { for (const fontUsage of htmlAssetTextWithProps.fontUsages) { if (!globalFontUsage[fontUsage.fontUrl]) { globalFontUsage[fontUsage.fontUrl] = []; } globalFontUsage[fontUsage.fontUrl].push(fontUsage.text); } } // Merge subset values, unique glyphs, sort for (const htmlAssetTextWithProps of htmlAssetTextsWithProps) { for (const fontUsage of htmlAssetTextWithProps.fontUsages) { fontUsage.text = _.uniq(globalFontUsage[fontUsage.fontUrl].join('')) .sort() .join(''); } } } if (fontDisplay) { for (const htmlAssetTextWithProps of htmlAssetTextsWithProps) { for (const fontUsage of htmlAssetTextWithProps.fontUsages) { fontUsage.props['font-display'] = fontDisplay; } } } // Generate codepoint sets for original font, the used subset and the unused subset for (const htmlAssetTextWithProps of htmlAssetTextsWithProps) { for (const fontUsage of htmlAssetTextWithProps.fontUsages) { const originalFont = assetGraph.findAssets({ url: fontUsage.fontUrl })[0]; if (originalFont.isLoaded) { let originalCodepoints; try { // Guard against 'Unknown font format' errors originalCodepoints = fontkit.create(originalFont.rawSrc) .characterSet; } catch (err) {} if (originalCodepoints) { const usedCodepoints = fontUsage.text .split('') .map(c => c.codePointAt(0)); const unusedCodepoints = originalCodepoints.filter( n => !usedCodepoints.includes(n) ); fontUsage.codepoints = { original: originalCodepoints, used: usedCodepoints, unused: unusedCodepoints }; } } } } // Generate subsets: await getSubsetsForFontUsage(assetGraph, htmlAssetTextsWithProps, formats); // Warn about missing glyphs const missingGlyphsErrors = []; for (const { htmlAsset, fontUsages } of htmlAssetTextsWithProps) { for (const fontUsage of fontUsages) { if (fontUsage.subsets) { const characterSet = fontkit.create( Object.values(fontUsage.subsets)[0] ).characterSet; for (const char of [...fontUsage.pageText]) { // Turns out that browsers don't mind that these are missing: if (char === '\t' || char === '\n') { continue; } const codePoint = char.codePointAt(0); const isMissing = !characterSet.includes(codePoint); if (isMissing) { let location; const charIdx = htmlAsset.text.indexOf(char); if (charIdx === -1) { location = `${htmlAsset.urlOrDescription} (generated content)`; } else { const position = new LinesAndColumns( htmlAsset.text ).locationForIndex(charIdx); location = `${htmlAsset.urlOrDescription}:${position.line + 1}:${position.column + 1}`; } missingGlyphsErrors.push({ codePoint, char, htmlAsset, fontUsage, location }); } } } } } if (missingGlyphsErrors.length) { const errorLog = missingGlyphsErrors.map( ({ char, fontUsage, location }) => `- \\u{${char .charCodeAt(0) .toString(16)}} (${char}) in font-family '${ fontUsage.props['font-family'] }' (${fontUsage.props['font-weight']}/${ fontUsage.props['font-style'] }) at ${location}` ); const message = `Missing glyph fallback detected. When your primary webfont doesn't contain the glyphs you use, your browser will load your fallback fonts, which will be a potential waste of bandwidth. These glyphs are used on your site, but they don't exist in the font you applied to them:`; assetGraph.warn(new Error(`${message}\n${errorLog.join('\n')}`)); } // Insert subsets: for (const { htmlAsset, fontUsages, accumulatedFontFaceDeclarations } of htmlAssetTextsWithProps) { const insertionPoint = assetGraph.findRelations({ type: 'HtmlStyle', from: htmlAsset })[0]; const subsetFontUsages = fontUsages.filter( fontUsage => fontUsage.subsets ); const unsubsettedFontUsages = fontUsages.filter( fontUsage => !subsetFontUsages.includes(fontUsage) ); // Remove all existing preload hints to fonts that might have new subsets for (const fontUsage of fontUsages) { for (const relation of assetGraph.findRelations({ type: { $in: ['HtmlPrefetchLink', 'HtmlPreloadLink'] }, from: htmlAsset, to: { url: fontUsage.fontUrl } })) { if (relation.type === 'HtmlPrefetchLink') { const err = new Error( `Detached ${ relation.node.outerHTML }. Will be replaced with preload with JS fallback.\nIf you feel this is wrong, open an issue at https://github.com/assetgraph/assetgraph/issues` ); err.asset = relation.from; err.relation = relation; assetGraph.info(err); } relation.detach(); } } if (unsubsettedFontUsages.length > 0) { // Insert <link rel="preload"> const preloadRelations = unsubsettedFontUsages.map(fontUsage => { // Always preload unsubsetted font files, they might be any format, so can't be clever here return htmlAsset.addRelation( { type: 'HtmlPreloadLink', hrefType: 'rootRelative', to: fontUsage.fontUrl, as: 'font' }, 'before', insertionPoint ); }); // Generate JS fallback for browser that don't support <link rel="preload"> const preloadData = unsubsettedFontUsages.map((fontUsage, idx) => { const preloadRelation = preloadRelations[idx]; const formatMap = { '.woff': 'woff', '.woff2': 'woff2', '.ttf': 'truetype', '.svg': 'svg', '.eot': 'embedded-opentype' }; const name = fontUsage.props['font-family']; const props = Object.keys(initialValueByProp).reduce( (result, prop) => { if ( fontUsage.props[prop] !== normalizeFontPropertyValue(prop, initialValueByProp[prop]) ) { result[prop] = fontUsage.props[prop]; } return result; }, {} ); return `new FontFace( "${name}", "url('" + "${preloadRelation.href}".toString('url') + "') format('${ formatMap[preloadRelation.to.extension] }')", ${JSON.stringify(props)} ).load();`; }); const originalFontJsPreloadAsset = htmlAsset.addRelation( { type: 'HtmlScript', hrefType: 'inline', to: { type: 'JavaScript', text: `try{${preloadData.join('')}}catch(e){}` } }, 'before', insertionPoint ).to; for (const [ idx, relation ] of originalFontJsPreloadAsset.outgoingRelations.entries()) { relation.hrefType = 'rootRelative'; relation.to = preloadRelations[idx].to; relation.refreshHref(); } originalFontJsPreloadAsset.minify(); } if (subsetFontUsages.length < 1) { return { fontInfo: [] }; } const subsetCssText = getFontUsageStylesheet( subsetFontUsages, accumulatedFontFaceDeclarations ); let cssAsset = assetGraph.addAsset({ type: 'Css', url: `${subsetUrl}subfontTemp.css`, text: subsetCssText }); subsetFontsToBeMinified.add(cssAsset); if (!inlineSubsets) { for (const fontRelation of cssAsset.outgoingRelations) { const fontAsset = fontRelation.to; const extension = fontAsset.contentType.split('/').pop(); const nameProps = ['font-family', 'font-weight', 'font-style'] .map(prop => fontRelation.node.nodes.find(decl => decl.prop === prop) ) .map(decl => decl.value); const fontWeightRangeStr = nameProps[1] .split(/\s+/) .map(token => normalizeFontPropertyValue('font-weight', token)) .join('_'); const fileNamePrefix = `${unquote(nameProps[0]) .replace(/__subset$/, '') .replace(/ /g, '_')}-${fontWeightRangeStr}${ nameProps[2] === 'italic' ? 'i' : '' }`; const fontFileName = `${fileNamePrefix}-${fontAsset.md5Hex.slice( 0, 10 )}.${extension}`; // If it's not inline, it's one of the unused variants that gets a mirrored declaration added // for the __subset @font-face. Do not move it to /subfont/ if (fontAsset.isInline) { const fontAssetUrl = subsetUrl + fontFileName; const existingFontAsset = assetGraph.findAssets({ url: fontAssetUrl })[0]; if (existingFontAsset && fontAsset.isInline) { fontRelation.to = existingFontAsset; assetGraph.removeAsset(fontAsset); } else { fontAsset.url = subsetUrl + fontFileName; } } if (inlineCss) { fontRelation.hrefType = 'rootRelative'; } else { fontRelation.hrefType = 'relative'; } } } const cssAssetUrl = `${subsetUrl}fonts-${cssAsset.md5Hex.slice( 0, 10 )}.css`; const existingCssAsset = assetGraph.findAssets({ url: cssAssetUrl })[0]; if (existingCssAsset) { assetGraph.removeAsset(cssAsset); subsetFontsToBeMinified.delete(cssAsset); cssAsset = existingCssAsset; } else { cssAsset.url = cssAssetUrl; } if (!inlineSubsets) { for (const fontRelation of cssAsset.outgoingRelations) { const fontAsset = fontRelation.to; if (fontAsset.contentType === 'font/woff2') { // Only <link rel="preload"> for woff2 files // Preload support is a subset of woff2 support: // - https://caniuse.com/#search=woff2 // - https://caniuse.com/#search=preload htmlAsset.addRelation( { type: 'HtmlPreloadLink', hrefType: 'rootRelative', to: fontAsset, as: 'font' }, 'before', insertionPoint ); } } } const cssRelation = htmlAsset.addRelation( { type: 'HtmlStyle', hrefType: inlineCss ? 'inline' : 'rootRelative', to: cssAsset }, 'before', insertionPoint ); // JS-based font preloading for browsers without <link rel="preload"> support if (inlineCss) { // If the CSS is inlined we can use the font declarations directly to load the fonts htmlAsset .addRelation( { type: 'HtmlScript', hrefType: 'inline', to: { type: 'JavaScript', text: 'try { document.fonts.forEach(function (f) { f.family.indexOf("__subset") !== -1 && f.load(); }); } catch (e) {}' } }, 'after', cssRelation ) .to.minify(); } else { // The CSS is external, can't use the font face declarations without waiting for a blocking load. // Go for direct FontFace construction instead const fontFaceContructorCalls = []; cssAsset.parseTree.walkAtRules('font-face', rule => { let name; let url; const props = {}; rule.walkDecls(({ prop, value }) => { const propName = prop.toLowerCase(); if (propName === 'font-weight') { value = value .split(/\s+/) .map(token => normalizeFontPropertyValue('font-weight', token)) .join(' '); if (/^\d+$/.test(value)) { value = parseInt(value, 10); } } if (propName in initialValueByProp) { if ( normalizeFontPropertyValue(propName, value) !== normalizeFontPropertyValue( propName, initialValueByProp[propName] ) ) { props[propName] = value; } } if (propName === 'font-family') { name = unquote(value); } else if (propName === 'src') { const fontRelations = cssAsset.outgoingRelations.filter( relation => relation.node === rule ); const urlStrings = value .split(/,\s*/) .filter(entry => entry.startsWith('url(')); const urlValues = urlStrings.map((urlString, idx) => urlString.replace( fontRelations[idx].href, '" + "/__subfont__".toString("url") + "' ) ); url = `"${urlValues.join(', ')}"`; } }); fontFaceContructorCalls.push( `new FontFace("${name}", ${url}, ${JSON.stringify(props)}).load();` ); }); const jsPreloadAsset = htmlAsset .addRelation( { type: 'HtmlScript', hrefType: 'inline', to: { type: 'JavaScript', text: `try {${fontFaceContructorCalls.join('')}} catch (e) {}` } }, 'before', cssRelation ) .to.minify(); for (const [ idx, relation ] of jsPreloadAsset.outgoingRelations.entries()) { relation.to = cssAsset.outgoingRelations[idx].to; relation.hrefType = 'rootRelative'; relation.refreshHref(); } } } // Async load Google Web Fonts CSS const googleFontStylesheets = assetGraph.findAssets({ type: 'Css', url: { $regex: googleFontsCssUrlRegex } }); for (const googleFontStylesheet of googleFontStylesheets) { const seenPages = new Set(); // Only do the work once for each font on each page for (const googleFontStylesheetRelation of googleFontStylesheet.incomingRelations) { let htmlParents; if (googleFontStylesheetRelation.type === 'CssImport') { // Gather Html parents. Relevant if we are dealing with CSS @import relations htmlParents = getParents( assetGraph, googleFontStylesheetRelation.to, { type: 'Html', isInline: false, isLoaded: true } ); } else if (googleFontStylesheetRelation.from.type === 'Html') { htmlParents = [googleFontStylesheetRelation.from]; } else { htmlParents = []; } for (const htmlParent of htmlParents) { if (seenPages.has(htmlParent)) { continue; } seenPages.add(htmlParent); let insertPoint = htmlParent.outgoingRelations.find( relation => relation.type === 'HtmlStyle' ); // Resource hint: preconnect to the Google font stylesheet hostname insertPoint = insertPreconnect( htmlParent, googleFontStylesheetRelation.to.hostname, insertPoint ); // Resource hint: preconnect to the Google font hostname insertPreconnect( htmlParent, googleFontStylesheetRelation.to.outgoingRelations[0].to.hostname, insertPoint ); asyncLoadStyleRelationWithFallback( htmlParent, googleFontStylesheetRelation ); } } googleFontStylesheet.unload(); } // Use subsets in font-family: const webfontNameMap = {}; for (const { fontUsages } of htmlAssetTextsWithProps) { for (const { subsets, fontFamilies, props } of fontUsages) { if (subsets) { for (const fontFamily of fontFamilies) { webfontNameMap[fontFamily.toLowerCase()] = `${ props['font-family'] }__subset`; } } } } let customPropertyDefinitions; // Avoid computing this unless necessary // Inject subset font name before original webfont const cssAssets = assetGraph.findAssets({ type: 'Css', isLoaded: true }); let changesMadeToCustomPropertyDefinitions = false; for (const cssAsset of cssAssets) { let changesMade = false; cssAsset.eachRuleInParseTree(cssRule => { if (cssRule.parent.type === 'rule' && cssRule.type === 'decl') { const propName = cssRule.prop.toLowerCase(); if ( (propName === 'font' || propName === 'font-family') && cssRule.value.includes('var(') ) { if (!customPropertyDefinitions) { customPropertyDefinitions = findCustomPropertyDefinitions( cssAssets ); } for (const customPropertyName of extractReferencedCustomPropertyNames( cssRule.value )) { for (const relatedCssRule of [ cssRule, ...(customPropertyDefinitions[customPropertyName] || []) ]) { const modifiedValue = injectSubsetDefinitions( relatedCssRule.value, webfontNameMap ); if (modifiedValue !== relatedCssRule.value) { relatedCssRule.value = modifiedValue; changesMadeToCustomPropertyDefinitions = true; } } } } else if (propName === 'font-family') { const fontFamilies = cssListHelpers.splitByCommas(cssRule.value); for (let i = 0; i < fontFamilies.length; i += 1) { const subsetFontFamily = webfontNameMap[unquote(fontFamilies[i]).toLowerCase()]; if ( subsetFontFamily && !fontFamilies.includes(subsetFontFamily) ) { fontFamilies.splice( i, 0, cssQuoteIfNecessary(subsetFontFamily) ); i += 1; cssRule.value = fontFamilies.join(', '); changesMade = true; } } } else if (propName === 'font') { const fontProperties = cssFontParser(cssRule.value); const fontFamilies = fontProperties && fontProperties['font-family'].map(unquote); if (fontFamilies) { const subsetFontFamily = webfontNameMap[fontFamilies[0].toLowerCase()]; if ( subsetFontFamily && !fontFamilies.includes(subsetFontFamily) ) { // FIXME: Clean up and move elsewhere fontFamilies.unshift(subsetFontFamily); const stylePrefix = fontProperties['font-style'] ? `${fontProperties['font-style']} ` : ''; const weightPrefix = fontProperties['font-weight'] ? `${fontProperties['font-weight']} ` : ''; const lineHeightSuffix = fontProperties['line-height'] ? `/${fontProperties['line-height']}` : ''; cssRule.value = `${stylePrefix}${weightPrefix}${ fontProperties['font-size'] }${lineHeightSuffix} ${fontFamilies .map(cssQuoteIfNecessary) .join(', ')}`; changesMade = true; } } } } }); if (changesMade) { cssAsset.markDirty(); } } // This is a bit crude, could be more efficient if we tracked the containing asset in findCustomPropertyDefinitions if (changesMadeToCustomPropertyDefinitions) { for (const cssAsset of cssAssets) { cssAsset.markDirty(); } } // This is a bit awkward now, but if it's done sooner, it breaks the CSS source regexping: for (const cssAsset of subsetFontsToBeMinified) { await cssAsset.minify(); } // Hand out some useful info about the detected subsets: return { fontInfo: htmlAssetTextsWithProps.map(({ fontUsages, htmlAsset }) => ({ htmlAsset: htmlAsset.urlOrDescription, fontUsages: fontUsages.map(fontUsage => _.omit(fontUsage, 'subsets')) })) }; };