Files
readest/apps/readest-app/src/__tests__/document/pdf-spread-seam.test.ts
T
Huang Xin a9c0f3d46d fix(reader): remove 1px white seam in PDF spread at fractional DPI (#4587) (#4713)
In a PDF two-page spread at a fractional devicePixelRatio (Windows display
scale 150% -> dpr 1.5), a one-pixel white bar appeared at the spine on certain
zoom levels. foliate-js' pdf.js sized the page canvas only via its bitmap, so
the fractional viewport width was truncated and the canvas rendered up to ~1
device pixel narrower than the page box, exposing the background at the spine.

Bump the foliate-js submodule to the fix (readest/foliate-js#35) which pins an
explicit canvas CSS size to the un-truncated viewport dimensions, and add a
regression test that drives render() at dpr 1.5 and asserts the canvas fills
its box exactly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:20:28 +02:00

135 lines
5.2 KiB
TypeScript

// Regression test for readest/readest issue #4587.
//
// In a PDF two-page spread at a fractional device pixel ratio (Windows display
// scale 150% -> devicePixelRatio 1.5), a one-pixel white bar appeared in the
// middle of the spread on certain zoom levels.
//
// Root cause: `render()` sized the page canvas only via its bitmap dimensions
// (`canvas.width = viewport.width`). A canvas bitmap width is an integer, so
// the fractional `viewport.width` (= pageWidthCss * devicePixelRatio) was
// truncated. The iframe content is displayed scaled by `1 / devicePixelRatio`,
// so the truncated bitmap rendered up to ~1 device pixel narrower than the page
// box. The left page's canvas therefore stopped short of the spine, exposing
// the background as a thin white seam.
//
// The fix gives the canvas an explicit CSS size equal to the *un-truncated*
// viewport dimensions, so the bitmap is scaled to fill the page box exactly and
// the left page reaches the spine regardless of bitmap truncation.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// US Letter, the size used by the test fixture PDFs.
const PAGE_W = 612;
const PAGE_H = 792;
// Minimal stand-in for the vendored pdf.js build. foliate-js/pdf.js imports it
// for the side effect of setting globalThis.pdfjsLib, then reads from that
// global — so the mock installs a controllable fake there.
vi.mock('@pdfjs/pdf.min.mjs', () => {
class PDFDataRangeTransport {
requestDataRange!: (begin: number, end: number) => void;
onDataRange = vi.fn();
constructor(
public length: number,
public initialData: unknown,
) {}
}
const makePage = () => ({
getViewport: ({ scale }: { scale: number }) => ({
width: PAGE_W * scale,
height: PAGE_H * scale,
scale,
}),
render: () => ({ promise: Promise.resolve(), cancel: () => {} }),
streamTextContent: () => ({}),
getTextContent: async () => ({ items: [] }),
getAnnotations: async () => [],
cleanup: () => {},
});
const fakePdf = {
numPages: 3,
getPage: vi.fn(async () => makePage()),
getMetadata: vi.fn(async () => ({ metadata: undefined, info: {} })),
getOutline: vi.fn(async () => null),
getDestination: vi.fn(),
getPageIndex: vi.fn(),
destroy: vi.fn(),
};
class TextLayer {
render = async () => {};
}
class AnnotationLayer {
render = async () => {};
}
(globalThis as unknown as { pdfjsLib: unknown }).pdfjsLib = {
GlobalWorkerOptions: {},
PDFDataRangeTransport,
getDocument: vi.fn(() => ({ promise: Promise.resolve(fakePdf) })),
TextLayer,
AnnotationLayer,
};
return {};
});
beforeEach(() => {
// Windows display scale 150%.
vi.stubGlobal('devicePixelRatio', 1.5);
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ text: async () => '' })),
);
URL.createObjectURL = vi.fn(() => 'blob:mock');
URL.revokeObjectURL = vi.fn();
// jsdom has no 2D context; render() only forwards it to the mocked
// page.render(), so a null context is fine.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('PDF spread canvas seam (#4587)', () => {
it('sizes the page canvas to fill its box exactly at fractional devicePixelRatio', async () => {
const { makePDF } = await import('foliate-js/pdf.js');
const file = { size: 1024, slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }) };
const book = (await makePDF(file as unknown as File)) as unknown as {
sections: { load: () => Promise<{ onZoom: (arg: unknown) => Promise<void> }> }[];
};
const { onZoom } = await book.sections[0]!.load();
// render() bails out when the document has no `defaultView`, so drive it
// through a real iframe document (which has one) rather than a detached one.
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const doc = iframe.contentDocument!;
doc.body.innerHTML =
'<div id="canvas"></div><div class="textLayer"></div><div class="annotationLayer"></div>';
// A zoom whose page-box width forces the device-pixel viewport width to be
// fractional: 612 * zoom * 1.5 must not be an integer.
const zoom = 0.8523;
const pageBoxWidth = PAGE_W * zoom; // CSS px width of the page box
const viewportWidth = pageBoxWidth * 1.5; // un-truncated device-pixel width
await onZoom({ doc, scale: zoom, pageColors: null });
const canvas = doc.querySelector('#canvas canvas') as HTMLCanvasElement;
expect(canvas).toBeTruthy();
// Bitmap width is an integer and is truncated below the ideal device size —
// this is the truncation that, left to drive layout, produced the seam.
expect(Number.isInteger(canvas.width)).toBe(true);
expect(canvas.width).toBeLessThan(viewportWidth);
// The fix: an explicit CSS size equal to the un-truncated viewport width, so
// the page fills its box exactly (displayed width = box width) and the left
// page reaches the spine — no white seam.
expect(canvas.style.width).not.toBe('');
expect(parseFloat(canvas.style.width)).toBeCloseTo(viewportWidth, 3);
expect(parseFloat(canvas.style.width) / 1.5).toBeCloseTo(pageBoxWidth, 3);
});
});