mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-10-17 03:04:04 +00:00
* 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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ test-results/
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.env
|
.env
|
||||||
|
playwright.local.config.ts
|
||||||
|
|||||||
@@ -95,21 +95,30 @@ function getStrippedSvg() {
|
|||||||
return svg;
|
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() {
|
function getSvgContents() {
|
||||||
return new XMLSerializer().serializeToString(getStrippedSvg());
|
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 svg = getStrippedSvg();
|
||||||
|
const width = Number(svg.getAttribute('width'));
|
||||||
|
const height = Number(svg.getAttribute('height'));
|
||||||
|
|
||||||
// Set white background because the default background of the SVG
|
// Set white background because the default background of the SVG
|
||||||
// is transparent, which causes issues when printing or exporting to PDF.
|
// is transparent, which causes issues when printing or exporting to PDF.
|
||||||
@@ -123,7 +132,8 @@ async function getSvgContentsWithInlinedImages() {
|
|||||||
svg.prepend(rect);
|
svg.prepend(rect);
|
||||||
|
|
||||||
await inlineImages(svg);
|
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. */
|
/** Shows the print dialog to print the currently displayed chart. */
|
||||||
@@ -147,13 +157,13 @@ export function printChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadSvg() {
|
export async function downloadSvg() {
|
||||||
const contents = await getSvgContentsWithInlinedImages();
|
const {contents} = await getSvgContentsWithInlinedImages();
|
||||||
const blob = new Blob([contents], {type: 'image/svg+xml'});
|
const blob = new Blob([contents], {type: 'image/svg+xml'});
|
||||||
saveAs(blob, 'topola.svg');
|
saveAs(blob, 'topola.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawOnCanvas(): Promise<HTMLCanvasElement> {
|
async function drawOnCanvas(): Promise<HTMLCanvasElement> {
|
||||||
const contents = await getSvgContentsWithInlinedImages();
|
const {contents} = await getSvgContentsWithInlinedImages();
|
||||||
const blob = new Blob([contents], {type: 'image/svg+xml'});
|
const blob = new Blob([contents], {type: 'image/svg+xml'});
|
||||||
return drawImageOnCanvas(await loadImage(blob));
|
return drawImageOnCanvas(await loadImage(blob));
|
||||||
}
|
}
|
||||||
@@ -168,13 +178,16 @@ export async function downloadPdf() {
|
|||||||
// Lazy load jspdf.
|
// Lazy load jspdf.
|
||||||
const {default: jspdf} = await import('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({
|
const doc = new jspdf({
|
||||||
orientation: width > height ? 'l' : 'p',
|
orientation: width > height ? 'l' : 'p',
|
||||||
unit: 'pt',
|
unit: 'pt',
|
||||||
format: [width, height],
|
format: [width, height],
|
||||||
});
|
});
|
||||||
const contents = await getSvgContentsWithInlinedImages();
|
|
||||||
await doc.addSvgAsImage(contents, 0, 0, width, height);
|
await doc.addSvgAsImage(contents, 0, 0, width, height);
|
||||||
doc.save('topola.pdf');
|
doc.save('topola.pdf');
|
||||||
}
|
}
|
||||||
|
|||||||
80
tests/chart_export.spec.ts
Normal file
80
tests/chart_export.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user