snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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
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
This commit is contained in:
211
apps/examples/e2e/tests/test-text-export-ink.spec.ts
Normal file
211
apps/examples/e2e/tests/test-text-export-ink.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user