Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for letterspacing #1615

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ project adheres to [Semantic Versioning](http://semver.org/).
* Fix compile errors with cairo
* Fix Image#complete if the image failed to load.
* Upgrade node-pre-gyp to v0.15.0 to use latest version of needle to fix error when downloading prebuilds.
* The small-caps variant setting is now honored if included in a font string
* Letterspacing can now be controlled by setting the context's textTracking attribute

2.6.1
==================
Expand Down
9 changes: 9 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ This project is an implementation of the Web Canvas API and implements that API
* [Canvas#createJPEGStream()](#canvascreatejpegstream)
* [Canvas#createPDFStream()](#canvascreatepdfstream)
* [Canvas#toDataURL()](#canvastodataurl)
* [CanvasRenderingContext2D#textTracking](#canvasrenderingcontext2dtexttracking)
* [CanvasRenderingContext2D#patternQuality](#canvasrenderingcontext2dpatternquality)
* [CanvasRenderingContext2D#quality](#canvasrenderingcontext2dquality)
* [CanvasRenderingContext2D#textDrawingMode](#canvasrenderingcontext2dtextdrawingmode)
Expand Down Expand Up @@ -392,6 +393,14 @@ canvas.toDataURL('image/jpeg', {...opts}, (err, jpeg) => { }) // see Canvas#crea
canvas.toDataURL('image/jpeg', quality, (err, jpeg) => { }) // spec-following; quality from 0 to 1
```

### CanvasRenderingContext2D#textTracking
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this present in the spec for CanvasRenderingContext2D, or supported by any browsers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this would be an extension since HTML Canvas makes no provisions for setting letterspacing. See @zbjornson's summary of current browser behaviors here: #1014 (comment)


> ```ts
> context.textTracking: Number
> ```

Defaults to 0. Sets the additional amount of space between characters to be added or subtracted when drawing text to the canvas. The amount of space is measured in integer units of thousandths-of-an-em, meaning a value of 1000 will separate characters by 1 'em' (a.k.a., the current point-size of the font). Positive values will increase the letter-spacing and negative values will tighten it.

### CanvasRenderingContext2D#patternQuality

> ```ts
Expand Down
Binary file added examples/crimsonFont/Crimson-Bold.ttf
Binary file not shown.
Binary file added examples/crimsonFont/Crimson-BoldItalic.ttf
Binary file not shown.
Binary file added examples/crimsonFont/Crimson-Italic.ttf
Binary file not shown.
Binary file added examples/crimsonFont/Crimson-Roman.ttf
Binary file not shown.
Binary file added examples/crimsonFont/Crimson-Semibold.ttf
Binary file not shown.
Binary file added examples/crimsonFont/Crimson-SemiboldItalic.ttf
Binary file not shown.
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ module.exports = {
gifVersion: bindings.gifVersion ? bindings.gifVersion.replace(/[^.\d]/g, '') : undefined,
/** freetype version. */
freetypeVersion: bindings.freetypeVersion,
/** pango version. */
pangoVersion: bindings.pangoVersion,
/** rsvg version. */
rsvgVersion: bindings.rsvgVersion
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"express": "^4.16.3",
"mocha": "^5.2.0",
"pixelmatch": "^4.0.2",
"semver": "^7.3.2",
"standard": "^12.0.1"
},
"engines": {
Expand Down
50 changes: 50 additions & 0 deletions src/CanvasRenderingContext2d.cc
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ Context2d::Initialize(Nan::ADDON_REGISTER_FUNCTION_ARGS_TYPE target) {
SetProtoAccessor(proto, Nan::New("fillStyle").ToLocalChecked(), GetFillStyle, SetFillStyle, ctor);
SetProtoAccessor(proto, Nan::New("strokeStyle").ToLocalChecked(), GetStrokeStyle, SetStrokeStyle, ctor);
SetProtoAccessor(proto, Nan::New("font").ToLocalChecked(), GetFont, SetFont, ctor);
SetProtoAccessor(proto, Nan::New("textTracking").ToLocalChecked(), GetTextTracking, SetTextTracking, ctor);
SetProtoAccessor(proto, Nan::New("textBaseline").ToLocalChecked(), GetTextBaseline, SetTextBaseline, ctor);
SetProtoAccessor(proto, Nan::New("textAlign").ToLocalChecked(), GetTextAlign, SetTextAlign, ctor);
Local<Context> ctx = Nan::GetCurrentContext();
Expand Down Expand Up @@ -199,6 +200,7 @@ Context2d::Context2d(Canvas *canvas) {
Context2d::~Context2d() {
while(stateno >= 0) {
pango_font_description_free(states[stateno]->fontDescription);
pango_attr_list_unref(states[stateno]->textAttributes);
free(states[stateno--]);
}
g_object_unref(_layout);
Expand All @@ -213,6 +215,7 @@ Context2d::~Context2d() {
void Context2d::resetState(bool init) {
if (!init) {
pango_font_description_free(state->fontDescription);
pango_attr_list_unref(states[stateno]->textAttributes);
}

state->shadowBlur = 0;
Expand All @@ -224,6 +227,7 @@ void Context2d::resetState(bool init) {
state->fillGradient = nullptr;
state->strokeGradient = nullptr;
state->textBaseline = TEXT_BASELINE_ALPHABETIC;
state->textTracking = 0;
rgba_t transparent = { 0, 0, 0, 1 };
rgba_t transparent_black = { 0, 0, 0, 0 };
state->fill = transparent;
Expand All @@ -235,6 +239,8 @@ void Context2d::resetState(bool init) {
state->fontDescription = pango_font_description_from_string("sans serif");
pango_font_description_set_absolute_size(state->fontDescription, 10 * PANGO_SCALE);
pango_layout_set_font_description(_layout, state->fontDescription);
state->textAttributes = pango_attr_list_new();
pango_layout_set_attributes(_layout, state->textAttributes);

_resetPersistentHandles();
}
Expand All @@ -244,6 +250,7 @@ void Context2d::_resetPersistentHandles() {
_strokeStyle.Reset();
_font.Reset();
_textBaseline.Reset();
_textTracking.Reset();
_textAlign.Reset();
}

Expand All @@ -258,6 +265,8 @@ Context2d::save() {
states[++stateno] = (canvas_state_t *) malloc(sizeof(canvas_state_t));
memcpy(states[stateno], state, sizeof(canvas_state_t));
states[stateno]->fontDescription = pango_font_description_copy(states[stateno-1]->fontDescription);
states[stateno]->textAttributes = pango_attr_list_copy(states[stateno-1]->textAttributes);
pango_layout_set_attributes(_layout, states[stateno]->textAttributes);
state = states[stateno];
}
}
Expand All @@ -271,10 +280,12 @@ Context2d::restore() {
if (stateno > 0) {
cairo_restore(_context);
pango_font_description_free(states[stateno]->fontDescription);
pango_attr_list_unref(states[stateno]->textAttributes);
free(states[stateno]);
states[stateno] = NULL;
state = states[--stateno];
pango_layout_set_font_description(_layout, state->fontDescription);
pango_layout_set_attributes(_layout, state->textAttributes);
}
}

Expand Down Expand Up @@ -2499,6 +2510,7 @@ NAN_GETTER(Context2d::GetFont) {

/*
* Set font:
* - variant
* - weight
* - style
* - size
Expand All @@ -2523,6 +2535,7 @@ NAN_SETTER(Context2d::SetFont) {
if (mparsed->IsUndefined()) return;
Local<Object> font = Nan::To<Object>(mparsed).ToLocalChecked();

Nan::Utf8String variant(Nan::Get(font, Nan::New("variant").ToLocalChecked()).ToLocalChecked());
Nan::Utf8String weight(Nan::Get(font, Nan::New("weight").ToLocalChecked()).ToLocalChecked());
Nan::Utf8String style(Nan::Get(font, Nan::New("style").ToLocalChecked()).ToLocalChecked());
double size = Nan::To<double>(Nan::Get(font, Nan::New("size").ToLocalChecked()).ToLocalChecked()).FromMaybe(0);
Expand All @@ -2547,9 +2560,46 @@ NAN_SETTER(Context2d::SetFont) {
context->state->fontDescription = sys_desc;
pango_layout_set_font_description(context->_layout, sys_desc);

#if PANGO_VERSION >= PANGO_VERSION_ENCODE(1, 37, 1)
PangoAttribute *features;
if (strlen(*variant) > 0 && strcmp("small-caps", *variant) == 0) {
features = pango_attr_font_features_new("smcp 1, onum 1");
} else {
features = pango_attr_font_features_new("");
}
pango_attr_list_change(context->state->textAttributes, features);
#endif

int oneEm = pango_font_description_get_size(context->state->fontDescription);
int perEm = context->state->textTracking;
PangoAttribute *tracking = pango_attr_letter_spacing_new(oneEm * perEm / 1000.0);
pango_attr_list_change(context->state->textAttributes, tracking);

context->_font.Reset(value);
}

/*
* Get text tracking.
*/

NAN_GETTER(Context2d::GetTextTracking) {
Context2d *context = Nan::ObjectWrap::Unwrap<Context2d>(info.This());
info.GetReturnValue().Set(Nan::New<Number>(context->state->textTracking));
}

/*
* Set text tracking.
*/

NAN_SETTER(Context2d::SetTextTracking) {
Context2d *context = Nan::ObjectWrap::Unwrap<Context2d>(info.This());
int oneEm = pango_font_description_get_size(context->state->fontDescription);
int perEm = Nan::To<int>(value).FromMaybe(0);
PangoAttribute *tracking = pango_attr_letter_spacing_new(oneEm * perEm / 1000.0);
pango_attr_list_change(context->state->textAttributes, tracking);
context->state->textTracking = perEm;
}

/*
* Get text baseline.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/CanvasRenderingContext2d.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ typedef struct {
float globalAlpha;
short textAlignment;
short textBaseline;
int textTracking;
rgba_t shadow;
int shadowBlur;
double shadowOffsetX;
double shadowOffsetY;
canvas_draw_mode_t textDrawingMode;
PangoFontDescription *fontDescription;
PangoAttrList *textAttributes;
bool imageSmoothingEnabled;
} canvas_state_t;

Expand Down Expand Up @@ -133,6 +135,7 @@ class Context2d: public Nan::ObjectWrap {
static NAN_GETTER(GetFillStyle);
static NAN_GETTER(GetStrokeStyle);
static NAN_GETTER(GetFont);
static NAN_GETTER(GetTextTracking);
static NAN_GETTER(GetTextBaseline);
static NAN_GETTER(GetTextAlign);
static NAN_SETTER(SetPatternQuality);
Expand All @@ -155,6 +158,7 @@ class Context2d: public Nan::ObjectWrap {
static NAN_SETTER(SetFillStyle);
static NAN_SETTER(SetStrokeStyle);
static NAN_SETTER(SetFont);
static NAN_SETTER(SetTextTracking);
static NAN_SETTER(SetTextBaseline);
static NAN_SETTER(SetTextAlign);
inline void setContext(cairo_t *ctx) { _context = ctx; }
Expand Down Expand Up @@ -193,6 +197,7 @@ class Context2d: public Nan::ObjectWrap {
Nan::Persistent<v8::Value> _fillStyle;
Nan::Persistent<v8::Value> _strokeStyle;
Nan::Persistent<v8::Value> _font;
Nan::Persistent<v8::Value> _textTracking;
Nan::Persistent<v8::Value> _textBaseline;
Nan::Persistent<v8::Value> _textAlign;
Canvas *_canvas;
Expand Down
4 changes: 4 additions & 0 deletions src/init.cc
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ NAN_MODULE_INIT(init) {
char freetype_version[10];
snprintf(freetype_version, 10, "%d.%d.%d", FREETYPE_MAJOR, FREETYPE_MINOR, FREETYPE_PATCH);
Nan::Set(target, Nan::New<String>("freetypeVersion").ToLocalChecked(), Nan::New<String>(freetype_version).ToLocalChecked()).Check();

char pango_version[10];
snprintf(pango_version, 10, "%d.%d.%d", PANGO_VERSION_MAJOR, PANGO_VERSION_MINOR, PANGO_VERSION_MICRO);
Nan::Set(target, Nan::New<String>("pangoVersion").ToLocalChecked(), Nan::New<String>(pango_version).ToLocalChecked()).Check();
}

NODE_MODULE(canvas, init);
59 changes: 59 additions & 0 deletions test/canvas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ const createImageData = require('../').createImageData
const loadImage = require('../').loadImage
const parseFont = require('../').parseFont
const registerFont = require('../').registerFont
const pangoVersion = require('../').pangoVersion

const assert = require('assert')
const os = require('os')
const Readable = require('stream').Readable
const semver = require('semver')

describe('Canvas', function () {
// Run with --expose-gc and uncomment this line to help find memory problems:
Expand Down Expand Up @@ -446,6 +448,38 @@ describe('Canvas', function () {
assert.equal(ctx.font, '15px Arial, sans-serif')
});

it('Context2d#font=small-caps', function () {
if (!semver.satisfies(pangoVersion, '>=1.37.1')){
this.skip();
}

registerFont('./examples/crimsonFont/Crimson-Bold.ttf', {family: 'Crimson'})
let canvas = createCanvas(200, 200),
ctx = canvas.getContext('2d');

// here is where the dot will appear above a lower-case 'i' (and be missing for small-caps)
let offsetX = 15, offsetY = -115;

ctx.font = "180px Crimson";
ctx.fillText('i', 20, 180);
let lcDottedI = ctx.getImageData(20 + offsetX, 180 + offsetY, 20, 20).data;
assert.equal(lcDottedI.some(p => p > 0), true);

ctx.save()

ctx.font = "small-caps 180px Crimson";
ctx.fillText('i', 80, 180);
let scEmptySpace = ctx.getImageData(80 + offsetX, 180 + offsetY, 20, 20).data;
assert.equal(scEmptySpace.every(p => p == 0), true);

ctx.restore()

ctx.fillText('i', 140, 180);
let lcDottedAgain = ctx.getImageData(140 + offsetX, 180 + offsetY, 20, 20).data;
assert.equal(lcDottedAgain.some(p => p > 0), true);
});


it('Context2d#lineWidth=', function () {
var canvas = createCanvas(200, 200)
, ctx = canvas.getContext('2d');
Expand Down Expand Up @@ -540,6 +574,31 @@ describe('Canvas', function () {
assert.equal('end', ctx.textAlign);
});

it('Context2d#textTracking', function () {
var canvas = createCanvas(200,200)
, ctx = canvas.getContext('2d')
, measureWidth = () => ctx.measureText('MMMMMMMMMMMMM').width;

let normalWidth = measureWidth();
assert.equal(0, ctx.textTracking);

ctx.save();

ctx.textTracking = -500;
assert.equal(-500, ctx.textTracking);
assert.ok(measureWidth() < normalWidth / 2 );

ctx.textTracking = 1000;
assert.equal(1000, ctx.textTracking);
assert.ok(measureWidth() > normalWidth * 2 );

ctx.restore();

assert.equal(0, ctx.textTracking);
assert.equal(measureWidth(), normalWidth );
});


describe('#toBuffer', function () {
it('Canvas#toBuffer()', function () {
var buf = createCanvas(200,200).toBuffer();
Expand Down