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

Experimental pdf exports v2 #639

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
3 changes: 2 additions & 1 deletion matrix-neoboard-widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"react-redux": "^9.1.2"
},
"resolutions": {
"fork-ts-checker-webpack-plugin": "^6.5.3"
"fork-ts-checker-webpack-plugin": "^6.5.3",
"@vitejs/plugin-react-swc": "git+https://github.com/vitejs/vite-plugin-react-swc.git"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required as we require vitejs/vite-plugin-react-swc@beb09db

},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/react-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@mui/lab": "^6.0.0-beta.10",
"@mui/material": "^6.1.2",
"@mui/utils": "^6.0.2",
"@react-pdf/renderer": "^4.1.4",
"@reduxjs/toolkit": "^2.2.7",
"emoji-regex": "^10.3.0",
"joi": "^17.13.3",
Expand Down Expand Up @@ -67,6 +68,7 @@
"@types/tinycolor2": "^1.4.6",
"@vitest/coverage-v8": "^2.1.4",
"axe-core": "^4.10.2",
"canvas": "^2.11.2",
"depcheck": "^1.4.7",
"eslint": "^8.57.1",
"i18next": "^23.15.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Dispatch, PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useActiveWhiteboardInstance } from '../../state';
import { useGetRoomNameQuery, useUserDetails } from '../../store';
import { createWhiteboardPdf } from './pdf';
import { createWhiteboardPdf } from './pdfv2';

export function ExportWhiteboardDialogDownloadPdf({
children,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
{
"absolutePosition": {
"x": 10,
"y": 51,
"y": 28.75,
},
"layout": "noBorders",
"table": {
Expand All @@ -52,7 +52,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
],
],
"heights": [
40,
62.25,
],
"widths": [
30,
Expand Down Expand Up @@ -86,7 +86,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
{
"absolutePosition": {
"x": 10,
"y": 51,
"y": 28.75,
},
"layout": "noBorders",
"table": {
Expand All @@ -106,7 +106,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
],
],
"heights": [
40,
62.25,
],
"widths": [
30,
Expand Down Expand Up @@ -138,7 +138,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
{
"absolutePosition": {
"x": 7.322330470336311,
"y": 51.14466094067262,
"y": 30.458869925047622,
},
"layout": "noBorders",
"table": {
Expand All @@ -162,7 +162,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
],
],
"heights": [
35.210678118654755,
55.896469134279755,
],
"widths": [
35.35533905932738,
Expand Down Expand Up @@ -194,7 +194,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
{
"absolutePosition": {
"x": 7.322330470336311,
"y": 51.14466094067262,
"y": 30.394660940672622,
},
"layout": "noBorders",
"table": {
Expand All @@ -204,7 +204,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
"alignment": "center",
"color": "#000",
"font": "Inter",
"fontSize": 10,
"fontSize": 13,
"lineHeight": 1,
"margin": 0,
"text": [
Expand All @@ -214,7 +214,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
],
],
"heights": [
35.210678118654755,
55.960678118654755,
],
"widths": [
35.35533905932738,
Expand Down Expand Up @@ -257,7 +257,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
{
"absolutePosition": {
"x": 8.333333333333332,
"y": 79.16666666666666,
"y": 70.48087565104166,
},
"layout": "noBorders",
"table": {
Expand All @@ -281,7 +281,7 @@ exports[`createWhiteboardPdfDefinition > should generate a pdf header and table
],
],
"heights": [
11.833333333333343,
20.519124348958343,
],
"widths": [
33.333333333333336,
Expand Down
57 changes: 54 additions & 3 deletions packages/react-sdk/src/components/BoardBar/pdf/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,68 @@ export function image(element: ImageElement, base64content: string): Content {
};
}

function preprocessSvg(element: ImageElement, svg: string): string {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Canvas requires there to be a width and height on the svg element. Otherwise it wont draw it.

if (element.mimeType !== 'image/svg+xml') {
return svg;
}

// Convert the svg base64 to the actual svg
const svgString = atob(svg);

// We lied in the past about the mimetype, so we need to check if the svg is actually an svg and otherwise return the original string
if (!svgString.includes('<svg')) {
element.mimeType = svgString.includes('PNG') ? 'image/png' : 'image/jpeg';
return svg;
}

// Parse the svg string
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');
const svgElement = doc.documentElement;

// Check if a size is set and if not set it to the size from the viewbox
if (!svgElement.getAttribute('width') || !svgElement.getAttribute('height')) {
const viewBox = svgElement.getAttribute('viewBox')?.split(' ');
if (viewBox) {
svgElement.setAttribute('width', viewBox[2]);
svgElement.setAttribute('height', viewBox[3]);
} else {
svgElement.setAttribute('width', element.width.toString());
svgElement.setAttribute('height', element.height.toString());
}
}

// Convert the svg element to a string
const serializer = new XMLSerializer();
const svgStringProcessed = serializer.serializeToString(svgElement);

// Convert the svg string to base64
return btoa(svgStringProcessed);
}

export function conv2png(element: ImageElement, base64content: string): string {
// If we have a svg image we need to preprocess it for the canvas to work
if (element.mimeType === 'image/svg+xml') {
base64content = preprocessSvg(element, base64content);
}

// If we now end up with a png or jpeg image we can return it early
if (element.mimeType === 'image/png' || element.mimeType === 'image/jpeg') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the short-circuit for files which lied about being an svg.

return base64content;
}

const img = new Image();
img.src = 'data:' + element.mimeType + ';base64,' + base64content;

const canvas = document.createElement('canvas');
[canvas.width, canvas.height] = [element.width, element.height];

const ctx = canvas.getContext('2d') ?? new CanvasRenderingContext2D();
ctx.drawImage(img, 0, 0, element.width, element.height);
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, element.width, element.height);

const dataUrl = canvas.toDataURL('image/png');

return canvas.toDataURL('image/png').split(',')[1];
return dataUrl.split(',')[1];
}

export function textContent(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright 2024 Nordeck IT + Consulting GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { WidgetApi } from '@matrix-widget-toolkit/api';
import { from, fromEvent, map, Observable, switchMap, take, tap } from 'rxjs';
import { WhiteboardDocumentExport, WhiteboardInstance } from '../../../state';
import { conv2png } from '../pdf/utils';

export function createWhiteboardPdf(params: {
whiteboardInstance: WhiteboardInstance;
roomName: string;
authorName: string;
widgetApi: WidgetApi;
}): Observable<Blob> {
const whiteboardExport = params.whiteboardInstance.export(params.widgetApi);

// Use local mode for tests
if (window.Worker && import.meta.env.MODE !== 'test') {
const worker = new Worker(new URL('./pdf.worker.ts', import.meta.url), {
type: 'module',
});
worker.onmessageerror = (e) => {
console.error('Worker error', e);
worker.terminate();
};

// Post the whiteboard instance to the worker and then return the blob that the worker sends back to us when it's done.
// We must return an observable that emits the blob and then completes.
// Ensure we also terminate the worker when we're done with it.
// Also note that we need the whiteboardExport which is a promise and the resolved value must be passed to the worker.
return from(whiteboardExport).pipe(
switchMap((exportData) => {
// Convert all images which are not png or jpeg to png
exportData = convertAllImagesToPNG(exportData);

// Pass the whiteboard export data to the worker and wait for the worker to send back the blob.
// If the worker sends back an error, we should throw it and stop the worker.
// If the worker sends back the blob, we should return it. We should also stop the worker.

// Create an observable that emits the blob when the worker sends it back.
const stream = fromEvent<MessageEvent>(worker, 'message');

if (import.meta.hot) {
// Delay the post message to the worker to ensure the worker is ready to receive it.
new Promise((resolve) => setTimeout(resolve, 2000))
.then(() => {
console.log('Posting message to worker');
worker.postMessage(exportData);
return;
})
.catch((e) => {
console.error('Error posting message to worker', e);
worker.terminate();
});
} else {
// Note that we cant directly return the stream as it needs to be Observable<Blob> and not Observable<MessageEvent<Blob>
console.log('Posting message to worker');
worker.postMessage(exportData);
}
return stream;
}),
map((e) => e.data),
take(1),
tap({
finalize: () => {
// terminate the worker
worker.terminate();
},
}),
);
} else {
// Async import pdf.local.ts and thn call renderPDF with the whiteboard export data which is a promise too.
return from(import('./pdf.local')).pipe(
switchMap(({ renderPDF }) =>
from(whiteboardExport).pipe(
switchMap((exportData) => {
// Convert all images which are not png or jpeg to png
exportData = convertAllImagesToPNG(exportData);
return renderPDF(exportData);
}),
),
),
take(1),
);
}
}

function convertAllImagesToPNG(
exportData: WhiteboardDocumentExport,
): WhiteboardDocumentExport {
// Convert all images which are not png or jpeg to png
exportData.whiteboard.slides.forEach((slide) => {
slide.elements.forEach((element) => {
if (element.type === 'image' && element.mimeType !== 'image/png') {
const file = exportData.whiteboard.files?.find(
(f) => f.mxc === element.mxc,
);
if (file) {
const data = conv2png(element, file.data);
file.data = data;
element.mimeType = 'image/png';
}
}
});
});

// Sanity check in debug mode. Report error if any image is not converted to png or jpeg.
if (import.meta.env.MODE === 'development') {
exportData.whiteboard.slides.forEach((slide) => {
slide.elements.forEach((element) => {
if (element.type === 'image' && element.mimeType !== 'image/png') {
console.error('Image not converted to png or jpeg', element);
}
});
});
}

return exportData;
}
Loading
Loading