Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rfc: create a minifyInlineFonts plugin #1794

Closed
1 task done
SethFalco opened this issue Sep 24, 2023 · 1 comment
Closed
1 task done

rfc: create a minifyInlineFonts plugin #1794

SethFalco opened this issue Sep 24, 2023 · 1 comment
Labels

Comments

@SethFalco
Copy link
Member

SethFalco commented Sep 24, 2023

This branches off the discussion in #660.

Describe what the plugin should do

A plugin that detects inline fonts, and removes unused glyphs based on the text content in the SVG.

I've made a demo based on Dirk Lemstra's README, but with the WOFF2 font replaced with a TTF, as handling WOFF2 is a pain. This has JetBrains Mono inlined, and only uses the glyphs for OPEN SOURCE MAINTAINER in it.

If we were to opt for the implementation based on what I have, due to the additional dependencies it's probably not worth including the plugin in this repository. Rather, it can be installed separately and imported.

The following demo only has optimizeInlineFonts enabled. It looks fine on desktop, but the optimized file is broken in Firefox on Android. 🤔

dlemstra-jetbrains.svg (246 KiB)
dlemstra-jetbrains

dlemstra-optimized.svg (6 KiB)
dlemstra-optimized

Additional context

You can review the usable implementation here, or just expand optimizeInlineFonts.js below to see the plugin itself:

optimizeInlineFonts.js
'use strict';

/**
 * @typedef {import('css-tree').CssNode} CssNode
 * @typedef {import('../lib/types').XastText} XastText
 */

const csstree = require('css-tree');
const opentype = require('opentype.js');

const { collectStylesheet, computeStyle } = require('../lib/style');

exports.name = 'optimizeInlineFonts';
exports.description = 'remove unused glyphs from inline fonts';

/**
 * @see https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#values
 */
const genericFontFamilies = [
  'serif',
  'sans-serif',
  'monospace',
  'cursive',
  'fantasy',
  'system-ui',
  'ui-serif',
  'ui-sans-serif',
  'ui-monospace',
  'ui-rounded',
  'math',
  'emoji',
  'fangsong'
];

/**
 * @type {import('./plugins-types').Plugin<'optimizeInlineFonts'>}
 */
exports.fn = (root) => {

  const stylesheet = collectStylesheet(root);

  /**
   * Set of all font-families used in the document.
   *
   * @type {Set<string>}
   */
  const fonts = new Set();

  /**
   * Store all glyphs so we don't mess up ligatures.
   *
   * @type {Set<string>}
   */
  const text = new Set();

  /**
   * @type {XastText}
   */
  let styleText;

  /**
   * @type {CssNode}
   */
  let styles;

  return {
    text: {
      enter: (node, parentNode) => {
        if (
          styles == null &&
          parentNode.type === 'element' &&
          parentNode.name === 'style'
        ) {
          styleText = node;
          styles = csstree.parse(node.value, {
            context: 'stylesheet'
          });
          return;
        }

        text.add(node.value);
      }
    },

    element: {
      enter: (node) => {
        const styles = computeStyle(stylesheet, node);
        const fontFamilyStyle = styles['font-family'];

        if (fontFamilyStyle == null || fontFamilyStyle.type === 'dynamic') {
          return;
        }

        const fontFamilies = fontFamilyStyle.value;
        const parsed = csstree.parse(fontFamilies, {
          context: 'value'
        });

        const fontFamilyNames = csstree.findAll(parsed, (node) => node.type === 'String');

        for (const fontFamily of fontFamilyNames) {
          if (fontFamily.type === 'String' && !genericFontFamilies.includes(fontFamily.value)) {
            fonts.add(fontFamily.value);
          }
        }
      },
    },

    root: {
      exit: () => {
        if (styles == null || styles.type !== 'StyleSheet') {
          return;
        }

        for (const fontName of fonts) {
          const fontNode = csstree.find(styles, (node) => {
            return node.type === 'Url' && node.value.startsWith('data:font/truetype;charset=utf-8;base64,');
          });

          if (fontNode == null || fontNode.type !== 'Url') {
            continue;
          }

          const fontData = fontNode.value.split('base64,')[1];
          const font = opentype.parse(Buffer.from(fontData, 'base64').buffer);

          const glyphs = new Set();

          for (let char of [...text].join('')) {
            if (char === ' ') {
              char = 'space';
            }

            glyphs.add(font.charToGlyph(char));
          }

          const newFont = new opentype.Font({
            familyName: fontName,
            styleName: 'Optimized',
            unitsPerEm: font.unitsPerEm,
            ascender: font.ascender,
            descender: font.descender,
            glyphs: [...glyphs]
          });

          const newFontData = Buffer.from(newFont.toArrayBuffer()).toString('base64');
          fontNode.value = `data:font/truetype;charset=utf-8;base64,${newFontData}`;
        }

        styleText.value = csstree.generate(styles);
      }
    }
  }
}

I half-assed it since it's just for demonstrative purposes. If we proceed with this, I'll happily improve it. Some known issues:

  • Sometimes impacts letter spacing / kerning of font.
  • Does not handle ligatures.
  • Only supports TTF fonts, most people use WOFF or WOFF2 for the web, though.
  • Only replaces first font found, not the others if there's more than one.
  • Only checks the first style tag for inlined fonts.
  • Does not grab strings that may be in JavaScript or CSS.
  • While minifying the font, we could rename it to a single character.

Implementation

  • Are you volunteering to work on this plugin?
@SethFalco
Copy link
Member Author

Closing this as a duplicate of #992.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant