Skip to content

Commit

Permalink
feat: support ctx.letterSpacing and ctx.wordSpacing (#794)
Browse files Browse the repository at this point in the history
* feat: support ctx.letterSpacing and ctx.wordSpacing

* Clippy fix
  • Loading branch information
Brooooooklyn authored Feb 26, 2024
1 parent f4a077a commit 793d2a0
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 1 deletion.
Binary file added __test__/snapshots/letter-spacing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __test__/snapshots/word-spacing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions __test__/text.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,64 @@ test('text-baseline-all', async (t) => {
await snapshotImage(t)
})

test('letter-spacing', async (t) => {
const canvas = createCanvas(800, 800)
const ctx = canvas.getContext('2d')!
ctx.font = '30px Iosevka Slab'

// Default letter spacing
ctx.fillText(`Hello world (default: ${ctx.letterSpacing})`, 10, 40)

// Custom letter spacing: 10px
ctx.letterSpacing = '10px'
ctx.fillText(`Hello world (${ctx.letterSpacing})`, 10, 90)
ctx.save()
// Custom letter spacing: 20px
ctx.letterSpacing = '20px'
ctx.fillText(`Hello world (${ctx.letterSpacing})`, 10, 140)
ctx.restore()
ctx.fillText(`Hello world (${ctx.letterSpacing})`, 10, 190)

ctx.textAlign = 'center'
const { width } = ctx.measureText(`Hello world (${ctx.letterSpacing})`)
ctx.fillText(`Hello world (${ctx.letterSpacing})`, width / 2 + 10, 240)

ctx.textAlign = 'start'
ctx.fillText(`Hello world (${ctx.letterSpacing})`, 10, 290)
ctx.textAlign = 'right'
ctx.fillText(`Hello world (${ctx.letterSpacing})`, -width + 10, 340)
await snapshotImage(t, { canvas, ctx })
})

test('word-spacing', async (t) => {
const canvas = createCanvas(800, 800)
const ctx = canvas.getContext('2d')!
ctx.font = '30px Iosevka Slab'

// Default word spacing
ctx.fillText(`Hello world (default: ${ctx.wordSpacing})`, 10, 40)

// Custom word spacing: 10px
ctx.wordSpacing = '10px'
ctx.fillText(`Hello world (${ctx.wordSpacing})`, 10, 90)
ctx.save()
// Custom word spacing: 20px
ctx.wordSpacing = '20px'
ctx.fillText(`Hello world (${ctx.wordSpacing})`, 10, 140)
ctx.restore()
ctx.fillText(`Hello world (${ctx.wordSpacing})`, 10, 190)

ctx.textAlign = 'center'
const { width } = ctx.measureText(`Hello world (${ctx.wordSpacing})`)
ctx.fillText(`Hello world (${ctx.wordSpacing})`, width / 2 + 10, 240)

ctx.textAlign = 'start'
ctx.fillText(`Hello world (${ctx.wordSpacing})`, 10, 290)
ctx.textAlign = 'right'
ctx.fillText(`Hello world (${ctx.wordSpacing})`, -width + 10, 340)
await snapshotImage(t, { canvas, ctx })
})

test('text-align-with-space', async (t) => {
if (process.platform !== 'darwin') {
t.pass('Skip test, no fallback fonts on this platform in CI')
Expand Down
3 changes: 3 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ export interface SKRSContext2D
): CanvasPattern
getContextAttributes(): { alpha: boolean; desynchronized: boolean }
getTransform(): DOMMatrix

letterSpacing: string
wordSpacing: string
}

export type ColorSpace = 'srgb' | 'display-p3'
Expand Down
9 changes: 8 additions & 1 deletion skia-c/skia_c.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ extern "C"
int baseline,
int align,
int direction,
float letter_spacing,
float world_spacing,
skiac_paint *c_paint,
skiac_canvas *c_canvas,
skiac_line_metrics *c_line_metrics)
Expand All @@ -426,15 +428,19 @@ extern "C"
}
text_style.setFontFamilies(families_vec);
text_style.setFontSize(font_size);
text_style.setWordSpacing(0);
text_style.setWordSpacing(world_spacing);
text_style.setLetterSpacing(letter_spacing);
text_style.setHeight(1);
text_style.setFontStyle(font_style);
text_style.setForegroundColor(*PAINT_CAST);
text_style.setTextBaseline(TextBaseline::kAlphabetic);
StrutStyle struct_style;
struct_style.setLeading(0);

ParagraphStyle paragraph_style;
paragraph_style.setTextStyle(text_style);
paragraph_style.setTextDirection(text_direction);
paragraph_style.setStrutStyle(struct_style);
ParagraphBuilderImpl builder(paragraph_style, font_collection, SkUnicode::Make());
builder.addText(text, text_len);
auto paragraph = static_cast<ParagraphImpl *>(builder.Build().release());
Expand Down Expand Up @@ -561,6 +567,7 @@ extern "C"
CANVAS_CAST->scale(ratio, 1.0);
}
auto paint_y = y + baseline_offset;
paint_x = paint_x - letter_spacing / 2;
paragraph->paint(CANVAS_CAST, need_scale ? (paint_x + (1 - ratio) * offset_x) / ratio : paint_x, paint_y);
if (need_scale)
{
Expand Down
2 changes: 2 additions & 0 deletions skia-c/skia_c.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ extern "C"
int baseline,
int align,
int direction,
float letter_spacing,
float world_spacing,
skiac_paint *c_paint,
skiac_canvas *c_canvas,
skiac_line_metrics *c_line_metrics);
Expand Down
60 changes: 60 additions & 0 deletions src/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ use std::str::FromStr;
use cssparser::{Color as CSSColor, Parser, ParserInput, RGBA};
use libavif::AvifData;
use napi::{bindgen_prelude::*, JsBuffer, JsString, NapiRaw, NapiValue};
use once_cell::sync::Lazy;
use regex::Regex;

use crate::font::parse_size_px;
use crate::font::FONT_MEDIUM_PX;
use crate::global_fonts::get_font;
use crate::picture_recorder::PictureRecorder;
use crate::sk::Canvas;
Expand All @@ -30,6 +34,9 @@ use crate::{
CanvasElement, SVGCanvas,
};

static CSS_SIZE_REGEXP: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"([\d\.]+)(%|px|pt|pc|in|cm|mm|%|em|ex|ch|rem|q)?\s*"#).unwrap());

impl From<SkError> for Error {
fn from(err: SkError) -> Error {
Error::new(Status::InvalidArg, format!("{err}"))
Expand Down Expand Up @@ -780,6 +787,8 @@ impl Context {
state.text_baseline,
state.text_align,
state.text_direction,
state.letter_spacing,
state.word_spacing,
&shadow_paint,
)?;
canvas.restore();
Expand All @@ -799,6 +808,8 @@ impl Context {
state.text_baseline,
state.text_align,
state.text_direction,
state.letter_spacing,
state.word_spacing,
paint,
)?;
Ok(())
Expand All @@ -825,6 +836,8 @@ impl Context {
state.text_baseline,
state.text_align,
state.text_direction,
state.letter_spacing,
state.word_spacing,
&fill_paint,
)?);
Ok(line_metrics)
Expand Down Expand Up @@ -1099,6 +1112,34 @@ impl CanvasRenderingContext2D {
};
}

#[napi(getter)]
pub fn get_letter_spacing(&self) -> String {
self.context.state.letter_spacing_raw.clone()
}

#[napi(setter, return_if_invalid)]
pub fn set_letter_spacing(&mut self, spacing: String) -> Result<()> {
if let Some(size) = parse_css_size(&spacing) {
self.context.state.letter_spacing = size;
self.context.state.letter_spacing_raw = spacing;
}
Ok(())
}

#[napi(getter)]
pub fn get_word_spacing(&self) -> String {
self.context.state.word_spacing_raw.clone()
}

#[napi(setter, return_if_invalid)]
pub fn set_word_spacing(&mut self, spacing: String) -> Result<()> {
if let Some(size) = parse_css_size(&spacing) {
self.context.state.word_spacing = size;
self.context.state.word_spacing_raw = spacing;
}
Ok(())
}

#[napi(getter)]
pub fn get_stroke_style(&self, this: This) -> Option<Unknown> {
this.get(STROKE_STYLE_HIDDEN_NAME).ok().flatten()
Expand Down Expand Up @@ -2163,3 +2204,22 @@ impl Task for ContextData {
}
}
}

fn parse_css_size(css_size: &str) -> Option<f32> {
if css_size.ends_with('%') {
return css_size
.parse::<f32>()
.map(|v| v / 100.0 * FONT_MEDIUM_PX)
.ok();
} else if let Some(captures) = CSS_SIZE_REGEXP.captures(css_size) {
return captures.get(1).and_then(|size| {
captures.get(2).and_then(|unit| {
Some(parse_size_px(
size.as_str().parse::<f32>().ok()?,
unit.as_str(),
))
})
});
}
None
}
10 changes: 10 additions & 0 deletions src/sk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,8 @@ pub mod ffi {
baseline: i32,
align: i32,
direction: i32,
letter_spacing: f32,
word_spacing: f32,
paint: *mut skiac_paint,
canvas: *mut skiac_canvas,
line_metrics: *mut skiac_line_metrics,
Expand Down Expand Up @@ -2089,6 +2091,8 @@ impl Canvas {
baseline: TextBaseline,
align: TextAlign,
direction: TextDirection,
letter_spacing: f32,
word_spacing: f32,
paint: &Paint,
) -> Result<(), NulError> {
let c_text = std::ffi::CString::new(text)?;
Expand All @@ -2111,6 +2115,8 @@ impl Canvas {
baseline as i32,
align as i32,
direction.as_sk_direction(),
letter_spacing,
word_spacing,
paint.0,
self.0,
ptr::null_mut(),
Expand All @@ -2131,6 +2137,8 @@ impl Canvas {
baseline: TextBaseline,
align: TextAlign,
direction: TextDirection,
letter_spacing: f32,
word_spacing: f32,
paint: &Paint,
) -> Result<ffi::skiac_line_metrics, NulError> {
let c_text = std::ffi::CString::new(text)?;
Expand All @@ -2155,6 +2163,8 @@ impl Canvas {
baseline as i32,
align as i32,
direction.as_sk_direction(),
letter_spacing,
word_spacing,
paint.0,
ptr::null_mut(),
&mut line_metrics,
Expand Down
8 changes: 8 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub struct Context2dRenderingState {
pub text_align: TextAlign,
pub text_baseline: TextBaseline,
pub text_direction: TextDirection,
pub letter_spacing: f32,
pub letter_spacing_raw: String,
pub word_spacing: f32,
pub word_spacing_raw: String,
pub transform: Matrix,
pub filter: Option<ImageFilter>,
pub filters_string: String,
Expand Down Expand Up @@ -57,6 +61,10 @@ impl Default for Context2dRenderingState {
text_align: TextAlign::default(),
text_baseline: TextBaseline::default(),
text_direction: TextDirection::default(),
letter_spacing: 0.0,
letter_spacing_raw: "0px".to_owned(),
word_spacing: 0.0,
word_spacing_raw: "0px".to_owned(),
transform: Matrix::identity(),
filter: None,
filters_string: "none".to_owned(),
Expand Down

0 comments on commit 793d2a0

Please sign in to comment.