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, }, };