Skip to content

Commit 8da5eea

Browse files
Brooooooklynclaude
andauthored
feat: add fontStretch and fontKerning support (#1160)
Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 0cf2fcc commit 8da5eea

File tree

10 files changed

+217
-9
lines changed

10 files changed

+217
-9
lines changed
34.2 KB
Binary file not shown.
17.9 KB
Loading
31.7 KB
Loading

__test__/text.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,83 @@ test('font-variation-settings-with-font-family', async (t) => {
205205
}
206206
await snapshotImage(t, { canvas, ctx })
207207
})
208+
209+
test('font-stretch', async (t) => {
210+
// Inconsolata is a variable font that supports width from 50% to 200%
211+
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'Inconsolata-VariableFont_wdth,wght.woff2'), 'Inconsolata')
212+
const canvas = createCanvas(800, 600)
213+
const ctx = canvas.getContext('2d')!
214+
ctx.font = '30px Inconsolata'
215+
216+
const stretches = [
217+
'ultra-condensed',
218+
'extra-condensed',
219+
'condensed',
220+
'semi-condensed',
221+
'normal',
222+
'semi-expanded',
223+
'expanded',
224+
'extra-expanded',
225+
'ultra-expanded',
226+
] as const
227+
228+
stretches.forEach((stretch, index) => {
229+
ctx.fontStretch = stretch
230+
ctx.fillText(`Hello World (${ctx.fontStretch})`, 10, 40 + index * 60)
231+
})
232+
233+
await snapshotImage(t, { canvas, ctx })
234+
})
235+
236+
test('font-kerning', async (t) => {
237+
// Use a serif font that has kerning information
238+
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'SourceSerifPro-Regular.ttf'), 'Source Serif Pro')
239+
const canvas = createCanvas(600, 300)
240+
const ctx = canvas.getContext('2d')!
241+
ctx.font = '48px Source Serif Pro'
242+
243+
// Test text with common kerning pairs: AV, Ta, We
244+
const testText = 'AVA Ta We'
245+
246+
// Default (auto)
247+
ctx.fillText(`${testText} (auto)`, 10, 60)
248+
t.is(ctx.fontKerning, 'auto')
249+
250+
// Kerning normal
251+
ctx.fontKerning = 'normal'
252+
ctx.fillText(`${testText} (normal)`, 10, 140)
253+
t.is(ctx.fontKerning, 'normal')
254+
255+
// Kerning none - characters should be evenly spread
256+
ctx.fontKerning = 'none'
257+
ctx.fillText(`${testText} (none)`, 10, 220)
258+
t.is(ctx.fontKerning, 'none')
259+
260+
await snapshotImage(t, { canvas, ctx })
261+
})
262+
263+
test('font-stretch-default-value', (t) => {
264+
const { ctx } = t.context
265+
t.is(ctx.fontStretch, 'normal')
266+
})
267+
268+
test('font-kerning-default-value', (t) => {
269+
const { ctx } = t.context
270+
t.is(ctx.fontKerning, 'auto')
271+
})
272+
273+
test('font-stretch-invalid-value-ignored', (t) => {
274+
const { ctx } = t.context
275+
ctx.fontStretch = 'condensed'
276+
t.is(ctx.fontStretch, 'condensed')
277+
ctx.fontStretch = 'invalid-stretch' as any
278+
t.is(ctx.fontStretch, 'condensed') // Should remain unchanged
279+
})
280+
281+
test('font-kerning-invalid-value-ignored', (t) => {
282+
const { ctx } = t.context
283+
ctx.fontKerning = 'none'
284+
t.is(ctx.fontKerning, 'none')
285+
ctx.fontKerning = 'invalid-kerning' as any
286+
t.is(ctx.fontKerning, 'none') // Should remain unchanged
287+
})

skia-c/skia_c.cpp

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ void skiac_canvas_get_line_metrics_or_draw_text(
465465
float font_size,
466466
int weight,
467467
int stretch,
468+
float stretch_width,
468469
int slant,
469470
const char* font_family,
470471
int baseline,
@@ -476,7 +477,8 @@ void skiac_canvas_get_line_metrics_or_draw_text(
476477
skiac_canvas* c_canvas,
477478
skiac_line_metrics* c_line_metrics,
478479
const skiac_font_variation* variations,
479-
int variations_count) {
480+
int variations_count,
481+
int kerning) {
480482
auto font_collection = c_collection->collection;
481483
auto font_style = SkFontStyle(weight, stretch, (SkFontStyle::Slant)slant);
482484
auto text_direction = (TextDirection)direction;
@@ -498,16 +500,33 @@ void skiac_canvas_get_line_metrics_or_draw_text(
498500

499501
// Apply variable font variations if provided
500502
if (variations && variations_count > 0) {
501-
coords.reserve(variations_count);
503+
coords.reserve(variations_count + 1);
502504
for (int i = 0; i < variations_count; i++) {
503505
coords.push_back({variations[i].tag, variations[i].value});
504506
}
507+
}
508+
509+
// Apply font stretch as 'wdth' variation for variable fonts
510+
// 'wdth' tag = 0x77647468
511+
if (stretch_width != 100.0f) {
512+
coords.push_back({SkSetFourByteTag('w', 'd', 't', 'h'), stretch_width});
513+
}
514+
515+
if (!coords.empty()) {
505516
SkFontArguments font_args;
506517
font_args.setVariationDesignPosition(
507518
{coords.data(), static_cast<int>(coords.size())});
508519
text_style.setFontArguments(std::make_optional(font_args));
509520
}
510521

522+
// Apply font kerning feature
523+
// kerning: 0=auto (don't set feature), 1=none (disable), 2=normal (enable)
524+
if (kerning == 1) {
525+
text_style.addFontFeature(SkString("kern"), 0);
526+
} else if (kerning == 2) {
527+
text_style.addFontFeature(SkString("kern"), 1);
528+
}
529+
511530
text_style.setForegroundColor(*PAINT_CAST);
512531
text_style.setTextBaseline(TextBaseline::kAlphabetic);
513532
StrutStyle struct_style;

skia-c/skia_c.hpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ void skiac_canvas_get_line_metrics_or_draw_text(
386386
float font_size,
387387
int weight,
388388
int stretch,
389+
float stretch_width,
389390
int slant,
390391
const char* font_family,
391392
int baseline,
@@ -397,7 +398,8 @@ void skiac_canvas_get_line_metrics_or_draw_text(
397398
skiac_canvas* c_canvas,
398399
skiac_line_metrics* c_line_metrics,
399400
const skiac_font_variation* variations,
400-
int variations_count);
401+
int variations_count,
402+
int kerning);
401403
void skiac_canvas_reset_transform(skiac_canvas* c_canvas);
402404
void skiac_canvas_clip_rect(skiac_canvas* c_canvas,
403405
float x,

src/ctx.rs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,21 @@ impl Context {
686686
Ok(())
687687
}
688688

689+
pub fn set_font_stretch(&mut self, stretch: String) -> result::Result<(), SkError> {
690+
if let Some(s) = crate::font::parse_font_stretch(&stretch) {
691+
self.state.font_stretch = s;
692+
self.state.font_stretch_raw = stretch;
693+
}
694+
Ok(())
695+
}
696+
697+
pub fn set_font_kerning(&mut self, kerning: String) -> result::Result<(), SkError> {
698+
if let Ok(k) = kerning.parse() {
699+
self.state.font_kerning = k;
700+
}
701+
Ok(())
702+
}
703+
689704
pub fn get_image_data(
690705
&mut self,
691706
x: f32,
@@ -929,7 +944,8 @@ impl Context {
929944
max_width,
930945
width as f32,
931946
state.font_style.weight,
932-
state.font_style.stretch as i32,
947+
state.font_stretch as i32,
948+
state.font_stretch.to_width_percentage(),
933949
state.font_style.style,
934950
&font,
935951
state.font_style.size,
@@ -941,6 +957,7 @@ impl Context {
941957
state.word_spacing,
942958
shadow_paint,
943959
variations,
960+
state.font_kerning,
944961
)?;
945962
shadow_canvas.restore();
946963
Ok(())
@@ -954,7 +971,8 @@ impl Context {
954971
max_width,
955972
width as f32,
956973
state.font_style.weight,
957-
state.font_style.stretch as i32,
974+
state.font_stretch as i32,
975+
state.font_stretch.to_width_percentage(),
958976
state.font_style.style,
959977
&font,
960978
state.font_style.size,
@@ -966,6 +984,7 @@ impl Context {
966984
state.word_spacing,
967985
paint,
968986
variations,
987+
state.font_kerning,
969988
)?;
970989
Ok(())
971990
},
@@ -977,7 +996,7 @@ impl Context {
977996
let state = &self.state;
978997
let fill_paint = self.fill_paint()?;
979998
let weight = state.font_style.weight;
980-
let stretch = state.font_style.stretch;
999+
let stretch = state.font_stretch;
9811000
let slant = state.font_style.style;
9821001
let font = get_font()?;
9831002
let line_metrics = LineMetrics(self.surface.canvas.get_line_metrics(
@@ -986,6 +1005,7 @@ impl Context {
9861005
state.font_style.size,
9871006
weight,
9881007
stretch as i32,
1008+
stretch.to_width_percentage(),
9891009
slant,
9901010
&state.font_style.family,
9911011
state.text_baseline,
@@ -995,6 +1015,7 @@ impl Context {
9951015
state.word_spacing,
9961016
&fill_paint,
9971017
&self.state.font_variations,
1018+
self.state.font_kerning,
9981019
)?);
9991020
Ok(line_metrics)
10001021
}
@@ -1487,6 +1508,28 @@ impl CanvasRenderingContext2D {
14871508
Ok(())
14881509
}
14891510

1511+
#[napi(getter)]
1512+
pub fn get_font_stretch(&self) -> String {
1513+
self.context.state.font_stretch_raw.clone()
1514+
}
1515+
1516+
#[napi(setter, return_if_invalid)]
1517+
pub fn set_font_stretch(&mut self, stretch: String) -> Result<()> {
1518+
self.context.set_font_stretch(stretch)?;
1519+
Ok(())
1520+
}
1521+
1522+
#[napi(getter)]
1523+
pub fn get_font_kerning(&self) -> String {
1524+
self.context.state.font_kerning.as_str().to_owned()
1525+
}
1526+
1527+
#[napi(setter, return_if_invalid)]
1528+
pub fn set_font_kerning(&mut self, kerning: String) -> Result<()> {
1529+
self.context.set_font_kerning(kerning)?;
1530+
Ok(())
1531+
}
1532+
14901533
#[napi]
14911534
pub fn arc(
14921535
&mut self,

src/font.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,22 @@ impl FontStretch {
229229
FontStretch::UltraExpanded => "ultra-expanded",
230230
}
231231
}
232+
233+
/// Returns the width percentage for variable font 'wdth' axis
234+
/// Based on CSS font-stretch percentages
235+
pub fn to_width_percentage(self) -> f32 {
236+
match self {
237+
FontStretch::UltraCondensed => 50.0,
238+
FontStretch::ExtraCondensed => 62.5,
239+
FontStretch::Condensed => 75.0,
240+
FontStretch::SemiCondensed => 87.5,
241+
FontStretch::Normal => 100.0,
242+
FontStretch::SemiExpanded => 112.5,
243+
FontStretch::Expanded => 125.0,
244+
FontStretch::ExtraExpanded => 150.0,
245+
FontStretch::UltraExpanded => 200.0,
246+
}
247+
}
232248
}
233249

234250
// https://drafts.csswg.org/css-fonts-4/#propdef-font-weight
@@ -251,7 +267,7 @@ fn parse_font_weight(weight: &str) -> Option<u32> {
251267
}
252268
}
253269

254-
fn parse_font_stretch(stretch: &str) -> Option<FontStretch> {
270+
pub fn parse_font_stretch(stretch: &str) -> Option<FontStretch> {
255271
match stretch {
256272
"ultra-condensed" | "50%" => Some(FontStretch::UltraCondensed),
257273
"extra-condensed" | "62.5%" => Some(FontStretch::ExtraCondensed),

0 commit comments

Comments
 (0)