480 lines
14 KiB
JavaScript
480 lines
14 KiB
JavaScript
import isUrl from 'is-url';
|
|
import * as fontkit from 'fontkit';
|
|
import { PDFFont } from '@react-pdf/pdfkit';
|
|
|
|
// @ts-expect-error ts being silly
|
|
const STANDARD_FONTS = [
|
|
'Courier',
|
|
'Courier-Bold',
|
|
'Courier-Oblique',
|
|
'Courier-BoldOblique',
|
|
'Helvetica',
|
|
'Helvetica-Bold',
|
|
'Helvetica-Oblique',
|
|
'Helvetica-BoldOblique',
|
|
'Times-Roman',
|
|
'Times-Bold',
|
|
'Times-Italic',
|
|
'Times-BoldItalic',
|
|
];
|
|
class StandardFont {
|
|
name;
|
|
src;
|
|
fullName;
|
|
familyName;
|
|
subfamilyName;
|
|
postscriptName;
|
|
copyright;
|
|
version;
|
|
underlinePosition;
|
|
underlineThickness;
|
|
italicAngle;
|
|
bbox;
|
|
'OS/2';
|
|
hhea;
|
|
numGlyphs;
|
|
characterSet;
|
|
availableFeatures;
|
|
type;
|
|
constructor(src) {
|
|
this.name = src;
|
|
this.fullName = src;
|
|
this.familyName = src;
|
|
this.subfamilyName = src;
|
|
this.type = 'STANDARD';
|
|
this.postscriptName = src;
|
|
this.availableFeatures = [];
|
|
this.copyright = '';
|
|
this.version = 1;
|
|
this.underlinePosition = -100;
|
|
this.underlineThickness = 50;
|
|
this.italicAngle = 0;
|
|
this.bbox = {};
|
|
this['OS/2'] = {};
|
|
this.hhea = {};
|
|
this.numGlyphs = 0;
|
|
this.characterSet = [];
|
|
this.src = PDFFont.open(null, src);
|
|
}
|
|
encode(str) {
|
|
return this.src.encode(str);
|
|
}
|
|
layout(str) {
|
|
const [encoded, positions] = this.encode(str);
|
|
const glyphs = encoded.map((g, i) => {
|
|
const glyph = this.getGlyph(parseInt(g, 16));
|
|
glyph.advanceWidth = positions[i].advanceWidth;
|
|
return glyph;
|
|
});
|
|
const advanceWidth = positions.reduce((acc, p) => acc + p.advanceWidth, 0);
|
|
return {
|
|
positions,
|
|
stringIndices: positions.map((_, i) => i),
|
|
glyphs,
|
|
script: 'latin',
|
|
language: 'dflt',
|
|
direction: 'ltr',
|
|
features: {},
|
|
advanceWidth,
|
|
advanceHeight: 0,
|
|
bbox: undefined,
|
|
};
|
|
}
|
|
glyphForCodePoint(codePoint) {
|
|
const glyph = this.getGlyph(codePoint);
|
|
glyph.advanceWidth = 400;
|
|
return glyph;
|
|
}
|
|
getGlyph(id) {
|
|
return {
|
|
id,
|
|
codePoints: [id],
|
|
isLigature: false,
|
|
name: this.src.font.characterToGlyph(id),
|
|
_font: this.src,
|
|
// @ts-expect-error assign proper value
|
|
advanceWidth: undefined,
|
|
};
|
|
}
|
|
hasGlyphForCodePoint(codePoint) {
|
|
return this.src.font.characterToGlyph(codePoint) !== '.notdef';
|
|
}
|
|
// Based on empirical observation
|
|
get ascent() {
|
|
return 900;
|
|
}
|
|
// Based on empirical observation
|
|
get capHeight() {
|
|
switch (this.name) {
|
|
case 'Times-Roman':
|
|
case 'Times-Bold':
|
|
case 'Times-Italic':
|
|
case 'Times-BoldItalic':
|
|
return 650;
|
|
case 'Courier':
|
|
case 'Courier-Bold':
|
|
case 'Courier-Oblique':
|
|
case 'Courier-BoldOblique':
|
|
return 550;
|
|
default:
|
|
return 690;
|
|
}
|
|
}
|
|
// Based on empirical observation
|
|
get xHeight() {
|
|
switch (this.name) {
|
|
case 'Times-Roman':
|
|
case 'Times-Bold':
|
|
case 'Times-Italic':
|
|
case 'Times-BoldItalic':
|
|
return 440;
|
|
case 'Courier':
|
|
case 'Courier-Bold':
|
|
case 'Courier-Oblique':
|
|
case 'Courier-BoldOblique':
|
|
return 390;
|
|
default:
|
|
return 490;
|
|
}
|
|
}
|
|
// Based on empirical observation
|
|
get descent() {
|
|
switch (this.name) {
|
|
case 'Times-Roman':
|
|
case 'Times-Bold':
|
|
case 'Times-Italic':
|
|
case 'Times-BoldItalic':
|
|
return -220;
|
|
case 'Courier':
|
|
case 'Courier-Bold':
|
|
case 'Courier-Oblique':
|
|
case 'Courier-BoldOblique':
|
|
return -230;
|
|
default:
|
|
return -200;
|
|
}
|
|
}
|
|
get lineGap() {
|
|
return 0;
|
|
}
|
|
get unitsPerEm() {
|
|
return 1000;
|
|
}
|
|
stringsForGlyph() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
glyphsForString() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
widthOfGlyph() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
getAvailableFeatures() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
createSubset() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
getVariation() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
getFont() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
getName() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
setDefaultLanguage() {
|
|
throw new Error('Method not implemented.');
|
|
}
|
|
}
|
|
|
|
const fetchFont = async (src, options) => {
|
|
const response = await fetch(src, options);
|
|
const data = await response.arrayBuffer();
|
|
return new Uint8Array(data);
|
|
};
|
|
const isDataUrl = (dataUrl) => {
|
|
const header = dataUrl.split(',')[0];
|
|
const hasDataPrefix = header.substring(0, 5) === 'data:';
|
|
const hasBase64Prefix = header.split(';')[1] === 'base64';
|
|
return hasDataPrefix && hasBase64Prefix;
|
|
};
|
|
class FontSource {
|
|
src;
|
|
fontFamily;
|
|
fontStyle;
|
|
fontWeight;
|
|
data;
|
|
options;
|
|
loadResultPromise;
|
|
constructor(src, fontFamily, fontStyle, fontWeight, options) {
|
|
this.src = src;
|
|
this.fontFamily = fontFamily;
|
|
this.fontStyle = fontStyle || 'normal';
|
|
this.fontWeight = fontWeight || 400;
|
|
this.data = null;
|
|
this.options = options || {};
|
|
this.loadResultPromise = null;
|
|
}
|
|
async _load() {
|
|
const { postscriptName } = this.options;
|
|
let data = null;
|
|
if (STANDARD_FONTS.includes(this.src)) {
|
|
data = new StandardFont(this.src);
|
|
}
|
|
else if (isDataUrl(this.src)) {
|
|
const raw = this.src.split(',')[1];
|
|
const uint8Array = new Uint8Array(atob(raw)
|
|
.split('')
|
|
.map((c) => c.charCodeAt(0)));
|
|
data = fontkit.create(uint8Array, postscriptName);
|
|
}
|
|
else if (isUrl(this.src)) {
|
|
const { headers, body, method = 'GET' } = this.options;
|
|
const buffer = await fetchFont(this.src, { method, body, headers });
|
|
data = fontkit.create(buffer, postscriptName);
|
|
}
|
|
else {
|
|
data = await fontkit.open(this.src, postscriptName);
|
|
}
|
|
if (data && 'fonts' in data) {
|
|
throw new Error('Font collection is not supported');
|
|
}
|
|
this.data = data;
|
|
}
|
|
async load() {
|
|
if (this.loadResultPromise === null) {
|
|
this.loadResultPromise = this._load();
|
|
}
|
|
return this.loadResultPromise;
|
|
}
|
|
}
|
|
|
|
const FONT_WEIGHTS = {
|
|
thin: 100,
|
|
hairline: 100,
|
|
ultralight: 200,
|
|
extralight: 200,
|
|
light: 300,
|
|
normal: 400,
|
|
medium: 500,
|
|
semibold: 600,
|
|
demibold: 600,
|
|
bold: 700,
|
|
ultrabold: 800,
|
|
extrabold: 800,
|
|
heavy: 900,
|
|
black: 900,
|
|
};
|
|
const resolveFontWeight = (value) => {
|
|
return typeof value === 'string' ? FONT_WEIGHTS[value] : value;
|
|
};
|
|
const sortByFontWeight = (a, b) => a.fontWeight - b.fontWeight;
|
|
class FontFamily {
|
|
family;
|
|
sources;
|
|
static create(family) {
|
|
return new FontFamily(family);
|
|
}
|
|
constructor(family) {
|
|
this.family = family;
|
|
this.sources = [];
|
|
}
|
|
register({ src, fontWeight, fontStyle, ...options }) {
|
|
const numericFontWeight = fontWeight
|
|
? resolveFontWeight(fontWeight)
|
|
: undefined;
|
|
this.sources.push(new FontSource(src, this.family, fontStyle, numericFontWeight, options));
|
|
}
|
|
resolve(descriptor) {
|
|
const { fontWeight = 400, fontStyle = 'normal' } = descriptor;
|
|
const styleSources = this.sources.filter((s) => s.fontStyle === fontStyle);
|
|
const exactFit = styleSources.find((s) => s.fontWeight === fontWeight);
|
|
if (exactFit)
|
|
return exactFit;
|
|
// Weight resolution. https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Fallback_weights
|
|
let font = null;
|
|
const numericFontWeight = resolveFontWeight(fontWeight);
|
|
if (numericFontWeight >= 400 && numericFontWeight <= 500) {
|
|
const leftOffset = styleSources.filter((s) => s.fontWeight <= numericFontWeight);
|
|
const rightOffset = styleSources.filter((s) => s.fontWeight > 500);
|
|
const fit = styleSources.filter((s) => s.fontWeight >= numericFontWeight && s.fontWeight < 500);
|
|
font = fit[0] || leftOffset[leftOffset.length - 1] || rightOffset[0];
|
|
}
|
|
const lt = styleSources
|
|
.filter((s) => s.fontWeight < numericFontWeight)
|
|
.sort(sortByFontWeight);
|
|
const gt = styleSources
|
|
.filter((s) => s.fontWeight > numericFontWeight)
|
|
.sort(sortByFontWeight);
|
|
if (numericFontWeight < 400) {
|
|
font = lt[lt.length - 1] || gt[0];
|
|
}
|
|
if (numericFontWeight > 500) {
|
|
font = gt[0] || lt[lt.length - 1];
|
|
}
|
|
if (!font) {
|
|
throw new Error(`Could not resolve font for ${this.family}, fontWeight ${fontWeight}, fontStyle ${fontStyle}`);
|
|
}
|
|
return font;
|
|
}
|
|
}
|
|
|
|
class FontStore {
|
|
fontFamilies = {};
|
|
emojiSource = null;
|
|
constructor() {
|
|
this.register({
|
|
family: 'Helvetica',
|
|
fonts: [
|
|
{ src: 'Helvetica', fontStyle: 'normal', fontWeight: 400 },
|
|
{ src: 'Helvetica-Bold', fontStyle: 'normal', fontWeight: 700 },
|
|
{ src: 'Helvetica-Oblique', fontStyle: 'italic', fontWeight: 400 },
|
|
{ src: 'Helvetica-BoldOblique', fontStyle: 'italic', fontWeight: 700 },
|
|
],
|
|
});
|
|
this.register({
|
|
family: 'Courier',
|
|
fonts: [
|
|
{ src: 'Courier', fontStyle: 'normal', fontWeight: 400 },
|
|
{ src: 'Courier-Bold', fontStyle: 'normal', fontWeight: 700 },
|
|
{ src: 'Courier-Oblique', fontStyle: 'italic', fontWeight: 400 },
|
|
{ src: 'Courier-BoldOblique', fontStyle: 'italic', fontWeight: 700 },
|
|
],
|
|
});
|
|
this.register({
|
|
family: 'Times-Roman',
|
|
fonts: [
|
|
{ src: 'Times-Roman', fontStyle: 'normal', fontWeight: 400 },
|
|
{ src: 'Times-Bold', fontStyle: 'normal', fontWeight: 700 },
|
|
{ src: 'Times-Italic', fontStyle: 'italic', fontWeight: 400 },
|
|
{ src: 'Times-BoldItalic', fontStyle: 'italic', fontWeight: 700 },
|
|
],
|
|
});
|
|
// For backwards compatibility
|
|
this.register({
|
|
family: 'Helvetica-Bold',
|
|
src: 'Helvetica-Bold',
|
|
});
|
|
this.register({
|
|
family: 'Helvetica-Oblique',
|
|
src: 'Helvetica-Oblique',
|
|
});
|
|
this.register({
|
|
family: 'Helvetica-BoldOblique',
|
|
src: 'Helvetica-BoldOblique',
|
|
});
|
|
this.register({
|
|
family: 'Courier-Bold',
|
|
src: 'Courier-Bold',
|
|
});
|
|
this.register({
|
|
family: 'Courier-Oblique',
|
|
src: 'Courier-Oblique',
|
|
});
|
|
this.register({
|
|
family: 'Courier-BoldOblique',
|
|
src: 'Courier-BoldOblique',
|
|
});
|
|
this.register({
|
|
family: 'Times-Bold',
|
|
src: 'Times-Bold',
|
|
});
|
|
this.register({
|
|
family: 'Times-Italic',
|
|
src: 'Times-Italic',
|
|
});
|
|
this.register({
|
|
family: 'Times-BoldItalic',
|
|
src: 'Times-BoldItalic',
|
|
});
|
|
// Load default fonts
|
|
this.load({
|
|
fontFamily: 'Helvetica',
|
|
fontStyle: 'normal',
|
|
fontWeight: 400,
|
|
});
|
|
this.load({
|
|
fontFamily: 'Helvetica',
|
|
fontStyle: 'normal',
|
|
fontWeight: 700,
|
|
});
|
|
this.load({
|
|
fontFamily: 'Helvetica',
|
|
fontStyle: 'italic',
|
|
fontWeight: 400,
|
|
});
|
|
this.load({
|
|
fontFamily: 'Helvetica',
|
|
fontStyle: 'italic',
|
|
fontWeight: 700,
|
|
});
|
|
}
|
|
hyphenationCallback = null;
|
|
register = (data) => {
|
|
const { family } = data;
|
|
if (!this.fontFamilies[family]) {
|
|
this.fontFamilies[family] = FontFamily.create(family);
|
|
}
|
|
// Bulk loading
|
|
if ('fonts' in data) {
|
|
for (let i = 0; i < data.fonts.length; i += 1) {
|
|
const { src, fontStyle, fontWeight, ...options } = data.fonts[i];
|
|
this.fontFamilies[family].register({
|
|
src,
|
|
fontStyle,
|
|
fontWeight,
|
|
...options,
|
|
});
|
|
}
|
|
}
|
|
else {
|
|
const { src, fontStyle, fontWeight, ...options } = data;
|
|
this.fontFamilies[family].register({
|
|
src,
|
|
fontStyle,
|
|
fontWeight,
|
|
...options,
|
|
});
|
|
}
|
|
};
|
|
registerEmojiSource = (emojiSource) => {
|
|
this.emojiSource = emojiSource;
|
|
};
|
|
registerHyphenationCallback = (callback) => {
|
|
this.hyphenationCallback = callback;
|
|
};
|
|
getFont = (descriptor) => {
|
|
const { fontFamily } = descriptor;
|
|
if (!this.fontFamilies[fontFamily]) {
|
|
throw new Error(`Font family not registered: ${fontFamily}. Please register it calling Font.register() method.`);
|
|
}
|
|
return this.fontFamilies[fontFamily].resolve(descriptor);
|
|
};
|
|
load = async (descriptor) => {
|
|
const font = this.getFont(descriptor);
|
|
if (font)
|
|
await font.load();
|
|
};
|
|
reset = () => {
|
|
const keys = Object.keys(this.fontFamilies);
|
|
for (let i = 0; i < keys.length; i += 1) {
|
|
const key = keys[i];
|
|
for (let j = 0; j < this.fontFamilies[key].sources.length; j++) {
|
|
const fontSource = this.fontFamilies[key].sources[j];
|
|
fontSource.data = null;
|
|
}
|
|
}
|
|
};
|
|
clear = () => {
|
|
this.fontFamilies = {};
|
|
};
|
|
getRegisteredFonts = () => this.fontFamilies;
|
|
getEmojiSource = () => this.emojiSource;
|
|
getHyphenationCallback = () => this.hyphenationCallback;
|
|
getRegisteredFontFamilies = () => Object.keys(this.fontFamilies);
|
|
}
|
|
|
|
export { FontStore as default };
|