From 7007d56c38dc6ba972babe8329831da6fc384cd6 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 13 Mar 2025 13:21:28 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20svg=20not=20rend?= =?UTF-8?q?ering=20export=20dox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The svg was not rendering in the dox export. We overwrite the default mapping to convert the svg to png before rendering. The images could be out of the page as well, we fixed this issue by adding a maxWidth to the image. --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-export.spec.ts | 13 ++ .../doc-export/blocks-mapping/imageDocx.tsx | 141 ++++++++++++++++++ .../docs/doc-export/blocks-mapping/index.ts | 1 + .../features/docs/doc-export/mappingDocx.tsx | 4 +- 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f28482..22c6d110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to ## Fixed +- 🐛(frontend) SVG export #706 - 🐛(frontend) remove scroll listener table content #688 - 🔒️(back) restrict access to favorite_list endpoint #690 - 🐛(backend) refactor to fix filtering on children diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 8b921ad7..9d452b34 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -98,6 +98,7 @@ test.describe('Doc Export', () => { test('it exports the doc to docx', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); + const fileChooserPromise = page.waitForEvent('filechooser'); const downloadPromise = page.waitForEvent('download', (download) => { return download.suggestedFilename().includes(`${randomDoc}.docx`); }); @@ -107,6 +108,18 @@ test.describe('Doc Export', () => { await page.locator('.ProseMirror.bn-editor').click(); await page.locator('.ProseMirror.bn-editor').fill('Hello World'); + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); + + const image = page.getByRole('img', { name: 'test.svg' }); + + await expect(image).toBeVisible(); + await page .getByRole('button', { name: 'download', diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx new file mode 100644 index 00000000..8fa3848e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx @@ -0,0 +1,141 @@ +import { + COLORS_DEFAULT, + DefaultProps, + UnreachableCaseError, +} from '@blocknote/core'; +import { + IParagraphOptions, + ImageRun, + Paragraph, + ShadingType, + TextRun, +} from 'docx'; + +import { DocsExporterDocx } from '../types'; +import { convertSvgToPng } from '../utils'; + +const MAX_WIDTH = 600; + +export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']['image'] = + async (block, exporter) => { + const blob = await exporter.resolveFile(block.props.url); + let pngConverted: string | undefined; + let dimensions: { width: number; height: number } | undefined; + + if (blob.type.includes('svg')) { + const svgText = await blob.text(); + pngConverted = await convertSvgToPng(svgText); + const img = new Image(); + img.src = pngConverted; + await new Promise((resolve) => { + img.onload = () => { + dimensions = { width: img.width, height: img.height }; + resolve(null); + }; + }); + } else { + dimensions = await getImageDimensions(blob); + } + + if (!dimensions) { + return []; + } + + const { width, height } = dimensions; + + let previewWidth = block.props.previewWidth; + if (previewWidth > MAX_WIDTH) { + previewWidth = MAX_WIDTH; + } + + return [ + new Paragraph({ + ...blockPropsToStyles(block.props, exporter.options.colors), + children: [ + new ImageRun({ + data: pngConverted + ? await (await fetch(pngConverted)).arrayBuffer() + : await blob.arrayBuffer(), + type: pngConverted ? 'png' : 'gif', + altText: block.props.caption + ? { + description: block.props.caption, + name: block.props.caption, + title: block.props.caption, + } + : undefined, + transformation: { + width: previewWidth, + height: (previewWidth / width) * height, + }, + }), + ], + }), + ...caption(block.props, exporter as DocsExporterDocx), + ]; + }; + +async function getImageDimensions(blob: Blob) { + if (typeof window !== 'undefined') { + const bmp = await createImageBitmap(blob); + const { width, height } = bmp; + bmp.close(); + return { width, height }; + } +} + +function blockPropsToStyles( + props: Partial, + colors: typeof COLORS_DEFAULT, +): IParagraphOptions { + return { + shading: + props.backgroundColor === 'default' || !props.backgroundColor + ? undefined + : { + type: ShadingType.SOLID, + color: + colors[ + props.backgroundColor as keyof typeof colors + ].background.slice(1), + }, + run: + props.textColor === 'default' || !props.textColor + ? undefined + : { + color: colors[props.textColor as keyof typeof colors].text.slice(1), + }, + alignment: + !props.textAlignment || props.textAlignment === 'left' + ? undefined + : props.textAlignment === 'center' + ? 'center' + : props.textAlignment === 'right' + ? 'right' + : props.textAlignment === 'justify' + ? 'distribute' + : (() => { + throw new UnreachableCaseError(props.textAlignment); + })(), + }; +} + +function caption( + props: Partial, + exporter: DocsExporterDocx, +) { + if (!props.caption) { + return []; + } + return [ + new Paragraph({ + ...blockPropsToStyles(props, exporter.options.colors), + children: [ + new TextRun({ + text: props.caption, + }), + ], + style: 'Caption', + }), + ]; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts index 69eb7da2..5cfbb48f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts @@ -1,6 +1,7 @@ export * from './dividerDocx'; export * from './dividerPDF'; export * from './headingPDF'; +export * from './imageDocx'; export * from './imagePDF'; export * from './paragraphPDF'; export * from './quoteDocx'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 79bb1150..44944692 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -2,8 +2,9 @@ import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter'; import { blockMappingDividerDocx, + blockMappingImageDocx, blockMappingQuoteDocx, -} from './blocks-mapping/'; +} from './blocks-mapping'; import { DocsExporterDocx } from './types'; export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { @@ -12,5 +13,6 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { ...docxDefaultSchemaMappings.blockMapping, divider: blockMappingDividerDocx, quote: blockMappingQuoteDocx, + image: blockMappingImageDocx, }, };