Skip to content

Commit

Permalink
✨ Support fontWeight and fontStyle
Browse files Browse the repository at this point in the history
The text attributes `bold` and `italic` only allow the selection of a
single alternative font weight and font style.

This commit introduces the text attributes `fontWeight` and `fontStyle`
as replacement for `bold` and `italic`. These new attributes correspond
to the CSS properties [font-weight] and [font-style]. This enables the
use of different weights of the same font family.

The attributes `bold` and `italic` are still supported, but they are
deprecated and will be removed in a future release.

[font-weight]: https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight
[font-style]: https://developer.mozilla.org/en-US/docs/Web/CSS/font-style
  • Loading branch information
ralfstx committed Nov 26, 2023
1 parent 7807478 commit 09b445d
Show file tree
Hide file tree
Showing 12 changed files with 373 additions and 159 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [0.5.4] - Unreleased

### Added

- Text attributes `fontStyle` and `fontWeight`.

### Deprecated

- Text attributes `bold` and `italic` in favor of `fontStyle: 'italic'`
and `fontWeight: 'bold'`.

### Fixed

- Text in a text block will no longer break before the first row, which
Expand Down
23 changes: 23 additions & 0 deletions src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,13 +503,34 @@ type TransformAttrs = {
*/
export type Text = string | ({ text: Text } & TextAttrs) | Text[];

/**
* The font weight is an integer between 0 and 1000.
* The keywords `normal` (400) and `bold` (700) are also supported.
*/
export type FontWeight = number | 'normal' | 'bold';

/**
* The font style selects a normal, italic, or oblique font face from
* the font family. Italic fonts are usually cursive in nature and
* oblique fonts are usually sloped versions of the regular font.
*/
export type FontStyle = 'normal' | 'italic' | 'oblique';

export type TextAttrs = {
/**
* The name of the font to use.
* If not specified, the first font registered in the document definition that matches the other
* font attributes will be used.
*/
fontFamily?: string;
/**
* The font style to use.
*/
fontStyle?: FontStyle;
/**
* The font weight to use.
*/
fontWeight?: FontWeight;
/**
* The font size in pt.
*/
Expand All @@ -520,10 +541,12 @@ export type TextAttrs = {
lineHeight?: number;
/**
* Whether to use a bold variant of the selected font.
* @deprecated Use `fontWeight: 'bold'` instead.
*/
bold?: boolean;
/**
* Whether to use an italic variant of the selected font.
* @deprecated Use `fontStyle: 'italic'` instead.
*/
italic?: boolean;
/**
Expand Down
187 changes: 115 additions & 72 deletions src/font-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,137 @@
import { beforeEach, describe, expect, it } from '@jest/globals';

import { createFontLoader, FontLoader } from './font-loader.js';
import { Font } from './fonts.js';
import { fakeFont } from './test/test-utils.js';
import { FontDef } from './fonts.js';
import { mkData } from './test/test-utils.js';

describe('font-loader', () => {
let normalFont: Font;
let italicFont: Font;
let boldFont: Font;
let italicBoldFont: Font;
let otherFont: Font;
let normalFont: FontDef;
let italicFont: FontDef;
let obliqueFont: FontDef;
let boldFont: FontDef;
let italicBoldFont: FontDef;
let obliqueBoldFont: FontDef;
let otherFont: FontDef;
let fontLoader: FontLoader;

beforeEach(() => {
normalFont = fakeFont('Test');
italicFont = fakeFont('Test', { italic: true });
boldFont = fakeFont('Test', { bold: true });
italicBoldFont = fakeFont('Test', { italic: true, bold: true });
otherFont = fakeFont('Other');
fontLoader = createFontLoader([normalFont, italicFont, boldFont, italicBoldFont, otherFont]);
});

it('rejects for unknown font', async () => {
const loader = createFontLoader([]);

expect(loader.loadFont({})).rejects.toThrowError('No font defined for normal');
});

it('rejects for unknown font name', async () => {
await expect(fontLoader.loadFont({ fontFamily: 'foo' })).rejects.toThrowError(
"No font defined for 'foo', normal"
);
});
describe('createFontLoader', () => {
beforeEach(() => {
normalFont = fakeFontDef('Test');
italicFont = fakeFontDef('Test', { style: 'italic' });
obliqueFont = fakeFontDef('Test', { style: 'oblique' });
boldFont = fakeFontDef('Test', { weight: 700 });
italicBoldFont = fakeFontDef('Test', { style: 'italic', weight: 700 });
obliqueBoldFont = fakeFontDef('Test', { style: 'oblique', weight: 700 });
otherFont = fakeFontDef('Other');
fontLoader = createFontLoader([normalFont, italicFont, boldFont, italicBoldFont, otherFont]);
});

it('selects different font variants', async () => {
const fontFamily = 'Test';
it('rejects when no fonts defined', async () => {
const loader = createFontLoader([]);

expect(await fontLoader.loadFont({ fontFamily })).toEqual({
name: 'Test',
data: normalFont.data,
});
expect(await fontLoader.loadFont({ fontFamily, bold: true })).toEqual({
name: 'Test',
data: boldFont.data,
await expect(loader.loadFont({})).rejects.toThrowError('No fonts defined');
});
expect(await fontLoader.loadFont({ fontFamily, italic: true })).toEqual({
name: 'Test',
data: italicFont.data,

it('rejects for unknown font name', async () => {
await expect(fontLoader.loadFont({ fontFamily: 'Unknown' })).rejects.toThrowError(
"No font defined for 'Unknown'"
);
});
expect(await fontLoader.loadFont({ fontFamily, italic: true, bold: true })).toEqual({
name: 'Test',
data: italicBoldFont.data,

it('rejects when no matching font style can be found', async () => {
await expect(() =>
fontLoader.loadFont({ fontFamily: 'Other', fontStyle: 'italic' })
).rejects.toThrowError("No font defined for 'Other', style=italic");
});
});

it('selects first matching font if no family specified', async () => {
await expect(fontLoader.loadFont({})).resolves.toEqual({
name: 'Test',
data: normalFont.data,
it('selects different font variants', async () => {
const fontFamily = 'Test';

expect(await fontLoader.loadFont({ fontFamily })).toEqual({
name: 'Test',
data: normalFont.data,
});
expect(await fontLoader.loadFont({ fontFamily, fontWeight: 'bold' })).toEqual({
name: 'Test',
data: boldFont.data,
});
expect(await fontLoader.loadFont({ fontFamily, fontStyle: 'italic' })).toEqual({
name: 'Test',
data: italicFont.data,
});
expect(
await fontLoader.loadFont({ fontFamily, fontStyle: 'italic', fontWeight: 'bold' })
).toEqual({
name: 'Test',
data: italicBoldFont.data,
});
});
await expect(fontLoader.loadFont({ bold: true })).resolves.toEqual({
name: 'Test',
data: boldFont.data,

it('selects first matching font if no family specified', async () => {
await expect(fontLoader.loadFont({})).resolves.toEqual({
name: 'Test',
data: normalFont.data,
});
await expect(fontLoader.loadFont({ fontWeight: 'bold' })).resolves.toEqual({
name: 'Test',
data: boldFont.data,
});
await expect(fontLoader.loadFont({ fontStyle: 'italic' })).resolves.toEqual({
name: 'Test',
data: italicFont.data,
});
await expect(
fontLoader.loadFont({ fontStyle: 'italic', fontWeight: 'bold' })
).resolves.toEqual({
name: 'Test',
data: italicBoldFont.data,
});
});
await expect(fontLoader.loadFont({ italic: true })).resolves.toEqual({
name: 'Test',
data: italicFont.data,

it('selects font with matching font family', async () => {
await expect(fontLoader.loadFont({ fontFamily: 'Other' })).resolves.toEqual({
name: 'Other',
data: otherFont.data,
});
});
await expect(fontLoader.loadFont({ italic: true, bold: true })).resolves.toEqual({
name: 'Test',
data: italicBoldFont.data,

it('falls back to oblique when no italic font can be found', async () => {
fontLoader = createFontLoader([normalFont, obliqueFont, boldFont, obliqueBoldFont]);
await expect(
fontLoader.loadFont({ fontFamily: 'Test', fontStyle: 'italic' })
).resolves.toEqual({
name: 'Test',
data: obliqueFont.data,
});
});
});

it('selects font with matching font family', async () => {
await expect(fontLoader.loadFont({ fontFamily: 'Other' })).resolves.toEqual({
name: 'Other',
data: otherFont.data,
it('falls back to italic when no oblique font can be found', async () => {
await expect(
fontLoader.loadFont({ fontFamily: 'Test', fontStyle: 'oblique' })
).resolves.toEqual({
name: 'Test',
data: italicFont.data,
});
});
});

it('rejects when no matching font can be found', async () => {
await expect(() =>
fontLoader.loadFont({ fontFamily: 'Other', italic: true })
).rejects.toThrowError("No font defined for 'Other', italic");
await expect(() =>
fontLoader.loadFont({ fontFamily: 'Other', bold: true })
).rejects.toThrowError("No font defined for 'Other', bold");
await expect(() =>
fontLoader.loadFont({ fontFamily: 'Other', italic: true, bold: true })
).rejects.toThrowError("No font defined for 'Other', bold italic");
it('falls back when no matching font weight can be found', async () => {
await expect(
fontLoader.loadFont({ fontFamily: 'Other', fontWeight: 'bold' })
).resolves.toEqual({
name: 'Other',
data: otherFont.data,
});
await expect(fontLoader.loadFont({ fontFamily: 'Other', fontWeight: 200 })).resolves.toEqual({
name: 'Other',
data: otherFont.data,
});
});
});
});

function fakeFontDef(family: string, options?: Partial<FontDef>): FontDef {
const style = options?.style ?? 'normal';
const weight = options?.weight ?? 400;
const data = options?.data ?? mkData([family, style, weight].join('_') as string);
return { family, style, weight, data };
}
76 changes: 61 additions & 15 deletions src/font-loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { toUint8Array } from 'pdf-lib';

import { FontDef, FontSelector } from './fonts.js';
import { FontWeight } from './content.js';
import { FontDef, FontSelector, weightToNumber } from './fonts.js';
import { pickDefined } from './types.js';

export type LoadedFont = {
Expand All @@ -18,25 +19,70 @@ export function createFontLoader(fontDefs: FontDef[]): FontLoader {
};

async function loadFont(selector: FontSelector) {
const fontDef = fontDefs.find((def) => match(def, selector));
if (!fontDef) {
const { fontFamily, italic, bold } = selector;
const style = italic ? (bold ? 'bold italic' : 'italic') : bold ? 'bold' : 'normal';
const selectorStr = fontFamily ? `'${fontFamily}', ${style}` : style;
if (!fontDefs.length) {
throw new Error('No fonts defined');
}
const fontsWithMatchingFamily = selector.fontFamily
? fontDefs.filter((def) => def.family === selector.fontFamily)
: fontDefs;
if (!fontsWithMatchingFamily.length) {
throw new Error(`No font defined for '${selector.fontFamily}'`);
}
let fontsWithMatchingStyle = fontsWithMatchingFamily.filter(
(def) => def.style === (selector.fontStyle ?? 'normal')
);
if (!fontsWithMatchingStyle.length) {
fontsWithMatchingStyle = fontsWithMatchingFamily.filter(
(def) =>
(def.style === 'italic' && selector.fontStyle === 'oblique') ||
(def.style === 'oblique' && selector.fontStyle === 'italic')
);
}
if (!fontsWithMatchingStyle.length) {
const { fontFamily: family, fontStyle: style } = selector;
const selectorStr = `'${family}', style=${style ?? 'normal'}`;
throw new Error(`No font defined for ${selectorStr}`);
}
const selected = selectFontForWeight(fontsWithMatchingStyle, selector.fontWeight ?? 'normal');
if (!selected) {
const { fontFamily: family, fontStyle: style, fontWeight: weight } = selector;
const selectorStr = `'${family}', style=${style ?? 'normal'}, weight=${weight ?? 'normal'}`;
throw new Error(`No font defined for ${selectorStr}`);
}
const data = toUint8Array(fontDef.data);
return pickDefined({
name: fontDef.name,
data,
name: selected.family,
data: toUint8Array(selected.data),
});
}
}

function match(fontDef: FontDef, selector: FontSelector): boolean {
return (
(!selector.fontFamily || fontDef.name === selector.fontFamily) &&
!fontDef.italic === !selector.italic &&
!fontDef.bold === !selector.bold
);
function selectFontForWeight(fonts: FontDef[], weight: FontWeight): FontDef | undefined {
const weightNum = weightToNumber(weight);
const font = fonts.find((font) => font.weight === weightNum);
if (font) return font;

// Fallback according to
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights
const ascending = fonts.slice().sort((a, b) => a.weight - b.weight);
const descending = ascending.slice().reverse();
if (weightNum >= 400 && weightNum <= 500) {
const font =
ascending.find((font) => font.weight > weightNum && font.weight <= 500) ??
descending.find((font) => font.weight < weightNum) ??
ascending.find((font) => font.weight > 500);
if (font) return font;
}
if (weightNum < 400) {
const font =
descending.find((font) => font.weight < weightNum) ??
ascending.find((font) => font.weight > weightNum);
if (font) return font;
}
if (weightNum > 500) {
const font =
ascending.find((font) => font.weight > weightNum) ??
descending.find((font) => font.weight < weightNum);
if (font) return font;
}
throw new Error(`Could not find font for weight ${weight}`);
}
Loading

0 comments on commit 09b445d

Please sign in to comment.