Skip to content

Commit 28fdfb2

Browse files
Brooooooklynclaude
andauthored
feat: add variable font support (#1148)
Co-authored-by: Claude <[email protected]>
1 parent 179fefd commit 28fdfb2

File tree

11 files changed

+643
-20
lines changed

11 files changed

+643
-20
lines changed

__test__/fonts/Oswald.ttf

168 KB
Binary file not shown.
3.3 KB
Loading

__test__/text.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,12 @@ test('text-align-with-space', async (t) => {
181181
ctx.fillText('A B C', 100, 200)
182182
await snapshotImage(t)
183183
})
184+
185+
test('font-variation-settings', async (t) => {
186+
const { ctx } = t.context
187+
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'Oswald.ttf'), 'Oswald')
188+
ctx.font = '50px Oswald'
189+
ctx.fontVariationSettings = "'wght' 700"
190+
ctx.fillText('Hello World', 50, 100)
191+
await snapshotImage(t)
192+
})

__test__/variable-fonts.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { join, dirname } from 'node:path'
2+
import { fileURLToPath } from 'node:url'
3+
4+
import test from 'ava'
5+
6+
import { GlobalFonts, Canvas } from '../index.js'
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url))
9+
10+
// This test demonstrates the new variable font support
11+
// It requires a variable font to be present in the test fonts directory
12+
13+
test('GlobalFonts.hasVariations should return false for non-variable fonts', (t) => {
14+
// Load a standard font
15+
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'iosevka-regular.ttf'), 'Iosevka')
16+
17+
// Check if it has variations (it shouldn't for a non-variable font)
18+
const hasVariations = GlobalFonts.hasVariations('Iosevka', 400, 5, 0)
19+
t.false(hasVariations)
20+
})
21+
22+
test('GlobalFonts.getVariationAxes should return empty array for non-variable fonts', (t) => {
23+
// Load a standard font
24+
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'iosevka-regular.ttf'), 'Iosevka')
25+
26+
// Get variation axes (should be empty for non-variable font)
27+
const axes = GlobalFonts.getVariationAxes('Iosevka', 400, 5, 0)
28+
t.is(axes.length, 0)
29+
})
30+
31+
test('GlobalFonts.hasVariations should return true for variable fonts', (t) => {
32+
const fontPath = join(__dirname, 'fonts', 'Oswald.ttf')
33+
GlobalFonts.registerFromPath(fontPath, 'Oswald')
34+
const hasVariations = GlobalFonts.hasVariations('Oswald', 400, 5, 0)
35+
t.true(hasVariations)
36+
})
37+
38+
test('GlobalFonts.getVariationAxes should return axes for variable fonts', (t) => {
39+
const fontPath = join(__dirname, 'fonts', 'Oswald.ttf')
40+
GlobalFonts.registerFromPath(fontPath, 'Oswald')
41+
const axes = GlobalFonts.getVariationAxes('Oswald', 400, 5, 0)
42+
t.true(axes.length > 0)
43+
44+
const weightAxis = axes.find(axis => axis.tag === 0x77676874) // 'wght'
45+
t.truthy(weightAxis)
46+
if (weightAxis) {
47+
t.is(weightAxis.min, 200)
48+
t.is(weightAxis.max, 700)
49+
t.is(weightAxis.def, 400)
50+
}
51+
})
52+
53+
test('FontVariationAxis interface has correct properties', (t) => {
54+
// Load a standard font and get its axes (even if empty)
55+
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'iosevka-regular.ttf'), 'Iosevka')
56+
const axes = GlobalFonts.getVariationAxes('Iosevka', 400, 5, 0)
57+
58+
// Verify the interface even with empty array
59+
t.true(Array.isArray(axes))
60+
61+
// If there were axes, they would have these properties:
62+
// - tag: number (OpenType tag as 32-bit integer)
63+
// - value: number (current value)
64+
// - min: number (minimum value)
65+
// - max: number (maximum value)
66+
// - def: number (default value)
67+
// - hidden: boolean (whether hidden from UI)
68+
69+
t.pass()
70+
})
71+
72+
test('CanvasRenderingContext2D.fontVariationSettings should persist', (t) => {
73+
const canvas = new Canvas(200, 200)
74+
const ctx = canvas.getContext('2d')
75+
76+
t.is(ctx.fontVariationSettings, 'normal')
77+
78+
ctx.fontVariationSettings = "'wght' 700, 'wdth' 50"
79+
t.is(ctx.fontVariationSettings, "'wght' 700, 'wdth' 50")
80+
81+
ctx.fontVariationSettings = "normal"
82+
t.is(ctx.fontVariationSettings, "normal")
83+
})

index.d.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@ export function clearAllCache(): void
55

66
interface CanvasRenderingContext2D
77
extends CanvasCompositing,
8-
CanvasDrawPath,
9-
CanvasFillStrokeStyles,
10-
CanvasFilters,
11-
CanvasImageData,
12-
CanvasImageSmoothing,
13-
CanvasPath,
14-
CanvasPathDrawingStyles,
15-
CanvasRect,
16-
CanvasSettings,
17-
CanvasShadowStyles,
18-
CanvasState,
19-
CanvasText,
20-
CanvasTextDrawingStyles,
21-
CanvasTransform,
22-
CanvasPDFAnnotations {}
8+
CanvasDrawPath,
9+
CanvasFillStrokeStyles,
10+
CanvasFilters,
11+
CanvasImageData,
12+
CanvasImageSmoothing,
13+
CanvasPath,
14+
CanvasPathDrawingStyles,
15+
CanvasRect,
16+
CanvasSettings,
17+
CanvasShadowStyles,
18+
CanvasState,
19+
CanvasText,
20+
CanvasTextDrawingStyles,
21+
CanvasTransform,
22+
CanvasPDFAnnotations { }
2323

2424
interface CanvasState {
2525
isContextLost(): boolean
@@ -211,6 +211,7 @@ interface CanvasTextDrawingStyles {
211211
textBaseline: CanvasTextBaseline
212212
textRendering: CanvasTextRendering
213213
wordSpacing: string
214+
fontVariationSettings: string
214215
}
215216

216217
interface CanvasFilters {
@@ -421,7 +422,7 @@ type OmitNeverOfMatrix = OmitMatrixMethod[keyof OmitMatrixMethod]
421422

422423
export const DOMMatrix: {
423424
prototype: DOMMatrix
424-
new (init?: string | number[]): DOMMatrix
425+
new(init?: string | number[]): DOMMatrix
425426
fromFloat32Array(array32: Float32Array): DOMMatrix
426427
fromFloat64Array(array64: Float64Array): DOMMatrix
427428
fromMatrix(other?: DOMMatrixInit): DOMMatrix
@@ -448,7 +449,7 @@ export interface DOMRect extends DOMRectReadOnly {
448449

449450
export const DOMRect: {
450451
prototype: DOMRect
451-
new (x?: number, y?: number, width?: number, height?: number): DOMRect
452+
new(x?: number, y?: number, width?: number, height?: number): DOMRect
452453
fromRect(other?: DOMRectInit): DOMRect
453454
}
454455

@@ -470,7 +471,7 @@ export interface DOMPoint extends DOMPointReadOnly {
470471

471472
export const DOMPoint: {
472473
prototype: DOMPoint
473-
new (x?: number, y?: number, z?: number, w?: number): DOMPoint
474+
new(x?: number, y?: number, z?: number, w?: number): DOMPoint
474475
fromPoint(other?: DOMPointInit): DOMPoint
475476
}
476477

@@ -708,6 +709,21 @@ export declare class FontKey {
708709
private readonly key: symbol
709710
}
710711

712+
export interface FontVariationAxis {
713+
/** OpenType tag as a 32-bit integer (e.g., 0x77676874 for 'wght') */
714+
tag: number
715+
/** Current value for this axis */
716+
value: number
717+
/** Minimum value for this axis */
718+
min: number
719+
/** Maximum value for this axis */
720+
max: number
721+
/** Default value for this axis */
722+
def: number
723+
/** Whether this axis should be hidden from UI */
724+
hidden: boolean
725+
}
726+
711727
interface IGlobalFonts {
712728
readonly families: { family: string; styles: { weight: number; width: string; style: string }[] }[]
713729
// return true if succeeded
@@ -717,6 +733,24 @@ interface IGlobalFonts {
717733
has(name: string): boolean
718734
loadFontsFromDir(path: string): number
719735
remove(key: FontKey): void
736+
/**
737+
* Get variation axes for a specific font instance
738+
* @param familyName The font family name
739+
* @param weight Font weight (100-900)
740+
* @param width Font width/stretch value
741+
* @param slant Font slant (0 = upright, 1 = italic, 2 = oblique)
742+
* @returns Array of variation axes or empty array if not a variable font
743+
*/
744+
getVariationAxes(familyName: string, weight: number, width: number, slant: number): FontVariationAxis[]
745+
/**
746+
* Check if a font has variable font capabilities
747+
* @param familyName The font family name
748+
* @param weight Font weight (100-900)
749+
* @param width Font width/stretch value
750+
* @param slant Font slant (0 = upright, 1 = italic, 2 = oblique)
751+
* @returns true if the font is a variable font
752+
*/
753+
hasVariations(familyName: string, weight: number, width: number, slant: number): boolean
720754
}
721755

722756
export const GlobalFonts: IGlobalFonts

skia-c/skia_c.cpp

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#include <assert.h>
22
#include <math.h>
3+
#include <algorithm>
4+
#include <vector>
35

46
#include "skia_c.hpp"
57
#define SURFACE_CAST reinterpret_cast<SkSurface*>(c_surface)
@@ -448,7 +450,9 @@ void skiac_canvas_get_line_metrics_or_draw_text(
448450
float world_spacing,
449451
skiac_paint* c_paint,
450452
skiac_canvas* c_canvas,
451-
skiac_line_metrics* c_line_metrics) {
453+
skiac_line_metrics* c_line_metrics,
454+
const skiac_font_variation* variations,
455+
int variations_count) {
452456
auto font_collection = c_collection->collection;
453457
auto font_style = SkFontStyle(weight, stretch, (SkFontStyle::Slant)slant);
454458
auto text_direction = (TextDirection)direction;
@@ -465,6 +469,21 @@ void skiac_canvas_get_line_metrics_or_draw_text(
465469
text_style.setLetterSpacing(letter_spacing);
466470
text_style.setHeight(1);
467471
text_style.setFontStyle(font_style);
472+
473+
std::vector<SkFontArguments::VariationPosition::Coordinate> coords;
474+
475+
// Apply variable font variations if provided
476+
if (variations && variations_count > 0) {
477+
coords.reserve(variations_count);
478+
for (int i = 0; i < variations_count; i++) {
479+
coords.push_back({variations[i].tag, variations[i].value});
480+
}
481+
SkFontArguments font_args;
482+
font_args.setVariationDesignPosition(
483+
{coords.data(), static_cast<int>(coords.size())});
484+
text_style.setFontArguments(std::make_optional(font_args));
485+
}
486+
468487
text_style.setForegroundColor(*PAINT_CAST);
469488
text_style.setTextBaseline(TextBaseline::kAlphabetic);
470489
StrutStyle struct_style;
@@ -1739,6 +1758,86 @@ void skiac_font_collection_destroy(skiac_font_collection* c_font_collection) {
17391758
delete c_font_collection;
17401759
}
17411760

1761+
// Variable Fonts
1762+
int skiac_typeface_get_variation_design_position(
1763+
skiac_font_collection* c_font_collection,
1764+
const char* family_name,
1765+
int weight,
1766+
int width,
1767+
int slant,
1768+
skiac_variable_font_axis* axes,
1769+
int max_axis_count) {
1770+
if (!c_font_collection || !family_name || !axes || max_axis_count <= 0) {
1771+
return 0;
1772+
}
1773+
1774+
auto font_style = SkFontStyle(weight, width, (SkFontStyle::Slant)slant);
1775+
auto typeface =
1776+
c_font_collection->assets->matchFamilyStyle(family_name, font_style);
1777+
if (!typeface) {
1778+
return 0;
1779+
}
1780+
1781+
// Use getVariationDesignParameters to get the full axis details (min, max,
1782+
// def)
1783+
int axis_count = typeface->getVariationDesignParameters({});
1784+
if (axis_count <= 0) {
1785+
return 0;
1786+
}
1787+
1788+
std::vector<SkFontParameters::Variation::Axis> params(axis_count);
1789+
typeface->getVariationDesignParameters({params.data(), params.size()});
1790+
1791+
// Also get current position to fill in the 'value' field
1792+
int pos_count = typeface->getVariationDesignPosition({});
1793+
std::vector<SkFontArguments::VariationPosition::Coordinate> coords(pos_count);
1794+
if (pos_count > 0) {
1795+
typeface->getVariationDesignPosition({coords.data(), coords.size()});
1796+
}
1797+
1798+
int count = std::min(axis_count, max_axis_count);
1799+
for (int i = 0; i < count; i++) {
1800+
axes[i].tag = params[i].tag;
1801+
axes[i].min = params[i].min;
1802+
axes[i].max = params[i].max;
1803+
axes[i].def = params[i].def;
1804+
axes[i].hidden = params[i].isHidden();
1805+
1806+
// Default to 'def' value
1807+
axes[i].value = params[i].def;
1808+
1809+
// If we have a current value for this axis, use it
1810+
for (int j = 0; j < pos_count; j++) {
1811+
if (coords[j].axis == params[i].tag) {
1812+
axes[i].value = coords[j].value;
1813+
break;
1814+
}
1815+
}
1816+
}
1817+
1818+
return count;
1819+
}
1820+
1821+
bool skiac_font_has_variations(skiac_font_collection* c_font_collection,
1822+
const char* family_name,
1823+
int weight,
1824+
int width,
1825+
int slant) {
1826+
if (!c_font_collection || !family_name) {
1827+
return false;
1828+
}
1829+
1830+
auto font_style = SkFontStyle(weight, width, (SkFontStyle::Slant)slant);
1831+
auto typeface =
1832+
c_font_collection->assets->matchFamilyStyle(family_name, font_style);
1833+
if (!typeface) {
1834+
return false;
1835+
}
1836+
1837+
int axis_count = typeface->getVariationDesignParameters({});
1838+
return axis_count > 0;
1839+
}
1840+
17421841
// SkWStream
17431842
void skiac_sk_w_stream_get(skiac_w_memory_stream* c_w_memory_stream,
17441843
skiac_sk_data* sk_data,

skia-c/skia_c.hpp

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,20 @@ struct skiac_pdf_metadata {
258258
// high
259259
};
260260

261+
struct skiac_variable_font_axis {
262+
uint32_t tag; // OpenType tag (e.g., 'wght', 'wdth', 'slnt', 'ital')
263+
float value; // Current value for this axis
264+
float min; // Minimum value for this axis
265+
float max; // Maximum value for this axis
266+
float def; // Default value for this axis
267+
bool hidden; // Whether this axis should be hidden
268+
};
269+
270+
struct skiac_font_variation {
271+
uint32_t tag; // OpenType tag
272+
float value; // Value for this axis
273+
};
274+
261275
extern "C" {
262276
void skiac_clear_all_cache();
263277
// Surface
@@ -380,7 +394,9 @@ void skiac_canvas_get_line_metrics_or_draw_text(
380394
float world_spacing,
381395
skiac_paint* c_paint,
382396
skiac_canvas* c_canvas,
383-
skiac_line_metrics* c_line_metrics);
397+
skiac_line_metrics* c_line_metrics,
398+
const skiac_font_variation* variations,
399+
int variations_count);
384400
void skiac_canvas_reset_transform(skiac_canvas* c_canvas);
385401
void skiac_canvas_clip_rect(skiac_canvas* c_canvas,
386402
float x,
@@ -703,6 +719,21 @@ void skiac_font_collection_set_alias(skiac_font_collection* c_font_collection,
703719
const char* alias);
704720
void skiac_font_collection_destroy(skiac_font_collection* c_font_collection);
705721

722+
// Variable Fonts
723+
int skiac_typeface_get_variation_design_position(
724+
skiac_font_collection* c_font_collection,
725+
const char* family_name,
726+
int weight,
727+
int width,
728+
int slant,
729+
skiac_variable_font_axis* axes,
730+
int max_axis_count);
731+
bool skiac_font_has_variations(skiac_font_collection* c_font_collection,
732+
const char* family_name,
733+
int weight,
734+
int width,
735+
int slant);
736+
706737
// SkDynamicMemoryWStream
707738
void skiac_sk_w_stream_get(skiac_w_memory_stream* c_w_memory_stream,
708739
skiac_sk_data* sk_data,

0 commit comments

Comments
 (0)