Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified __test__/snapshots/direction-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.
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/direction-save-restore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
189 changes: 167 additions & 22 deletions __test__/text.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ test('direction-all-values', (t) => {

// MDN example: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/direction
test('direction-letter-spacing', async (t) => {
const canvas = createCanvas(500, 150)
const canvas = createCanvas(600, 360)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic')
const x = canvas.width / 2
Expand All @@ -462,24 +462,29 @@ test('direction-letter-spacing', async (t) => {
ctx.strokeStyle = 'green'
ctx.stroke()

ctx.font = '48px Science Gothic'
ctx.font = '45px Science Gothic'
ctx.fillStyle = 'black'
ctx.letterSpacing = '20px'

// First line: default direction (should be ltr)
ctx.fillText('Hi!', x, 50)
// Second line: rtl direction - "Hi!" should become "!Hi" visually
ctx.direction = 'rtl'
ctx.letterSpacing = '20px'
ctx.fillText('Hi!', x, 130)

ctx.letterSpacing = '12.832px'
ctx.fillText('Hello world!', x, 210, 236.21)
ctx.direction = 'ltr'
ctx.fillText('Hello world!', x, 280, 236.21)

await snapshotImage(t, { canvas, ctx })
})

test('direction-align', async (t) => {
const canvas = createCanvas(500, 580)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic');
function drawDirectionAlignTest(
ctx: SKRSContext2D,
{ text, maxWidth, letterSpacing }: { text?: string; maxWidth?: number; letterSpacing: string },
) {
const canvas = ctx.canvas
const x = canvas.width / 2

ctx.fillStyle = 'white'
Expand All @@ -494,41 +499,181 @@ test('direction-align', async (t) => {

ctx.font = '48px Science Gothic'
ctx.fillStyle = 'black'
ctx.letterSpacing = '3px'
ctx.letterSpacing = letterSpacing

const getText = (dir: string, align: string) => text ?? `${dir} ${align}!`

// ltr align
// start = left in ltr
// ltr align (start = left in ltr)
ctx.direction = 'ltr'
ctx.fillText('ltr start!', 0, 50)
ctx.fillText(getText('ltr', 'start'), 0, 50, maxWidth)
ctx.direction = 'inherit' // inherit = ltr
ctx.textAlign = 'left'
ctx.fillText('ltr left!', 0, 100)
ctx.fillText(getText('ltr', 'left'), 0, 100, maxWidth)

ctx.textAlign = 'center'
ctx.fillText('ltr center!', x, 150)
ctx.fillText(getText('ltr', 'center'), x, 150, maxWidth)

// end = right in ltr
ctx.textAlign = 'end'
ctx.fillText('ltr end!', canvas.width, 200)
ctx.fillText(getText('ltr', 'end'), canvas.width, 200, maxWidth)
ctx.textAlign = 'right'
ctx.fillText('ltr right!', canvas.width, 250)
ctx.fillText(getText('ltr', 'right'), canvas.width, 250, maxWidth)

// rtl align
// start = right in rtl
// rtl align (start = right in rtl)
ctx.direction = 'rtl'
ctx.textAlign = 'start'
ctx.fillText('rtl start!', canvas.width, 350)
ctx.fillText(getText('rtl', 'start'), canvas.width, 350, maxWidth)
ctx.textAlign = 'right'
ctx.fillText('rtl right!', canvas.width, 400)
ctx.fillText(getText('rtl', 'right'), canvas.width, 400, maxWidth)

ctx.textAlign = 'center'
ctx.fillText('rtl center!', x, 450)
ctx.fillText(getText('rtl', 'center'), x, 450, maxWidth)

// end = left in rtl
ctx.textAlign = 'end'
ctx.fillText('rtl end!', 0, 500)
ctx.fillText(getText('rtl', 'end'), 0, 500, maxWidth)
ctx.textAlign = 'left'
ctx.fillText('rtl left!', 0, 550)
ctx.fillText(getText('rtl', 'left'), 0, 550, maxWidth)
}

test('direction-align', async (t) => {
const canvas = createCanvas(500, 580)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic')
drawDirectionAlignTest(ctx, { letterSpacing: '3px' })
await snapshotImage(t, { canvas, ctx })
})

test('direction-align-max-width', async (t) => {
const canvas = createCanvas(500, 580)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic')
drawDirectionAlignTest(ctx, { text: 'Hello!', maxWidth: 160, letterSpacing: '20px' })
await snapshotImage(t, { canvas, ctx })
})

test('direction-save-restore', async (t) => {
const canvas = createCanvas(400, 160)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic')

ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)

ctx.font = '38px Science Gothic'
ctx.fillStyle = 'black'

// Default direction
ctx.fillText(`direction: ${ctx.direction}`, 0, 50)

ctx.save()
ctx.direction = 'rtl'
ctx.fillText(`direction: ${ctx.direction}`, canvas.width, 90)
ctx.restore()

// Should be back to default after restore
ctx.fillText(`direction: ${ctx.direction}`, 0, 130)

t.is(ctx.direction, 'ltr')
await snapshotImage(t, { canvas, ctx })
})

test('direction-stroke-letter-spacing', async (t) => {
const canvas = createCanvas(500, 260)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'SourceSerifPro-Regular.ttf'), 'Source Serif Pro')
const x = canvas.width / 2

ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)

// Draw center line
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, canvas.height)
ctx.strokeStyle = 'green'
ctx.stroke()

ctx.font = '38px Source Serif Pro'
ctx.letterSpacing = '10px'

// LTR with letterSpacing
ctx.direction = 'ltr'
ctx.fillStyle = 'black'
ctx.fillText('LTR text', x, 50)
ctx.strokeStyle = 'blue'
ctx.lineWidth = 1.5
ctx.strokeText('LTR text', x, 100)

// RTL with letterSpacing
ctx.direction = 'rtl'
ctx.fillStyle = 'black'
ctx.fillText('RTL text', x, 170)
ctx.strokeStyle = 'red'
ctx.strokeText('RTL text', x, 220)

await snapshotImage(t, { canvas, ctx })
})

test('direction-negative-letter-spacing', async (t) => {
const canvas = createCanvas(500, 200)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic')
const x = canvas.width / 2

ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)

// Draw center line
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, canvas.height)
ctx.strokeStyle = 'green'
ctx.stroke()

ctx.font = '40px Science Gothic'
ctx.fillStyle = 'black'
ctx.letterSpacing = '-5px'

ctx.direction = 'ltr'
ctx.fillText('Negative', x, 60)

ctx.direction = 'rtl'
ctx.fillText('Negative', x, 140)

await snapshotImage(t, { canvas, ctx })
})

// Ensure that measureText.width is exactly the same in LRT and RTL.
// This is consistent with the behavior of Chrome and Firefox.
test('direction-measure-text', (t) => {
const canvas = createCanvas(400, 100)
const ctx = canvas.getContext('2d')!
GlobalFonts.registerFromPath(join(__dirname, 'fonts', 'ScienceGothic-VariableFont.ttf'), 'Science Gothic')

ctx.font = '38px Science Gothic'

// Test without internal spaces
const textNoSpaces = 'Hello'
ctx.direction = 'ltr'
const ltrMetrics1 = ctx.measureText(textNoSpaces)
ctx.direction = 'rtl'
const rtlMetrics1 = ctx.measureText(textNoSpaces)
t.is(ltrMetrics1.width, rtlMetrics1.width, 'Text without spaces should have same width in LTR and RTL')

// Test with internal spaces
const textWithSpaces = 'Hello World!'
ctx.direction = 'ltr'
const ltrMetrics2 = ctx.measureText(textWithSpaces)
ctx.direction = 'rtl'
const rtlMetrics2 = ctx.measureText(textWithSpaces)
t.is(ltrMetrics2.width, rtlMetrics2.width, 'Text with spaces should have same width in LTR and RTL')

// Test with trailing spaces
const textTrailingSpace = ' Hello World '
ctx.direction = 'ltr'
const ltrMetrics3 = ctx.measureText(textTrailingSpace)
ctx.direction = 'rtl'
const rtlMetrics3 = ctx.measureText(textTrailingSpace)
t.is(ltrMetrics3.width, rtlMetrics3.width, 'Text with trailing space should have same width in LTR and RTL')
})
106 changes: 51 additions & 55 deletions skia-c/skia_c.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -579,19 +579,25 @@ void skiac_canvas_get_line_metrics_or_draw_text(
auto glyphs = run.glyphs();
auto glyphs_size = glyphs.size();
font.getBounds(glyphs, bounds, PAINT_CAST);
auto text_box = paragraph->getRectsForRange(
0, text_len, RectHeightStyle::kTight, RectWidthStyle::kTight);

// line_metrics.fWidth doesn't contain the suffix spaces
// run.calculateWidth will return 0 if font is rendering as fallback
auto line_width = 0.0;
//
// So we use `getMaxIntrinsicWidth()` to get the `line_width`.
// - For single-run text without internal spaces: uses run.advance().fX from
// HarfBuzz shaping
// - For text with internal spaces or multiple runs: uses TextWrapper's
// cluster-based calculation Both are direction-independent and include
// trailing spaces.
//
// Note: Using `getRectsForRange()` may cause `measureText.width` to return
// different values for LTR and RTL layouts.
auto line_width = paragraph->getMaxIntrinsicWidth();
auto first_char_bounds = bounds[0];
auto descent = first_char_bounds.fBottom;
auto ascent = first_char_bounds.fTop;
auto last_char_bounds = bounds[glyphs_size - 1];
auto last_char_pos_x = run.positionX(glyphs_size - 1);
for (auto& box : text_box) {
line_width += box.rect.width();
}

for (size_t i = 1; i <= glyphs_size - 1; ++i) {
auto char_bounds = bounds[i];
Expand Down Expand Up @@ -640,75 +646,65 @@ void skiac_canvas_get_line_metrics_or_draw_text(

auto line_center = line_width / 2.0f;
float paint_x;
float offset_x = 0.0;
// When RTL, Skia lays out text starting from the right edge of the layout
// width (MAX_LAYOUT_WIDTH). We need to compensate for this by subtracting the
// RTL offset.
float offset_x = 0.0f;

// RTL: Skia lays out text from MAX_LAYOUT_WIDTH right edge, compensate here.
// Separated from paint_x to avoid being affected by maxWidth scaling.
float rtl_offset = (text_direction == TextDirection::kRtl)
? (MAX_LAYOUT_WIDTH - line_width)
: 0.0f;
// Skia Paragraph adds letter_spacing / 2 before the first character and after
// the last character.
// For LTR: we need to shift left by letter_spacing / 2 to compensate for the
// spacing before the first character.

// LTR: Skia adds letter_spacing/2 before first char, compensate here.
// Separated from paint_x to avoid being affected by maxWidth scaling.
float letter_spacing_offset =
(text_direction == TextDirection::kLtr) ? -letter_spacing / 2 : 0.0f;
switch ((TextAlign)align) {
case TextAlign::kLeft:
paint_x = x - rtl_offset + letter_spacing_offset;
break;
case TextAlign::kCenter:
paint_x = x - line_center - rtl_offset + letter_spacing_offset;
offset_x = line_center;
break;
case TextAlign::kRight:
paint_x = x - line_width - rtl_offset + letter_spacing_offset;
offset_x = line_width;
break;

// Determine alignment type
auto text_align = (TextAlign)align;
bool is_right_aligned =
text_align == TextAlign::kRight ||
(text_align == TextAlign::kStart &&
text_direction == TextDirection::kRtl) ||
(text_align == TextAlign::kEnd && text_direction == TextDirection::kLtr);

// Calculate paint_x and offset_x based on alignment
if (text_align == TextAlign::kCenter) {
paint_x = x - line_center;
offset_x = line_center;
} else if (is_right_aligned) {
paint_x = x - line_width;
offset_x = line_width;
} else if (text_align == TextAlign::kJustify) {
// Unreachable
case TextAlign::kJustify:
paint_x = x - rtl_offset + letter_spacing_offset;
break;
case TextAlign::kStart:
if (text_direction == TextDirection::kLtr) {
paint_x = x + letter_spacing_offset;
} else {
paint_x = x - line_width - rtl_offset;
offset_x = line_width;
}
break;
case TextAlign::kEnd:
if (text_direction == TextDirection::kRtl) {
paint_x = x - rtl_offset;
} else {
paint_x = x - line_width + letter_spacing_offset;
offset_x = line_width;
}
break;
};
paint_x = x;
} else {
paint_x = x;
}

if (c_canvas) {
auto need_scale = line_width > max_width;
float ratio = need_scale ? max_width / line_width : 1.0;
float ratio = need_scale ? max_width / line_width : 1.0f;
if (need_scale) {
CANVAS_CAST->save();
CANVAS_CAST->scale(ratio, 1.0);
CANVAS_CAST->scale(ratio, 1.0f);
}
auto paint_y = y + baseline_offset;
paragraph->paint(
CANVAS_CAST,
need_scale ? (paint_x + (1 - ratio) * offset_x) / ratio : paint_x,
paint_y);
// final_x: scale user coords (paint_x, offset_x), then apply Skia offsets
float final_x = need_scale ? (paint_x + (1 - ratio) * offset_x) / ratio -
rtl_offset + letter_spacing_offset
: paint_x - rtl_offset + letter_spacing_offset;
paragraph->paint(CANVAS_CAST, final_x, y + baseline_offset);
if (need_scale) {
CANVAS_CAST->restore();
}
} else {
auto offset = -baseline_offset - alphabetic_baseline;
float metrics_paint_x = paint_x + letter_spacing_offset;
c_line_metrics->ascent = -ascent + offset;
c_line_metrics->descent = descent - offset;
c_line_metrics->left =
-paint_x + line_metrics.fLeft - first_char_bounds.fLeft;
c_line_metrics->right = paint_x + last_char_pos_x + last_char_bounds.fRight;
-metrics_paint_x + line_metrics.fLeft - first_char_bounds.fLeft;
c_line_metrics->right =
metrics_paint_x + last_char_pos_x + last_char_bounds.fRight;
c_line_metrics->width = line_width;
c_line_metrics->font_ascent = -font_metrics.fAscent + offset;
c_line_metrics->font_descent = font_metrics.fDescent - offset;
Expand Down