fonts: support variable fonts when importing ttf/otf font files, closes #2003 (#2007)

This commit is contained in:
Huang Xin
2025-09-09 17:25:27 +08:00
committed by GitHub
parent d95656b37b
commit 01fde8573c
4 changed files with 76 additions and 13 deletions
@@ -74,6 +74,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
family: fontInfo.family,
style: fontInfo.style,
weight: fontInfo.weight,
variable: fontInfo.variable,
});
console.log('Added custom font:', customFont);
if (customFont && !customFont.error) {
@@ -8,10 +8,7 @@ interface FontStoreState {
loading: boolean;
setFonts: (fonts: CustomFont[]) => void;
addFont: (
path: string,
options?: { name?: string; family?: string; style?: string; weight?: number },
) => CustomFont;
addFont: (path: string, options?: Partial<Omit<CustomFont, 'id' | 'path'>>) => CustomFont;
removeFont: (id: string) => boolean;
updateFont: (id: string, updates: Partial<CustomFont>) => boolean;
getFont: (id: string) => CustomFont | undefined;
+8 -9
View File
@@ -120,10 +120,12 @@ export type FontStyle = 'normal' | 'italic' | 'oblique';
export interface CustomFont {
id: string;
name: string;
path: string;
family?: string;
style?: string;
weight?: number;
path: string;
variable?: boolean;
downloadedAt?: number;
deletedAt?: number;
@@ -177,6 +179,7 @@ export function createFontCSS(font: CustomFont): string {
const fontFamily = createFontFamily(font.family || font.name);
const fontStyle = font.style || 'normal';
const fontWeight = font.weight || 400;
const variable = font.variable || false;
if (!font.blobUrl) {
throw new Error(`Blob URL not available for font: ${font.name}`);
}
@@ -184,8 +187,8 @@ export function createFontCSS(font: CustomFont): string {
const css = `
@font-face {
font-family: "${fontFamily}";
font-style: ${fontStyle};
font-weight: ${fontWeight};
${variable ? '' : `font-style: ${fontStyle};`}
${variable ? '' : `font-weight: ${fontWeight};`}
src: url("${font.blobUrl}") format("${cssFormat}");
font-display: swap;
}
@@ -196,12 +199,7 @@ export function createFontCSS(font: CustomFont): string {
export function createCustomFont(
path: string,
options?: {
name?: string;
family?: string;
style?: string;
weight?: number;
},
options?: Partial<Omit<CustomFont, 'id' | 'path'>>,
): CustomFont {
const name = options?.name || getFontName(path);
return {
@@ -210,6 +208,7 @@ export function createCustomFont(
family: options?.family,
style: options?.style,
weight: options?.weight,
variable: options?.variable,
path,
};
}
+66
View File
@@ -88,6 +88,59 @@ function parseOS2Selection(dataView: DataView, os2TableOffset: number): number {
return dataView.getUint16(os2TableOffset + 62, false);
}
interface VariableFontAxis {
tag: string;
minValue: number;
defaultValue: number;
maxValue: number;
name?: string;
}
function parseVariableFontAxes(dataView: DataView, fvarTableOffset: number): VariableFontAxis[] {
try {
// fvar table structure:
// version (4 bytes) + axisCount (2 bytes) + axisSize (2 bytes) + instanceCount (2 bytes) + instanceSize (2 bytes)
const axisCount = dataView.getUint16(fvarTableOffset + 4, false);
const axisSize = dataView.getUint16(fvarTableOffset + 6, false);
const axes: VariableFontAxis[] = [];
// Each axis record starts at offset 16 from table start
let axisOffset = fvarTableOffset + 16;
for (let i = 0; i < axisCount; i++) {
// Axis record structure:
// axisTag (4 bytes) + minValue (4 bytes) + defaultValue (4 bytes) + maxValue (4 bytes) + flags (2 bytes) + axisNameID (2 bytes)
const tag = String.fromCharCode(
dataView.getUint8(axisOffset),
dataView.getUint8(axisOffset + 1),
dataView.getUint8(axisOffset + 2),
dataView.getUint8(axisOffset + 3),
);
// Fixed-point values (16.16 format)
const minValue = dataView.getInt32(axisOffset + 4, false) / 65536;
const defaultValue = dataView.getInt32(axisOffset + 8, false) / 65536;
const maxValue = dataView.getInt32(axisOffset + 12, false) / 65536;
axes.push({
tag,
minValue,
defaultValue,
maxValue,
});
axisOffset += axisSize;
}
return axes;
} catch (error) {
console.warn('Failed to parse fvar table:', error);
return [];
}
}
function weightClassToCSSWeight(weightClass: number): number {
// Map OpenType weight class to CSS weight
if (weightClass >= 1 && weightClass <= 100) return 100;
@@ -164,6 +217,7 @@ export const parseFontInfo = (fontData: ArrayBuffer, filename: string) => {
const numTables = dataView.getUint16(4, false);
let nameTableOffset = 0;
let os2TableOffset = 0;
let fvarTableOffset = 0;
for (let i = 0; i < numTables; i++) {
const tableOffset = 12 + i * 16;
const tag = String.fromCharCode(
@@ -177,6 +231,8 @@ export const parseFontInfo = (fontData: ArrayBuffer, filename: string) => {
nameTableOffset = dataView.getUint32(tableOffset + 8, false);
} else if (tag === 'OS/2') {
os2TableOffset = dataView.getUint32(tableOffset + 8, false);
} else if (tag === 'fvar') {
fvarTableOffset = dataView.getUint32(tableOffset + 8, false);
}
}
@@ -263,6 +319,14 @@ export const parseFontInfo = (fontData: ArrayBuffer, filename: string) => {
}
}
let isVariable = false;
if (fvarTableOffset > 0) {
const axes = parseVariableFontAxes(dataView, fvarTableOffset);
if (axes && axes.length > 0) {
isVariable = true;
}
}
// If OS/2 table weight is default (400) or unavailable, try to infer from style name
if (fontWeight === 400 && styleName) {
const inferredWeight = inferWeightFromStyleName(styleName);
@@ -281,6 +345,7 @@ export const parseFontInfo = (fontData: ArrayBuffer, filename: string) => {
family: familyName,
weight: fontWeight,
style: fontStyle,
variable: isVariable,
};
} catch (error) {
console.warn(`Failed to parse font: ${error}`);
@@ -289,6 +354,7 @@ export const parseFontInfo = (fontData: ArrayBuffer, filename: string) => {
family: fallbackName,
weight: 400,
style: 'normal' as FontStyle,
variable: false,
};
}
};