Skip to content

Commit

Permalink
Merge pull request #161 from Munter/tech/explicitlyIncludeCharacters
Browse files Browse the repository at this point in the history
Add support for specifying additional characters to include in the subsets
  • Loading branch information
papandreou authored Jun 13, 2022
2 parents 048b4bb + 54cb99b commit e82ae9e
Show file tree
Hide file tree
Showing 17 changed files with 381 additions and 38 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ Run subfont on the files you are ready to deploy to a static file hosting servic

If you want to run directly against your raw original files, it is recommended to create a recursive copy of your files which you run `subfont` on. This keeps your original authoring abstraction unchanged.

## Including additional characters in the subsets

If you have a use case where the automatic tracing doesn't find all the characters you need, you can tell subfont to include specific characters in the subsets by adding a custom `-subfont-text` property to the respective `@font-face` declarations.

Example where all numerical digits are added to the bold italic Roboto variant:

```css
@font-face {
font-family: Roboto;
font-style: italic;
font-weight: 700;
src: url(roboto.woff) format('woff');
-subfont-text: '0123456789';
}
```

An easier, but less fine-grained option is to use the `--text` switch to include a set of characters in all created subsets.

## Other usages

You can have subfont output a copy of your input files to a new directory. This uses [Assetgraph](/~https://github.com/assetgraph/assetgraph) to trace a dependency graph of your website and writes it to your specified output directory. Be aware of any errors or warnings that might indicate Assetgraph having problems with your code, and be sure to double check that the expected files are in the output directory. Run `subfont path/to/index.html -o path/to/outputDir`.
Expand Down Expand Up @@ -72,6 +90,8 @@ Options:
the formats based on the browser capabilities as specified
via --browsers or the browserslist configuration.
[array] [choices: "woff2", "woff", "truetype"]
--text Additional characters to include in the subset for every
@font-face found on the page [string]
--fallbacks Include fallbacks so the original font will be loaded when
dynamic content gets injected at runtime. Disable with
--no-fallbacks [boolean] [default: true]
Expand Down
5 changes: 5 additions & 0 deletions lib/parseCommandLineOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ module.exports = function parseCommandLineOptions(argv) {
);
},
})
.options('text', {
describe:
'Additional characters to include in the subset for every @font-face found on the page',
type: 'string',
})
.options('fallbacks', {
describe:
'Include fallbacks so the original font will be loaded when dynamic content gets injected at runtime. Disable with --no-fallbacks',
Expand Down
2 changes: 2 additions & 0 deletions lib/subfont.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.exports = async function subfont(
fallbacks = true,
dynamic = false,
browsers,
text,
},
console
) {
Expand Down Expand Up @@ -224,6 +225,7 @@ module.exports = async function subfont(
formats,
omitFallbacks: !fallbacks,
hrefType: relativeUrls ? 'relative' : 'rootRelative',
text,
dynamic,
console,
});
Expand Down
128 changes: 90 additions & 38 deletions lib/subsetFonts.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,50 +103,84 @@ function groupTextsByFontFamilyProps(
htmlOrSvgAsset,
globalTextByPropsArray,
pageTextByPropsArray,
availableFontFaceDeclarations
availableFontFaceDeclarations,
text
) {
const snappedTexts = _.flatMapDeep(globalTextByPropsArray, (textAndProps) => {
const isOnPage = pageTextByPropsArray.includes(textAndProps);
const family = textAndProps.props['font-family'];
if (family === undefined) {
return [];
const snappedTexts = [];

for (const fontFaceDeclaration of availableFontFaceDeclarations) {
const {
relations,
'-subfont-text': subfontText,
...props
} = fontFaceDeclaration;
if (subfontText !== undefined) {
delete fontFaceDeclaration['-subfont-text'];
snappedTexts.push({
htmlOrSvgAsset,
fontRelations: relations,
fontUrl: getPreferredFontUrl(relations),
preload: false,
text: unquote(subfontText),
props,
});
}
// Find all the families in the traced font-family that we have @font-face declarations for:
const families = fontFamily
.parse(family)
.filter((family) =>
availableFontFaceDeclarations.some(
(fontFace) =>
fontFace['font-family'].toLowerCase() === family.toLowerCase()
)
);

return families.map((family) => {
const activeFontFaceDeclaration = fontSnapper(
availableFontFaceDeclarations,
{
...textAndProps.props,
'font-family': fontFamily.stringify([family]),
}
);
if (text !== undefined) {
snappedTexts.push({
htmlOrSvgAsset,
fontRelations: relations,
fontUrl: getPreferredFontUrl(relations),
preload: false,
text,
props,
});
}
}

if (!activeFontFaceDeclaration) {
snappedTexts.push(
..._.flatMapDeep(globalTextByPropsArray, (textAndProps) => {
const isOnPage = pageTextByPropsArray.includes(textAndProps);
const family = textAndProps.props['font-family'];
if (family === undefined) {
return [];
}
// Find all the families in the traced font-family that we have @font-face declarations for:
const families = fontFamily
.parse(family)
.filter((family) =>
availableFontFaceDeclarations.some(
(fontFace) =>
fontFace['font-family'].toLowerCase() === family.toLowerCase()
)
);

const { relations, ...props } = activeFontFaceDeclaration;
const fontUrl = getPreferredFontUrl(relations);
return families.map((family) => {
const activeFontFaceDeclaration = fontSnapper(
availableFontFaceDeclarations,
{
...textAndProps.props,
'font-family': fontFamily.stringify([family]),
}
);

return {
htmlOrSvgAsset: textAndProps.htmlOrSvgAsset,
text: textAndProps.text,
props,
fontRelations: relations,
fontUrl,
preload: isOnPage,
};
});
}).filter((textByProps) => textByProps && textByProps.fontUrl);
if (!activeFontFaceDeclaration) {
return [];
}

const { relations, ...props } = activeFontFaceDeclaration;
const fontUrl = getPreferredFontUrl(relations);

return {
htmlOrSvgAsset: textAndProps.htmlOrSvgAsset,
text: textAndProps.text,
props,
fontRelations: relations,
fontUrl,
preload: isOnPage,
};
});
}).filter((textByProps) => textByProps && textByProps.fontUrl)
);

const textsByFontUrl = _.groupBy(snappedTexts, 'fontUrl');

Expand Down Expand Up @@ -546,6 +580,7 @@ async function subsetFonts(
onlyInfo,
dynamic,
console = global.console,
text,
} = {}
) {
if (!validFontDisplayValues.includes(fontDisplay)) {
Expand Down Expand Up @@ -713,10 +748,27 @@ async function subsetFonts(
htmlOrSvgAsset,
globalTextByProps,
textByProps,
accumulatedFontFaceDeclarations
accumulatedFontFaceDeclarations,
text
);
}

for (const fontFaceDeclarations of fontFaceDeclarationsByHtmlOrSvgAsset.values()) {
for (const fontFaceDeclaration of fontFaceDeclarations) {
const firstRelation = fontFaceDeclaration.relations[0];
const subfontTextNode = firstRelation.node.nodes.find(
(childNode) =>
childNode.type === 'decl' &&
childNode.prop.toLowerCase() === '-subfont-text'
);

if (subfontTextNode) {
subfontTextNode.remove();
firstRelation.from.markDirty();
}
}
}

if (omitFallbacks) {
for (const htmlOrSvgAsset of htmlOrSvgAssets) {
const accumulatedFontFaceDeclarations =
Expand Down
158 changes: 158 additions & 0 deletions test/subsetFonts.js
Original file line number Diff line number Diff line change
Expand Up @@ -3277,6 +3277,164 @@ describe('subsetFonts', function () {
});
});

describe('with -subfont-text', function () {
describe('with a @font-face that is unused', function () {
it('should make a subset with the specified characters', async function () {
const assetGraph = new AssetGraph({
root: pathModule.resolve(
__dirname,
'../testdata/subsetFonts/local-unused-with-subfont-text/'
),
});
await assetGraph.loadAssets('index.html');
await assetGraph.populate();
const { fontInfo } = await subsetFonts(assetGraph);

expect(fontInfo, 'to satisfy', {
0: {
fontUsages: [
{
texts: ['0123456789'],
text: '0123456789',
},
],
},
});

// Make sure that the annotation gets stripped from the output:
for (const cssAsset of assetGraph.findAssets({ type: 'Css' })) {
expect(cssAsset.text, 'not to contain', '-subfont-text');
}
});
});

describe('with a @font-face that is also used', function () {
describe('on a single page', function () {
it('should add the specified characters to the subset', async function () {
const assetGraph = new AssetGraph({
root: pathModule.resolve(
__dirname,
'../testdata/subsetFonts/local-used-with-subfont-text/'
),
});
await assetGraph.loadAssets('index.html');
await assetGraph.populate();
const { fontInfo } = await subsetFonts(assetGraph);

expect(fontInfo, 'to satisfy', {
0: {
fontUsages: [
{
texts: ['0123456789', 'Hello, world!'],
text: ' !,0123456789Hdelorw',
},
],
},
});

// Make sure that the annotation gets stripped from the output:
for (const cssAsset of assetGraph.findAssets({ type: 'Css' })) {
expect(cssAsset.text, 'not to contain', '-subfont-text');
}
});
});

describe('when the CSS is shared between multiple pages', function () {
it('should add the specified characters to the subset', async function () {
const assetGraph = new AssetGraph({
root: pathModule.resolve(
__dirname,
'../testdata/subsetFonts/local-used-multipage-with-subfont-text/'
),
});
await assetGraph.loadAssets('page*.html');
await assetGraph.populate();
const { fontInfo } = await subsetFonts(assetGraph);

expect(fontInfo, 'to satisfy', {
0: {
fontUsages: [
{
texts: ['0123456789', 'Hello, world!', 'Aloha, world!'],
text: ' !,0123456789AHadehlorw',
},
],
},
1: {
fontUsages: [
{
texts: ['0123456789', 'Hello, world!', 'Aloha, world!'],
text: ' !,0123456789AHadehlorw',
},
],
},
});

// Make sure that the annotation gets stripped from the output:
for (const cssAsset of assetGraph.findAssets({ type: 'Css' })) {
expect(cssAsset.text, 'not to contain', '-subfont-text');
}
});
});
});
});

describe('with text explicitly passed to be included in all fonts', function () {
describe('with a @font-face that is unused', function () {
it('should make a subset with the specified characters', async function () {
const assetGraph = new AssetGraph({
root: pathModule.resolve(
__dirname,
'../testdata/subsetFonts/local-unused/'
),
});
await assetGraph.loadAssets('index.html');
await assetGraph.populate();
const { fontInfo } = await subsetFonts(assetGraph, {
text: '0123456789',
});

expect(fontInfo, 'to satisfy', {
0: {
fontUsages: [
{
texts: ['0123456789'],
text: '0123456789',
},
],
},
});
});
});

describe('with a @font-face that is used', function () {
it('should add the specified characters to the subset', async function () {
const assetGraph = new AssetGraph({
root: pathModule.resolve(
__dirname,
'../testdata/subsetFonts/local-used/'
),
});
await assetGraph.loadAssets('index.html');
await assetGraph.populate();
const { fontInfo } = await subsetFonts(assetGraph, {
text: '0123456789',
});

expect(fontInfo, 'to satisfy', {
0: {
fontUsages: [
{
texts: ['0123456789', 'Hello, world!'],
text: ' !,0123456789Hdelorw',
},
],
},
});
});
});
});

describe('with SVG using webfonts', function () {
describe('in a standalone SVG', function () {
it('should trace the correct characters and patch up the stylesheet', async function () {
Expand Down
Binary file not shown.
Loading

0 comments on commit e82ae9e

Please sign in to comment.