Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MS] Improve PdfViewer pages error #9362

Merged
merged 1 commit into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/parsec/mock_files/pdf.ts

Large diffs are not rendered by default.

132 changes: 82 additions & 50 deletions client/src/views/viewers/PdfViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { FileControls, FileControlsButton, FileControlsPagination, FileControlsZ
import { MsSpinner, MsReportText, MsReportTheme, I18n } from 'megashark-lib';
import * as pdfjs from 'pdfjs-dist';
import { scan } from 'ionicons/icons';
import { needsMocks } from '@/parsec';

const props = defineProps<{
contentInfo: FileContentInfo;
Expand All @@ -71,6 +72,11 @@ const zoomControl = ref();
const scale = ref(1);
const canvas = ref<HTMLCanvasElement[]>([]);
const isRendering = ref(false);
const enum CanvasStates {
Rendered = 'rendered',
Failed = 'failed',
}
const CanvasStateAttribute = 'data-canvas-state';

onMounted(async () => {
loading.value = true;
Expand All @@ -95,7 +101,11 @@ function isInViewport(canvasElement: HTMLCanvasElement): boolean {
}

function isCanvasRendered(canvasElement: HTMLCanvasElement): boolean {
return canvasElement.getAttribute('data-rendered') !== null;
return canvasElement.getAttribute(CanvasStateAttribute) === CanvasStates.Rendered;
}

function isCanvasOnError(canvasElement: HTMLCanvasElement): boolean {
return canvasElement.getAttribute(CanvasStateAttribute) === CanvasStates.Failed;
}

async function loadPage(pageIndex: number): Promise<void> {
Expand All @@ -108,52 +118,63 @@ async function loadPage(pageIndex: number): Promise<void> {

try {
const page = await pdf.value.getPage(pageIndex);
if (needsMocks() && pageIndex === 4) {
throw new Error('Failed to load page');
}

const canvasElement = canvas.value.at(pageIndex - 1);
if (!canvasElement) {
loading.value = false;
return;
}

const outputScale = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: scale.value });
canvasElement.width = viewport.width * outputScale;
canvasElement.height = viewport.height * outputScale;
canvasElement.style.width = `${Math.floor(viewport.width)}px`;
canvasElement.style.height = `${Math.floor(viewport.height)}px`;

canvasElement.removeAttribute('data-rendered');
drawBlankCanvas(canvasElement, viewport);
} catch (e: any) {
const canvasElement = canvas.value.at(pageIndex - 1);
if (!canvasElement) {
loading.value = false;
return;
}
canvasElement.width = 300;
canvasElement.height = 60;
canvasElement.style.width = '300px';
canvasElement.style.height = '60px';
canvasElement.classList.add('error');
const context = canvasElement.getContext('2d', { willReadFrequently: true });
if (context) {
const errorMessage = I18n.translate('fileViewers.pdf.loadPageError');
context.font = '14px "Albert Sans"';
drawErrorCanvas(canvasElement);
window.electronAPI.log('error', `Failed to load PDF page: ${e}`);
} finally {
loading.value = false;
}
}

// Calculate the width and height of the text
const textMetrics = context.measureText(errorMessage);
const textWidth = textMetrics.width;
const textHeight = 14; // Approximate height based on font size
function drawBlankCanvas(canvasElement: HTMLCanvasElement, viewport: pdfjs.PageViewport): void {
const outputScale = window.devicePixelRatio || 1;
canvasElement.width = viewport.width * outputScale;
canvasElement.height = viewport.height * outputScale;
canvasElement.style.width = `${Math.floor(viewport.width)}px`;
canvasElement.style.height = `${Math.floor(viewport.height)}px`;

// Calculate the position to center the text
const x = (canvasElement.width - textWidth) / 2;
const y = (canvasElement.height + textHeight) / 2;
canvasElement.removeAttribute(CanvasStateAttribute);
canvasElement.classList.remove('error');
}

// Draw the text
context.fillText(errorMessage, x, y);
}
window.electronAPI.log('error', `Failed to open PDF page: ${e}`);
} finally {
loading.value = false;
function drawErrorCanvas(canvasElement: HTMLCanvasElement): void {
canvasElement.width = 300;
canvasElement.height = 60;
canvasElement.style.width = '300px';
canvasElement.style.height = '60px';
canvasElement.setAttribute(CanvasStateAttribute, CanvasStates.Failed);
canvasElement.classList.add('error');

const context = canvasElement.getContext('2d', { willReadFrequently: true });
if (context) {
const errorMessage = I18n.translate('fileViewers.pdf.loadPageError');
context.font = '14px "Albert Sans"';

// Calculate the width and height of the text
const textMetrics = context.measureText(errorMessage);
const textWidth = textMetrics.width;
const textHeight = 14; // Approximate height based on font size

// Calculate the position to center the text
const x = (canvasElement.width - textWidth) / 2;
const y = (canvasElement.height + textHeight) / 2;

// Draw the text
context.fillText(errorMessage, x, y);
}
}

Expand All @@ -168,29 +189,40 @@ async function loadPages(): Promise<void> {
}

async function renderPage(pageNumber: number): Promise<void> {
const page = await pdf.value!.getPage(pageNumber);
if (!page || isRendering.value) {
return;
}

const canvasElement = canvas.value.at(pageNumber - 1);
if (!canvasElement || isCanvasRendered(canvasElement)) {
if (isRendering.value || !canvasElement || isCanvasRendered(canvasElement) || isCanvasOnError(canvasElement)) {
return;
}

isRendering.value = true;

const outputScale = window.devicePixelRatio || 1;
const context = canvasElement.getContext('2d', { willReadFrequently: true });
const renderContext = {
canvasContext: context!,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined,
viewport: page.getViewport({ scale: scale.value }),
};

await page.render(renderContext).promise;
isRendering.value = false;
canvasElement.setAttribute('data-rendered', '');
try {
const page = await pdf.value!.getPage(pageNumber);
if (!page) {
return;
}

const viewport = page.getViewport({ scale: scale.value });
const outputScale = window.devicePixelRatio || 1;
const context = canvasElement.getContext('2d', { willReadFrequently: true });
const renderContext = {
canvasContext: context!,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined,
viewport: viewport,
};

await page.render(renderContext).promise;
canvasElement.setAttribute(CanvasStateAttribute, CanvasStates.Rendered);
} catch (e: any) {
const canvasElement = canvas.value.at(pageNumber - 1);
if (!canvasElement) {
return;
}
drawErrorCanvas(canvasElement);
window.electronAPI.log('error', `Failed to render PDF page: ${e}`);
} finally {
isRendering.value = false;
}
}

async function onZoomLevelChange(value: number): Promise<void> {
Expand Down
102 changes: 73 additions & 29 deletions client/tests/e2e/specs/file_viewers_pdf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
import { Locator } from '@playwright/test';
import { expect, msTest, openFileType } from '@tests/e2e/helpers';

const enum CanvasStates {
Rendered = 'rendered',
Failed = 'failed',
Blank = 'blank',
}

msTest('PDF viewer: content', async ({ documents }) => {
await openFileType(documents, 'pdf');
await expect(documents).toBeViewerPage();
Expand All @@ -11,7 +17,7 @@ msTest('PDF viewer: content', async ({ documents }) => {

const wrapper = documents.locator('.file-viewer-wrapper');
const pages = wrapper.locator('.pdf-container').locator('canvas');
await expect(pages).toHaveCount(3);
await expect(pages).toHaveCount(4);
});

msTest('PDF viewer: pagination', async ({ documents }) => {
Expand All @@ -25,7 +31,7 @@ msTest('PDF viewer: pagination', async ({ documents }) => {
const paginationElement = pagination.locator('.file-controls-input');

async function expectPage(pageNumber: number): Promise<void> {
await expect(paginationElement).toHaveText(`Page ${pageNumber} / 3`);
await expect(paginationElement).toHaveText(`Page ${pageNumber} / 4`);
for (const [index, page] of (await pages.all()).entries()) {
index === pageNumber - 1 ? await expect(page).toBeInViewport() : await expect(page).not.toBeInViewport();
}
Expand Down Expand Up @@ -92,27 +98,38 @@ msTest('PDF viewer: scroll', async ({ documents }) => {
const firstPage = pages.nth(0);
const secondPage = pages.nth(1);
const thirdPage = pages.nth(2);
const fourthPage = pages.nth(3);

const pagination = bottomBar.locator('.file-controls-pagination');
const paginationElement = pagination.locator('.file-controls-input');

await fourthPage.scrollIntoViewIfNeeded();
await expect(firstPage).not.toBeInViewport();
await expect(secondPage).not.toBeInViewport();
await expect(thirdPage).toBeInViewport(); // the last page is an error canvas, leaving space for the previous one
await expect(fourthPage).toBeInViewport();
await expect(paginationElement).toHaveText('Page 4 / 4');

await thirdPage.scrollIntoViewIfNeeded();
await expect(firstPage).not.toBeInViewport();
await expect(secondPage).not.toBeInViewport();
await expect(thirdPage).toBeInViewport();
await expect(paginationElement).toHaveText('Page 3 / 3');
await expect(fourthPage).not.toBeInViewport();
await expect(paginationElement).toHaveText('Page 4 / 4');

await secondPage.scrollIntoViewIfNeeded();
await expect(firstPage).not.toBeInViewport();
await expect(secondPage).toBeInViewport();
await expect(thirdPage).not.toBeInViewport();
await expect(paginationElement).toHaveText('Page 2 / 3');
await expect(fourthPage).not.toBeInViewport();
await expect(paginationElement).toHaveText('Page 2 / 4');

await firstPage.scrollIntoViewIfNeeded();
await expect(firstPage).toBeInViewport();
await expect(secondPage).not.toBeInViewport();
await expect(thirdPage).not.toBeInViewport();
await expect(paginationElement).toHaveText('Page 1 / 3');
await expect(fourthPage).not.toBeInViewport();
await expect(paginationElement).toHaveText('Page 1 / 4');
});

msTest('PDF viewer: zoom', async ({ documents }) => {
Expand All @@ -133,8 +150,10 @@ msTest('PDF viewer: zoom', async ({ documents }) => {

async function expectZoomLevel(expectedZoomLevel: number): Promise<void> {
for (const page of await canvas.all()) {
await expect(page).toHaveCSS('width', `${Math.floor((initialWidth * expectedZoomLevel) / 100)}px`);
await expect(page).toHaveCSS('height', `${Math.floor((initialHeight * expectedZoomLevel) / 100)}px`);
if ((await page.getAttribute('data-canvas-state')) !== CanvasStates.Failed) {
await expect(page).toHaveCSS('width', `${Math.floor((initialWidth * expectedZoomLevel) / 100)}px`);
await expect(page).toHaveCSS('height', `${Math.floor((initialHeight * expectedZoomLevel) / 100)}px`);
}
}
}

Expand Down Expand Up @@ -198,42 +217,67 @@ msTest('PDF viewer: progressive loading', async ({ documents }) => {
const firstPage = pages.nth(0);
const secondPage = pages.nth(1);
const thirdPage = pages.nth(2);
await expect(pages).toHaveCount(3);

async function expectPageToBeRendered(page: Locator, rendered: boolean): Promise<void> {
rendered ? await expect(page).toHaveAttribute('data-rendered') : await expect(page).not.toHaveAttribute('data-rendered');
const fourthPage = pages.nth(3);
await expect(pages).toHaveCount(4);

async function expectCanvasStateToBe(page: Locator, state: CanvasStates): Promise<void> {
switch (state) {
case CanvasStates.Rendered:
await expect(page).toHaveAttribute('data-canvas-state', CanvasStates.Rendered);
break;
case CanvasStates.Failed:
await expect(page).toHaveAttribute('data-canvas-state', CanvasStates.Failed);
break;
case CanvasStates.Blank:
await expect(page).not.toHaveAttribute('data-canvas-state');
break;
}
}
await expectPageToBeRendered(firstPage, true);
await expectPageToBeRendered(secondPage, false);
await expectPageToBeRendered(thirdPage, false);
await expectCanvasStateToBe(firstPage, CanvasStates.Rendered);
await expectCanvasStateToBe(secondPage, CanvasStates.Blank);
await expectCanvasStateToBe(thirdPage, CanvasStates.Blank);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);

await secondPage.scrollIntoViewIfNeeded();
await expectPageToBeRendered(firstPage, true);
await expectPageToBeRendered(secondPage, true);
await expectPageToBeRendered(thirdPage, false);
await expectCanvasStateToBe(firstPage, CanvasStates.Rendered);
await expectCanvasStateToBe(secondPage, CanvasStates.Rendered);
await expectCanvasStateToBe(thirdPage, CanvasStates.Blank);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);

await thirdPage.scrollIntoViewIfNeeded();
await expectPageToBeRendered(firstPage, true);
await expectPageToBeRendered(secondPage, true);
await expectPageToBeRendered(thirdPage, true);
await expectCanvasStateToBe(firstPage, CanvasStates.Rendered);
await expectCanvasStateToBe(secondPage, CanvasStates.Rendered);
await expectCanvasStateToBe(thirdPage, CanvasStates.Rendered);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);

await fourthPage.scrollIntoViewIfNeeded();
await expectCanvasStateToBe(firstPage, CanvasStates.Rendered);
await expectCanvasStateToBe(secondPage, CanvasStates.Rendered);
await expectCanvasStateToBe(thirdPage, CanvasStates.Rendered);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);

await secondPage.scrollIntoViewIfNeeded();
await documents.waitForTimeout(500); // wait for the scroll animation to be done
await zoomIn.click(); // zoom in to force re-rendering
await expectPageToBeRendered(firstPage, false);
await expectPageToBeRendered(secondPage, false);
await expectPageToBeRendered(thirdPage, true);
await expectCanvasStateToBe(firstPage, CanvasStates.Blank);
await expectCanvasStateToBe(secondPage, CanvasStates.Rendered);
await expectCanvasStateToBe(thirdPage, CanvasStates.Blank);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);

await pagination.click();
await paginationElement.locator('input').fill('1');
await paginationElement.locator('input').press('Enter');
await expectPageToBeRendered(firstPage, true);
await expectPageToBeRendered(secondPage, true);
await expectPageToBeRendered(thirdPage, true);
await expectCanvasStateToBe(firstPage, CanvasStates.Rendered);
await expectCanvasStateToBe(secondPage, CanvasStates.Rendered);
await expectCanvasStateToBe(thirdPage, CanvasStates.Blank);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);

await documents.waitForTimeout(500); // wait for the scroll animation to be done
await zoomReset.click(); // reset zoom to force re-rendering
await expectPageToBeRendered(firstPage, true);
await expectPageToBeRendered(secondPage, false);
await expectPageToBeRendered(thirdPage, false);
await expectCanvasStateToBe(firstPage, CanvasStates.Rendered);
await expectCanvasStateToBe(secondPage, CanvasStates.Blank);
await expectCanvasStateToBe(thirdPage, CanvasStates.Blank);
await expectCanvasStateToBe(fourthPage, CanvasStates.Failed);
});

msTest('PDF viewer: fullscreen', async ({ documents }) => {
Expand Down
Loading