Files
upstream-tldraw/apps/examples/e2e/tests/test-text-export-ink.spec.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

212 lines
7.1 KiB
TypeScript

import test, { expect } from '@playwright/test'
import { Editor } from 'tldraw'
import { setup } from '../shared-e2e'
declare const editor: Editor
// #8802: italic / cursive glyph ink (side bearings, slanted ascenders) spills past a text shape's
// advance box. On PNG export the shape is drawn into a <foreignObject> sized to that advance box,
// and the rasteriser clips the overflow at its edge — so the exported image loses the ink.
//
// This is self-relative so it's font-independent and stable across CI: the same glyphs are exported
// twice — once in an autosizing shape whose box hugs the text (so its foreignObject can clip), and
// once in a deliberately wide fixed-width box that has room to spare (so it can't clip). We scan the
// painted glyph width of each PNG. If the autosize export clips the ink, it comes out narrower than
// the wide one; when the export encloses the ink, the two match.
test.describe('text export ink', () => {
test.beforeEach(setup)
test('autosize export encloses italic glyph ink like a wide box does (#8802)', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'chromium',
'export rasterisation compared on desktop chromium'
)
const { autoInkWidth, wideInkWidth } = await page.evaluate(async () => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
const richText = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'jiffy', marks: [{ type: 'italic' }] }],
},
],
}
// Same glyphs, two boxes: one that hugs the text (can clip) and one with room (can't).
ed.createShape({
id: 'shape:exAuto',
type: 'text',
x: 0,
y: 0,
props: { richText, font: 'serif', size: 'xl', autoSize: true, color: 'black' },
})
ed.createShape({
id: 'shape:exWide',
type: 'text',
x: 0,
y: 400,
props: { richText, font: 'serif', size: 'xl', autoSize: false, w: 800, color: 'black' },
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
// Scan a single shape's PNG export for its painted glyph bounding-box width (outline off, so
// the shadow halo doesn't pollute the scan).
async function inkWidth(id: string) {
const { url } = await ed.toImageDataUrl([id], {
format: 'png',
background: false,
scale: 2,
})
const img = new Image()
await new Promise((res, rej) => {
img.onload = res
img.onerror = rej
img.src = url
})
const cv = ed.getContainerDocument().createElement('canvas')
cv.width = img.naturalWidth
cv.height = img.naturalHeight
const ictx = cv.getContext('2d', { willReadFrequently: true })
ictx.drawImage(img, 0, 0)
const data = ictx.getImageData(0, 0, cv.width, cv.height).data
let minX = Infinity
let maxX = -Infinity
for (let y = 0; y < cv.height; y++) {
for (let x = 0; x < cv.width; x++) {
if (data[(y * cv.width + x) * 4 + 3] > 10) {
if (x < minX) minX = x
if (x > maxX) maxX = x
}
}
}
return isFinite(minX) ? maxX - minX + 1 : 0
}
const util = ed.getShapeUtil('text')
const prevOutline = util.options.showTextOutline
util.options.showTextOutline = false
try {
const autoInkWidth = await inkWidth('shape:exAuto')
const wideInkWidth = await inkWidth('shape:exWide')
return { autoInkWidth, wideInkWidth }
} finally {
util.options.showTextOutline = prevOutline
}
})
// The wide box can't clip, so its export shows the full glyph ink. The autosize export must
// enclose the same ink (to within anti-aliasing) rather than cutting it at the advance box.
expect(autoInkWidth).toBeGreaterThanOrEqual(wideInkWidth - 3)
})
test('png export of italic text is not clipped (visual) (#8802)', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'chromium', 'visual export snapshot on desktop chromium')
const dataUrl: string = await page.evaluate(async () => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
ed.createShape({
id: 'shape:jiffy',
type: 'text',
x: 0,
y: 0,
props: {
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'jiffy', marks: [{ type: 'italic' }] }],
},
],
},
font: 'serif',
size: 'xl',
autoSize: true,
color: 'black',
},
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
// Auto-trimmed export over the page background: the render padding is cropped back to the
// painted glyphs, so the golden is a tight image of the ink — the italic j / ff / y fully
// enclosed, on a solid page background.
const { url } = await ed.toImageDataUrl(['shape:jiffy'], {
format: 'png',
background: true,
scale: 2,
})
return url as string
})
// Golden of the exported glyphs: the italic j / ff / y ink must be fully present, not cut off
// at the advance box. Before the fix the descenders and slant were clipped at the edge.
expect(Buffer.from(dataUrl.split(',')[1], 'base64')).toMatchSnapshot('jiffy-italic-export.png')
})
test('svg export of italic text is not clipped (visual) (#8802)', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'chromium', 'visual export snapshot on desktop chromium')
const dataUrl: string = await page.evaluate(async () => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
ed.createShape({
id: 'shape:jiffy',
type: 'text',
x: 0,
y: 0,
props: {
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'jiffy', marks: [{ type: 'italic' }] }],
},
],
},
font: 'serif',
size: 'xl',
autoSize: true,
color: 'black',
},
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
// Export as an SVG (auto-trimmed viewBox), then rasterise it the way a browser/viewer does:
// loading the SVG into an <img> is where Chrome clips each <foreignObject> to its rect, so this
// exercises the exact clipping path the fix targets. The padded foreignObject must render the
// full ink within the trimmed viewBox.
const { url, width, height } = await ed.toImageDataUrl(['shape:jiffy'], {
format: 'svg',
background: true,
})
const img = new Image()
await new Promise((res, rej) => {
img.onload = res
img.onerror = rej
img.src = url
})
const scale = 2
const cv = ed.getContainerDocument().createElement('canvas')
cv.width = Math.ceil(width * scale)
cv.height = Math.ceil(height * scale)
const c = cv.getContext('2d')!
c.drawImage(img, 0, 0, cv.width, cv.height)
return cv.toDataURL('image/png')
})
// Golden of the exported SVG rasterised: the italic j / ff / y ink must be fully present, matching
// the PNG export. Before the fix the foreignObject clipped the descenders and slant on rasterisation.
expect(Buffer.from(dataUrl.split(',')[1], 'base64')).toMatchSnapshot(
'jiffy-italic-svg-export.png'
)
})
})