Fix PDF export sizing chart to zoomed viewport (#260, #18) (#319)

* Fix PDF export sizing chart to zoomed viewport

downloadPdf() read the PDF page size from getSvgDimensions(), which returns
the live #chartSvg width/height attributes. Those carry the on-screen zoomed
size (chart_wrapper.ts writes size * scale on every d3-zoom event), not the
true chart size. The SVG drawn into the page instead carried the true
dimensions (getStrippedSvg divides by the zoom scale), so the PDF page and
its contents mismatched: zooming in produced a giant page with the tree in
one corner, zooming out produced a truncated strip. See issues #260 and #18.

Fix: have getSvgContentsWithInlinedImages() return the chart's true width
and height (read from the single stripped SVG clone it already builds), and
have downloadPdf() consume those for both the jsPDF page format and the
draw target. downloadSvg()/downloadPng() were already correct and only need
to destructure contents from the new return shape. The buggy helper
getSvgDimensions() is removed.

* Add regression test for PDF export page size

Zooms the chart, exports a PDF and asserts the PDF MediaBox matches the
chart's true (unscaled) dimensions and not the live zoomed ones. Verified
to fail on the pre-fix code (pdfW == liveW) and pass after the fix
(pdfW == trueW).

* Apply prettier formatting to chart_export.spec.ts
This commit is contained in:
lorenzo perone
2026-07-30 00:04:34 +02:00
committed by GitHub
parent 689f9f253f
commit f04e987df6
3 changed files with 109 additions and 15 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ test-results/
.idea
.vscode
.env
playwright.local.config.ts

View File

@@ -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<HTMLCanvasElement> {
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');
}

View File

@@ -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);
});
});