diff --git a/.gitignore b/.gitignore index e616305..e54c5f1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ test-results/ .idea .vscode .env +playwright.local.config.ts diff --git a/src/chart/chart_export.ts b/src/chart/chart_export.ts index 14fbf32..a701603 100644 --- a/src/chart/chart_export.ts +++ b/src/chart/chart_export.ts @@ -95,21 +95,30 @@ function getStrippedSvg() { return svg; } -function getSvgDimensions() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const svg = document.getElementById('chartSvg')!; - return { - width: Number(svg.getAttribute('width')), - height: Number(svg.getAttribute('height')), - }; -} - function getSvgContents() { return new XMLSerializer().serializeToString(getStrippedSvg()); } -async function getSvgContentsWithInlinedImages() { +/** + * Returns the serialized chart SVG with a white background and all images + * inlined as data URLs, together with the chart's true (unscaled) width and + * height in pixels. + * + * The dimensions are read from the stripped SVG (i.e. with the d3-zoom scale + * divided out), NOT from the live `#chartSvg` element, whose `width`/`height` + * attributes carry the zoom-scaled values. Callers that need the real page + * size (e.g. PDF export) must use these returned dimensions rather than + * reading the live element, otherwise the output is sized to the on-screen + * zoomed viewport and ends up truncated. + */ +async function getSvgContentsWithInlinedImages(): Promise<{ + contents: string; + width: number; + height: number; +}> { const svg = getStrippedSvg(); + const width = Number(svg.getAttribute('width')); + const height = Number(svg.getAttribute('height')); // Set white background because the default background of the SVG // is transparent, which causes issues when printing or exporting to PDF. @@ -123,7 +132,8 @@ async function getSvgContentsWithInlinedImages() { svg.prepend(rect); await inlineImages(svg); - return new XMLSerializer().serializeToString(svg); + const contents = new XMLSerializer().serializeToString(svg); + return {contents, width, height}; } /** Shows the print dialog to print the currently displayed chart. */ @@ -147,13 +157,13 @@ export function printChart() { } export async function downloadSvg() { - const contents = await getSvgContentsWithInlinedImages(); + const {contents} = await getSvgContentsWithInlinedImages(); const blob = new Blob([contents], {type: 'image/svg+xml'}); saveAs(blob, 'topola.svg'); } async function drawOnCanvas(): Promise { - const contents = await getSvgContentsWithInlinedImages(); + const {contents} = await getSvgContentsWithInlinedImages(); const blob = new Blob([contents], {type: 'image/svg+xml'}); return drawImageOnCanvas(await loadImage(blob)); } @@ -168,13 +178,16 @@ export async function downloadPdf() { // Lazy load jspdf. const {default: jspdf} = await import('jspdf'); - const {width, height} = getSvgDimensions(); + // Use the chart's true (unscaled) dimensions, returned by the same call that + // serializes the SVG, so the PDF page and the drawn SVG always match. Reading + // the live `#chartSvg` attributes here would pick up d3-zoom's scaled values + // and produce a page sized to the on-screen viewport instead of the full tree. + const {contents, width, height} = await getSvgContentsWithInlinedImages(); const doc = new jspdf({ orientation: width > height ? 'l' : 'p', unit: 'pt', format: [width, height], }); - const contents = await getSvgContentsWithInlinedImages(); await doc.addSvgAsImage(contents, 0, 0, width, height); doc.save('topola.pdf'); } diff --git a/tests/chart_export.spec.ts b/tests/chart_export.spec.ts new file mode 100644 index 0000000..0844503 --- /dev/null +++ b/tests/chart_export.spec.ts @@ -0,0 +1,80 @@ +import {expect, test} from '@playwright/test'; +import {readFile} from 'node:fs/promises'; +import {setupGedcomRoute} from './helpers'; + +/** + * Reads the chart's live (zoom-scaled) and true (unscaled) dimensions from the + * rendered page. Mirrors what `getStrippedSvg()` does in chart_export.ts: the + * live `#chartSvg` width/height carry the on-screen zoomed size, so the true + * size is recovered by dividing by the d3-zoom scale stored on `#svgContainer`. + */ +async function getChartDimensions(page: import('@playwright/test').Page) { + return page.evaluate(() => { + const svg = document.getElementById('chartSvg'); + const container = document.getElementById('svgContainer'); + const k = (container as unknown as {__zoom?: {k: number}}).__zoom?.k ?? 1; + const liveW = Number(svg?.getAttribute('width') ?? 0); + const liveH = Number(svg?.getAttribute('height') ?? 0); + return {liveW, liveH, scale: k, trueW: liveW / k, trueH: liveH / k}; + }); +} + +/** Parses the page size (in PDF user units / points) from a generated PDF. */ +async function getPdfPageSize(pdfPath: string): Promise<[number, number]> { + const buf = await readFile(pdfPath); + // jsPDF writes a single MediaBox entry as [0 0 W H]. PDF numbers are the part + // between the brackets; the last two whitespace-separated tokens are W and H. + const mediaBox = /\/MediaBox\s*\[\s*([0-9.\s-]+)\s*\]/.exec( + buf.toString('latin1'), + ); + if (!mediaBox) { + throw new Error('MediaBox not found in generated PDF'); + } + const nums = mediaBox[1].trim().split(/\s+/).map(Number); + return [nums[nums.length - 2], nums[nums.length - 1]]; +} + +test.describe('Chart export', () => { + test.beforeEach(async ({page, context}) => { + await setupGedcomRoute(context); + await page.goto('/#/view?url=https://example.org/family.ged'); + }); + + test('PDF page size matches the chart size regardless of zoom', async ({ + page, + }) => { + await expect(page.locator('#content')).toContainText('Bonifacy'); + + // Zoom in so the live #chartSvg width/height are scaled and no longer equal + // to the true chart size. Without this step the bug would be masked. + for (let i = 0; i < 3; i++) { + await page.locator('.zoom-in').click(); + } + await page.waitForTimeout(300); + + const dims = await getChartDimensions(page); + // Sanity: the zoom actually applied, otherwise the test below is vacuous. + expect(dims.scale).toBeGreaterThan(1); + expect(dims.liveW).not.toEqual(dims.trueW); + expect(dims.liveH).not.toEqual(dims.trueH); + + // Open the Download dropdown and choose "PDF file". + await page.getByText('Download', {exact: true}).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByText('PDF file', {exact: true}).click(); + const download = await downloadPromise; + const pdfPath = await download.path(); + expect(pdfPath).toBeTruthy(); + + const [pdfW, pdfH] = await getPdfPageSize(pdfPath ?? ''); + + // The PDF page must be sized to the TRUE (unscaled) chart, not to the + // on-screen zoomed viewport. This is the regression check for the bug where + // downloadPdf() read the live #chartSvg attributes. + expect(pdfW).toBeCloseTo(dims.trueW, 0); + expect(pdfH).toBeCloseTo(dims.trueH, 0); + // And explicitly NOT the zoomed size. + expect(pdfW).not.toBeCloseTo(dims.liveW, 0); + expect(pdfH).not.toBeCloseTo(dims.liveH, 0); + }); +});