From ec4871f3963f47a821e5321886d045d8d3d72c25 Mon Sep 17 00:00:00 2001 From: Zach Bjornson Date: Fri, 1 Sep 2017 21:05:13 -0700 Subject: [PATCH] Fix text baselines and measureText * Fixes text baseline adjustment (fillText, strokeText, measureText) when rotation transform is active. * Fixes measureText results. Some where negative when they should have been positive and weren't adjusting for the baseline properly. Fixes #983 --- src/CanvasRenderingContext2d.cc | 67 +++++++++++------------- test/canvas.test.js | 36 ++++++++++--- test/public/tests.js | 92 +++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 43 deletions(-) diff --git a/src/CanvasRenderingContext2d.cc b/src/CanvasRenderingContext2d.cc index 9fbefb954..c5b6fa5d1 100644 --- a/src/CanvasRenderingContext2d.cc +++ b/src/CanvasRenderingContext2d.cc @@ -1927,6 +1927,25 @@ NAN_METHOD(Context2d::StrokeText) { cairo_scale(context->context(), 1 / scaled_by, 1); } +/* + * Gets the baseline adjustment in device pixels, taking into account the + * transformation matrix. TODO This does not handle skew (which cannot easily + * be extracted from the matrix separately from rotation). + */ +inline double getBaselineAdjustment(PangoFontMetrics* metrics, cairo_matrix_t matrix, short baseline) { + double yScale = sqrt(matrix.yx * matrix.yx + matrix.yy * matrix.yy); + switch (baseline) { + case TEXT_BASELINE_ALPHABETIC: + return (pango_font_metrics_get_ascent(metrics) / PANGO_SCALE) * yScale; + case TEXT_BASELINE_MIDDLE: + return ((pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics)) / (2.0 * PANGO_SCALE)) * yScale; + case TEXT_BASELINE_BOTTOM: + return ((pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics)) / PANGO_SCALE) * yScale; + default: + return 0; + } +} + /* * Set text path for the given string at (x, y). */ @@ -1934,7 +1953,6 @@ NAN_METHOD(Context2d::StrokeText) { void Context2d::setTextPath(const char *str, double x, double y) { PangoRectangle ink_rect, logical_rect; - PangoFontMetrics *metrics = NULL; cairo_matrix_t matrix; pango_layout_set_text(_layout, str, -1); @@ -1955,22 +1973,9 @@ Context2d::setTextPath(const char *str, double x, double y) { break; } - switch (state->textBaseline) { - case TEXT_BASELINE_ALPHABETIC: - metrics = PANGO_LAYOUT_GET_METRICS(_layout); - y -= (pango_font_metrics_get_ascent(metrics) / PANGO_SCALE) * matrix.yy; - break; - case TEXT_BASELINE_MIDDLE: - metrics = PANGO_LAYOUT_GET_METRICS(_layout); - y -= ((pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics))/(2.0 * PANGO_SCALE)) * matrix.yy; - break; - case TEXT_BASELINE_BOTTOM: - metrics = PANGO_LAYOUT_GET_METRICS(_layout); - y -= ((pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics)) / PANGO_SCALE) * matrix.yy; - break; - } - - if (metrics) pango_font_metrics_unref(metrics); + PangoFontMetrics *metrics = PANGO_LAYOUT_GET_METRICS(_layout); + y -= getBaselineAdjustment(metrics, matrix, state->textBaseline); + pango_font_metrics_unref(metrics); cairo_move_to(_context, x, y); if (state->textDrawingMode == TEXT_DRAW_PATHS) { @@ -2091,20 +2096,9 @@ NAN_METHOD(Context2d::MeasureText) { x_offset = 0.0; } - double y_offset; - switch (context->state->textBaseline) { - case TEXT_BASELINE_ALPHABETIC: - y_offset = -pango_font_metrics_get_ascent(metrics) / PANGO_SCALE; - break; - case TEXT_BASELINE_MIDDLE: - y_offset = -(pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics))/(2.0 * PANGO_SCALE); - break; - case TEXT_BASELINE_BOTTOM: - y_offset = -(pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics)) / PANGO_SCALE; - break; - default: - y_offset = 0.0; - } + cairo_matrix_t matrix; + cairo_get_matrix(ctx, &matrix); + double y_offset = getBaselineAdjustment(metrics, matrix, context->state->textBaseline); obj->Set(Nan::New("width").ToLocalChecked(), Nan::New(logical_rect.width)); @@ -2113,16 +2107,15 @@ NAN_METHOD(Context2d::MeasureText) { obj->Set(Nan::New("actualBoundingBoxRight").ToLocalChecked(), Nan::New(x_offset + PANGO_RBEARING(logical_rect))); obj->Set(Nan::New("actualBoundingBoxAscent").ToLocalChecked(), - Nan::New(-(y_offset+ink_rect.y))); + Nan::New(y_offset + PANGO_ASCENT(ink_rect))); obj->Set(Nan::New("actualBoundingBoxDescent").ToLocalChecked(), - Nan::New((PANGO_DESCENT(ink_rect) + y_offset))); + Nan::New(PANGO_DESCENT(ink_rect) - y_offset)); obj->Set(Nan::New("emHeightAscent").ToLocalChecked(), - Nan::New(PANGO_ASCENT(logical_rect) - y_offset)); + Nan::New(-(PANGO_ASCENT(logical_rect) - y_offset))); obj->Set(Nan::New("emHeightDescent").ToLocalChecked(), - Nan::New(PANGO_DESCENT(logical_rect) + y_offset)); + Nan::New(PANGO_DESCENT(logical_rect) - y_offset)); obj->Set(Nan::New("alphabeticBaseline").ToLocalChecked(), - Nan::New((pango_font_metrics_get_ascent(metrics) / PANGO_SCALE) - + y_offset)); + Nan::New(-(pango_font_metrics_get_ascent(metrics) / PANGO_SCALE - y_offset))); pango_font_metrics_unref(metrics); diff --git a/test/canvas.test.js b/test/canvas.test.js index daf4f2fa0..6bc81004c 100644 --- a/test/canvas.test.js +++ b/test/canvas.test.js @@ -746,13 +746,37 @@ describe('Canvas', function () { }); }); - it('Context2d#measureText().width', function () { - var canvas = createCanvas(20, 20) - , ctx = canvas.getContext('2d'); + describe('Context2d#measureText()', function () { + it('Context2d#measureText().width', function () { + var canvas = createCanvas(20, 20) + , ctx = canvas.getContext('2d'); - assert.ok(ctx.measureText('foo').width); - assert.ok(ctx.measureText('foo').width != ctx.measureText('foobar').width); - assert.ok(ctx.measureText('foo').width != ctx.measureText(' foo').width); + assert.ok(ctx.measureText('foo').width); + assert.ok(ctx.measureText('foo').width != ctx.measureText('foobar').width); + assert.ok(ctx.measureText('foo').width != ctx.measureText(' foo').width); + }); + + it('works', function () { + var canvas = createCanvas(20, 20) + var ctx = canvas.getContext('2d') + ctx.font = "20px Arial" + + ctx.textBaseline = "alphabetic" + var metrics = ctx.measureText("Alphabet") + // Zero if the given baseline is the alphabetic baseline + assert.equal(metrics.alphabeticBaseline, 0) + // Positive = going up from the baseline + assert.ok(metrics.actualBoundingBoxAscent > 0) + // Positive = going down from the baseline + assert.ok(metrics.actualBoundingBoxDescent > 0) // ~4-5 + + ctx.textBaseline = "bottom" + metrics = ctx.measureText("Alphabet") + assert.ok(metrics.alphabeticBaseline > 0) // ~4-5 + assert.ok(metrics.actualBoundingBoxAscent > 0) + // On the baseline or slightly above + assert.ok(metrics.actualBoundingBoxDescent <= 0) + }); }); it('Context2d#createImageData(ImageData)', function () { diff --git a/test/public/tests.js b/test/public/tests.js index e5f107dad..eebf78d88 100644 --- a/test/public/tests.js +++ b/test/public/tests.js @@ -2163,3 +2163,95 @@ tests['textBaseline and scale'] = function (ctx) { ctx.textAlign = 'center' ctx.fillText('bottom', 1000, 1500) } + +tests['rotated baseline'] = function (ctx) { + ctx.font = '12px Arial' + ctx.fillStyle = 'black' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.translate(100, 100) + + for (var i = 0; i < 16; i++) { + ctx.fillText('Hello world!', -50, -50) + ctx.rotate(-Math.PI / 8) + } +} + +tests['rotated and scaled baseline'] = function (ctx) { + ctx.font = '120px Arial' + ctx.fillStyle = 'black' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.translate(100, 100) + ctx.scale(0.1, 0.2) + + for (var i = 0; i < 16; i++) { + ctx.fillText('Hello world!', -50 / 0.1, -50 / 0.2) + ctx.rotate(-Math.PI / 8) + } +} + +tests['rotated and skewed baseline'] = function (ctx) { + ctx.font = '12px Arial' + ctx.fillStyle = 'black' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.translate(100, 100) + ctx.transform(1, 1, 0, 1, 1, 1) + + for (var i = 0; i < 16; i++) { + ctx.fillText('Hello world!', -50, -50) + ctx.rotate(-Math.PI / 8) + } +} + +tests['rotated, scaled and skewed baseline'] = function (ctx) { + // Known issue: we don't have a way to decompose the cairo matrix into the + // skew and rotation separately. + ctx.font = '120px Arial' + ctx.fillStyle = 'black' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.translate(100, 100) + ctx.scale(0.1, 0.2) + ctx.transform(1, 1, 0, 1, 1, 1) + + for (var i = 0; i < 16; i++) { + ctx.fillText('Hello world!', -50 / 0.1, -50 / 0.2) + ctx.rotate(-Math.PI / 8) + } +} + +tests['measureText()'] = function (ctx) { + // Note: As of Sep 2017, Chrome is the only browser with advanced TextMetrics, + // and they're behind a flag, and a few of them are missing and others are + // wrong. + function drawWithBBox (text, x, y) { + ctx.fillText(text, x, y) + ctx.strokeStyle = 'red' + ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(200, y + 0.5); ctx.stroke() + var metrics = ctx.measureText(text) + ctx.strokeStyle = 'blue' + ctx.strokeRect( + x - metrics.actualBoundingBoxLeft + 0.5, + y - metrics.actualBoundingBoxAscent + 0.5, + metrics.width, + metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + ) + } + + ctx.font = '20px Arial' + ctx.textBaseline = 'alphabetic' + drawWithBBox('Alphabet alphabetic', 20, 50) + + drawWithBBox('weruoasnm', 50, 175) // no ascenders/descenders + + drawWithBBox(',', 100, 125) // tiny height + + ctx.textBaseline = 'bottom' + drawWithBBox('Alphabet bottom', 20, 90) + + ctx.textBaseline = 'alphabetic' + ctx.rotate(Math.PI / 8) + drawWithBBox('Alphabet', 50, 100) +}