From 0cd0ee635680a4c0fad52cab2277c30e0c1b9ca2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 25 Jul 2026 23:52:01 +0300 Subject: [PATCH] fix(text): center glyphs within line height (#422) Enable CanvasKit half-leading for solid and gradient paragraph rendering while preserving the existing font fallback system.\n\nCo-authored-by: jongwong --- CHANGELOG.md | 4 ++++ packages/core/src/canvas/scene.ts | 12 +++++++++--- tests/e2e/canvas/typography-depth-visual.spec.ts | 1 + tests/engine/render/canvas/text.test.ts | 8 ++++++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384414824..4f312ce4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering. + ### Added - Import HTML, CSS, Tailwind, and JSX as editable documents from the app, CLI, and SDK, and export standalone browser-ready HTML with compiled CSS and optional external assets. diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index ff44d85ac..427dcb508 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -636,7 +636,9 @@ function drawOutlinedText( function drawGradientText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boolean { if (!r.fontsLoaded || !r.fontProvider) return false - const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1)) + const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1), { + halfLeading: true + }) try { const paragraphY = textVerticalOffset(node, paragraph.getHeight()) r.effectLayerPaint.setImageFilter(null) @@ -693,7 +695,9 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil if (shouldRenderTextAsOutline(fill)) { let paragraphY = 0 if (node.textAlignVertical !== 'TOP') { - const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1)) + const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1), { + halfLeading: true + }) paragraphY = textVerticalOffset(node, paragraph.getHeight()) paragraph.delete() } @@ -707,7 +711,9 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil return } if (r.fontsLoaded && r.fontProvider) { - const paragraph = r.buildParagraph(node, r.fillPaint.getColor()) + const paragraph = r.buildParagraph(node, r.fillPaint.getColor(), { + halfLeading: true + }) const paragraphY = textVerticalOffset(node, paragraph.getHeight()) canvas.drawParagraph(paragraph, 0, paragraphY) paragraph.delete() diff --git a/tests/e2e/canvas/typography-depth-visual.spec.ts b/tests/e2e/canvas/typography-depth-visual.spec.ts index 548bb6bc2..ab2b3dce7 100644 --- a/tests/e2e/canvas/typography-depth-visual.spec.ts +++ b/tests/e2e/canvas/typography-depth-visual.spec.ts @@ -35,6 +35,7 @@ test('text case vertical alignment and ending truncation', async () => { height: 106, text: item.label, fontSize: 20, + lineHeight: 36, textCase: item.textCase, textAlignVertical: item.vertical }) diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index e234cbbc5..d4dd6d111 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -129,7 +129,9 @@ describe('renderText', () => { renderText(r, canvas as never, textNode()) - expect(r.buildParagraph).toHaveBeenCalledTimes(1) + expect(r.buildParagraph).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + halfLeading: true + }) expect(canvas.drawParagraph).toHaveBeenCalledTimes(1) expect(canvas.drawText).not.toHaveBeenCalled() expect(r._paragraph.delete).toHaveBeenCalledTimes(1) @@ -158,7 +160,9 @@ describe('renderText', () => { gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }) - expect(r.buildParagraph).toHaveBeenCalledTimes(1) + expect(r.buildParagraph).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + halfLeading: true + }) expect(canvas.saveLayer).toHaveBeenCalledTimes(2) expect(canvas.drawParagraph).toHaveBeenCalledTimes(1) expect(canvas.drawRect).toHaveBeenCalledTimes(1)