From a0eeed0cdb58b0e1a4baa187c469bdc1ec3feb5f Mon Sep 17 00:00:00 2001 From: Xun Sun Date: Sat, 13 Dec 2025 18:05:25 +0800 Subject: [PATCH] feat: implement export as PDF (#14057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I used [pdfmake](https://www.npmjs.com/package/pdfmake) to implement an "export as PDF" feature, and I am happy to share with you! This should fix #13577, fix #8846, and fix #13959. A showcase: [Getting Started.pdf](https://github.com/user-attachments/files/24013057/Getting.Started.pdf) Although it might miss rendering some properties currently, it can evolve in the long run and provide a more native experience for the users. ## Summary by CodeRabbit * **New Features** - Experimental "Export to PDF" option added to the export menu (behind a feature flag) - PDF export supports headings, paragraphs, lists, code blocks, tables, images, callouts, linked documents and embedded content * **Chores** - Added PDF rendering library and consolidated PDF utilities - Feature flag introduced to control rollout * **Tests** - Comprehensive unit tests added for PDF content rendering logic ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: DarkSky --- .../src/__tests__/adapters/pdf.unit.spec.ts | 1622 +++++++++++++++++ .../blocks/list/src/utils/get-list-icon.ts | 3 +- .../affine/blocks/table/src/table-cell.ts | 1 + blocksuite/affine/shared/package.json | 2 + .../affine/shared/src/adapters/index.ts | 1 + .../shared/src/adapters/pdf/css-utils.ts | 25 + .../src/adapters/pdf/delta-converter.ts | 122 ++ .../shared/src/adapters/pdf/image-utils.ts | 114 ++ .../affine/shared/src/adapters/pdf/index.ts | 6 + .../affine/shared/src/adapters/pdf/pdf.ts | 1004 ++++++++++ .../shared/src/adapters/pdf/svg-utils.ts | 42 + .../affine/shared/src/adapters/pdf/utils.ts | 71 + .../src/services/feature-flag-service.ts | 2 + blocksuite/affine/shared/src/utils/index.ts | 1 + .../src/utils/number-prefix.ts} | 11 +- .../linked-doc/src/transformers/index.ts | 1 + .../linked-doc/src/transformers/pdf.ts | 32 + .../components/use-user-management.ts | 1 + .../ai/components/playground/content.ts | 2 + .../frontend/core/src/bootstrap/cleanup.ts | 41 +- .../hooks/affine/use-export-page.ts | 13 +- .../page-list/operation-menu-items/export.tsx | 18 +- .../integration/mcp-server/setting-panel.tsx | 1 + .../core/src/modules/feature-flag/constant.ts | 9 + .../web/components/saved-recording-item.tsx | 1 + tests/kit/src/utils/keyboard.ts | 1 + yarn.lock | 382 ++-- 27 files changed, 3391 insertions(+), 138 deletions(-) create mode 100644 blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/css-utils.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/delta-converter.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/image-utils.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/index.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/pdf.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/svg-utils.ts create mode 100644 blocksuite/affine/shared/src/adapters/pdf/utils.ts rename blocksuite/affine/{blocks/list/src/utils/get-number-prefix.ts => shared/src/utils/number-prefix.ts} (85%) create mode 100644 blocksuite/affine/widgets/linked-doc/src/transformers/pdf.ts diff --git a/blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts new file mode 100644 index 0000000000..185885ba4c --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts @@ -0,0 +1,1622 @@ +import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model'; +import { PdfAdapter } from '@blocksuite/affine-shared/adapters'; +import type { BlockSnapshot, DocSnapshot } from '@blocksuite/store'; +import { AssetsManager, MemoryBlobCRUD } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { createJob } from '../utils/create-job.js'; +import { getProvider } from '../utils/get-provider.js'; + +const provider = getProvider(); + +// Helper function to create a base snapshot structure +function createBaseSnapshot( + children: BlockSnapshot['children'] +): BlockSnapshot { + return { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children, + }, + ], + }; +} + +describe('snapshot to pdf', () => { + test('paragraph', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello World', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + expect(Array.isArray(definition.content)).toBe(true); + const content = definition.content as any[]; + expect(content.length).toBeGreaterThan(0); + + // Find the paragraph content + const paragraphContent = content.find( + (item: any) => + item.text === 'Hello World' || + (Array.isArray(item.text) && item.text.includes('Hello World')) + ); + expect(paragraphContent).toBeDefined(); + }); + + test('code block', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:code', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'print("Hello")', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // Find code block table + const codeBlock = content.find( + (item: any) => item.table && item.table.body + ); + expect(codeBlock).toBeDefined(); + expect(codeBlock.table.body).toBeDefined(); + }); + + test('list items', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:list1', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Item 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:list2', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Item 2', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // Find list items (they should be tables with 2 columns) + const listItems = content.filter( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 2 + ); + expect(listItems.length).toBeGreaterThanOrEqual(2); + }); + + test('header', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:header', + flavour: 'affine:paragraph', + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Heading 1', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // Find header content + const header = content.find( + (item: any) => + item.style === 'header1' || + (item.text && + (item.text === 'Heading 1' || + (Array.isArray(item.text) && item.text.includes('Heading 1')))) + ); + expect(header).toBeDefined(); + if (header.style) { + expect(header.style).toBe('header1'); + } + }); + + test('document with title', async () => { + const docSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'testDocument', + title: 'Test Document', + createDate: 1718225423102, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }, + ], + }, + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [docSnapshot.blocks], + docSnapshot.meta?.title + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // First item should be the title + expect(content[0].text).toBe('Test Document'); + expect(content[0].style).toBe('title'); + }); + + test('styles definition', async () => { + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition([], undefined); + + expect(definition.styles).toBeDefined(); + expect(definition.styles?.title).toBeDefined(); + expect(definition.styles?.header1).toBeDefined(); + expect(definition.styles?.header2).toBeDefined(); + expect(definition.styles?.header3).toBeDefined(); + expect(definition.styles?.header4).toBeDefined(); + expect(definition.styles?.code).toBeDefined(); + + expect(definition.defaultStyle).toBeDefined(); + expect(definition.defaultStyle?.font).toBe('Roboto'); + }); + + describe('inline text styling', () => { + test('bold text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Bold text', + attributes: { bold: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => typeof t === 'object' && t.bold === true + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Bold text'); + } + }); + + test('italic text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Italic text', + attributes: { italic: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => typeof t === 'object' && t.italics === true + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Italic text'); + } + }); + + test('underline text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Underlined text', + attributes: { underline: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => + typeof t === 'object' && + (t.decoration === 'underline' || + (Array.isArray(t.decoration) && + t.decoration.includes('underline'))) + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Underlined text'); + } + }); + + test('strikethrough text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Strikethrough text', + attributes: { strike: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => + typeof t === 'object' && + (t.decoration === 'lineThrough' || + (Array.isArray(t.decoration) && + t.decoration.includes('lineThrough'))) + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Strikethrough text'); + } + }); + + test('inline code', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'code', + attributes: { code: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const codeText = textContent.text.find( + (t: any) => + typeof t === 'object' && + t.font === 'Roboto' && + t.background === '#f5f5f5' + ); + expect(codeText).toBeDefined(); + expect(codeText.text).toContain('code'); + } + }); + + test('combined styles', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Bold and italic', + attributes: { bold: true, italic: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => + typeof t === 'object' && t.bold === true && t.italics === true + ); + expect(styledText).toBeDefined(); + } + }); + }); + + describe('links and references', () => { + test('link attribute', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Click here', + attributes: { link: 'https://example.com' }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const linkText = textContent.text.find( + (t: any) => typeof t === 'object' && t.link === 'https://example.com' + ); + expect(linkText).toBeDefined(); + expect(linkText.color).toBe('#0066cc'); + } + }); + + test('linked page reference - found', async () => { + const job = createJob(); + const pdfAdapter = new PdfAdapter(job, provider); + pdfAdapter.configs.set('title:page123', 'Referenced Page'); + pdfAdapter.configs.set('docLinkBaseUrl', 'https://example.com/doc'); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'page123', + }, + }, + }, + ], + }, + }, + children: [], + }, + ]); + + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const refText = textContent.text.find( + (t: any) => + typeof t === 'object' && + t.text === 'Referenced Page' && + t.link === 'https://example.com/doc/page123' + ); + expect(refText).toBeDefined(); + expect(refText.color).toBe('#0066cc'); + } + }); + + test('linked page reference - not found', async () => { + const job = createJob(); + const pdfAdapter = new PdfAdapter(job, provider); + pdfAdapter.configs.set('docLinkBaseUrl', 'https://example.com/doc'); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'nonexistent', + }, + }, + }, + ], + }, + }, + children: [], + }, + ]); + + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const refText = textContent.text.find( + (t: any) => + typeof t === 'object' && + t.text === 'Page not found' && + Array.isArray(t.decoration) && + t.decoration.includes('lineThrough') + ); + expect(refText).toBeDefined(); + } + }); + }); + + describe('quote blocks', () => { + test('quote block with text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:quote', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'This is a quote', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const quoteBlock = content.find( + (item: any) => + item.table && + item.table.widths && + item.table.widths.length === 2 && + item.table.widths[0] === 2 + ); + expect(quoteBlock).toBeDefined(); + expect(quoteBlock.table.body[0][0].fillColor).toBe('#cccccc'); + }); + + test('quote block with children', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:quote', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Quote text', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:child', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Child paragraph', + }, + ], + }, + }, + children: [], + }, + ], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const quoteBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 2 + ); + expect(quoteBlock).toBeDefined(); + expect(quoteBlock.table.body[0][1].stack).toBeDefined(); + }); + }); + + describe('callout blocks', () => { + test('callout with default background', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:callout', + flavour: 'affine:callout', + props: { + backgroundColorName: 'grey', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Callout content', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const calloutBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 1 + ); + expect(calloutBlock).toBeDefined(); + expect(calloutBlock.table.body[0][0].fillColor).toBeDefined(); + }); + + test('callout with custom background color', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:callout', + flavour: 'affine:callout', + props: { + backgroundColorName: 'blue', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Blue callout', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const calloutBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 1 + ); + expect(calloutBlock).toBeDefined(); + expect(calloutBlock.table.body[0][0].fillColor).toBeDefined(); + }); + + test('callout with children', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:callout', + flavour: 'affine:callout', + props: { + backgroundColorName: 'grey', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Callout', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:child', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Child content', + }, + ], + }, + }, + children: [], + }, + ], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const calloutBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 1 + ); + expect(calloutBlock).toBeDefined(); + expect(calloutBlock.table.body[0][0].stack).toBeDefined(); + }); + }); + + describe('image handling', () => { + test('image without sourceId', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: undefined, + caption: 'Test caption', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const imageContent = content.find( + (item: any) => + item.text && + (item.text === '[Image: Test caption]' || item.text.includes('Image')) + ); + expect(imageContent).toBeDefined(); + expect(imageContent.italics).toBe(true); + }); + + test('SVG image', async () => { + const blobCRUD = new MemoryBlobCRUD(); + const svgBlob = new Blob( + [ + '', + ], + { + type: 'image/svg+xml', + } + ); + const blobId = await blobCRUD.set(svgBlob); + const assets = new AssetsManager({ blob: blobCRUD }); + await assets.readFromBlob(blobId); + assets.getAssets().set(blobId, svgBlob); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: blobId, + caption: 'SVG Image', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined, + assets + ); + + const content = definition.content as any[]; + const svgContent = content.find((item: any) => item.svg); + expect(svgContent).toBeDefined(); + expect(svgContent.svg).toContain(' { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: 'nonexistent-blob-id', + caption: 'Missing image', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const fallbackContent = content.find( + (item: any) => + item.text && + (item.text === '[Image: Missing image]' || + item.text.includes('Image')) + ); + expect(fallbackContent).toBeDefined(); + }); + + test('image with width and height', async () => { + const blobCRUD = new MemoryBlobCRUD(); + const svgBlob = new Blob( + [ + '', + ], + { + type: 'image/svg+xml', + } + ); + const blobId = await blobCRUD.set(svgBlob); + const assets = new AssetsManager({ blob: blobCRUD }); + await assets.readFromBlob(blobId); + assets.getAssets().set(blobId, svgBlob); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: blobId, + width: 300, + height: 200, + caption: 'Sized image', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined, + assets + ); + + const content = definition.content as any[]; + const svgContent = content.find((item: any) => item.svg); + expect(svgContent).toBeDefined(); + expect(svgContent.width).toBeDefined(); + expect(svgContent.height).toBeDefined(); + }); + + test('image text alignment', async () => { + const blobCRUD = new MemoryBlobCRUD(); + const svgBlob = new Blob([''], { + type: 'image/svg+xml', + }); + const blobId = await blobCRUD.set(svgBlob); + const assets = new AssetsManager({ blob: blobCRUD }); + await assets.readFromBlob(blobId); + assets.getAssets().set(blobId, svgBlob); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: blobId, + textAlign: 'right', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined, + assets + ); + + const content = definition.content as any[]; + const svgContent = content.find((item: any) => item.svg); + expect(svgContent).toBeDefined(); + expect(svgContent.alignment).toBe('right'); + }); + }); + + describe('table rendering', () => { + test('table with cells', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:table', + flavour: 'affine:table', + props: { + columns: { + col1: { + columnId: 'col1', + order: 'a0', + }, + col2: { + columnId: 'col2', + order: 'a1', + }, + }, + rows: { + row1: { + rowId: 'row1', + order: 'a0', + }, + row2: { + rowId: 'row2', + order: 'a1', + }, + }, + cells: { + 'row1:col1': { + text: { + delta: [{ insert: 'Cell 1-1' }], + }, + }, + 'row1:col2': { + text: { + delta: [{ insert: 'Cell 1-2' }], + }, + }, + 'row2:col1': { + text: { + delta: [{ insert: 'Cell 2-1' }], + }, + }, + 'row2:col2': { + text: { + delta: [{ insert: 'Cell 2-2' }], + }, + }, + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const table = content.find( + (item: any) => + item.table && item.table.body && Array.isArray(item.table.body) + ); + expect(table).toBeDefined(); + expect(table.table.body.length).toBe(2); + expect(table.table.body[0].length).toBe(2); + }); + + test('empty table', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:table', + flavour: 'affine:table', + props: { + columns: {}, + rows: {}, + cells: {}, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const table = content.find((item: any) => item.table && item.table.body); + expect(table).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + test('empty paragraph', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + expect(content.length).toBeGreaterThan(0); + }); + + test('paragraph with empty delta', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: '' }], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + }); + + test('malformed delta operations', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { insert: 123 as any }, // Invalid type + { insert: 'Valid text' }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + }); + + test('text alignment', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + textAlign: 'center', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Centered text', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const paragraph = content.find( + (item: any) => + item.text && + (item.text === 'Centered text' || + (Array.isArray(item.text) && + item.text.some((t: any) => + typeof t === 'string' + ? t === 'Centered text' + : t.text === 'Centered text' + ))) + ); + expect(paragraph).toBeDefined(); + expect(paragraph.alignment).toBe('center'); + }); + + test('nested list items', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:list1', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Parent item', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:list2', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Child item', + }, + ], + }, + }, + children: [], + }, + ], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + const listItems = content.filter( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 2 + ); + expect(listItems.length).toBeGreaterThanOrEqual(2); + }); + + test('numbered list with order', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:list1', + flavour: 'affine:list', + props: { + type: 'numbered', + order: 5, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Item 5', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const listItem = content.find( + (item: any) => + item.table && + item.table.body && + item.table.body[0] && + item.table.body[0][0] && + item.table.body[0][0].text + ); + expect(listItem).toBeDefined(); + expect(listItem.table.body[0][0].text).toContain('5.'); + }); + + test('todo list checked', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:todo', + flavour: 'affine:list', + props: { + type: 'todo', + checked: true, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Completed task', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const todoItem = content.find( + (item: any) => + item.table && + item.table.body && + item.table.body[0] && + item.table.body[0][0] && + item.table.body[0][0].svg + ); + expect(todoItem).toBeDefined(); + expect(todoItem.table.body[0][0].svg).toContain('svg'); + }); + }); +}); diff --git a/blocksuite/affine/blocks/list/src/utils/get-list-icon.ts b/blocksuite/affine/blocks/list/src/utils/get-list-icon.ts index 4de1bb39b0..c30f427e17 100644 --- a/blocksuite/affine/blocks/list/src/utils/get-list-icon.ts +++ b/blocksuite/affine/blocks/list/src/utils/get-list-icon.ts @@ -1,4 +1,5 @@ import type { ListBlockModel } from '@blocksuite/affine-model'; +import { getNumberPrefix } from '@blocksuite/affine-shared/utils'; import { BulletedList01Icon, BulletedList02Icon, @@ -11,8 +12,6 @@ import { } from '@blocksuite/icons/lit'; import { html } from 'lit'; -import { getNumberPrefix } from './get-number-prefix.js'; - const getListDeep = (model: ListBlockModel): number => { let deep = 0; let parent = model.store.getParent(model); diff --git a/blocksuite/affine/blocks/table/src/table-cell.ts b/blocksuite/affine/blocks/table/src/table-cell.ts index f54319dbb2..a13a987fab 100644 --- a/blocksuite/affine/blocks/table/src/table-cell.ts +++ b/blocksuite/affine/blocks/table/src/table-cell.ts @@ -418,6 +418,7 @@ export class TableCell extends SignalWatcher( name: 'Paste', prefix: PasteIcon(), select: () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises navigator.clipboard.readText().then(text => { this.selectionController.doPaste(text, selected); }); diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index 0693687733..82a44d4cbc 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -40,6 +40,7 @@ "micromark-extension-gfm-task-list-item": "^2.1.0", "micromark-util-combine-extensions": "^2.0.0", "minimatch": "^10.1.1", + "pdfmake": "^0.2.20", "quick-lru": "^7.3.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", @@ -73,6 +74,7 @@ "!dist/__tests__" ], "devDependencies": { + "@types/pdfmake": "^0.2.12", "vitest": "^3.2.4" }, "version": "0.25.7" diff --git a/blocksuite/affine/shared/src/adapters/index.ts b/blocksuite/affine/shared/src/adapters/index.ts index dab76bd0f3..1c9333a2d5 100644 --- a/blocksuite/affine/shared/src/adapters/index.ts +++ b/blocksuite/affine/shared/src/adapters/index.ts @@ -61,6 +61,7 @@ export { NotionHtmlDeltaConverter, } from './notion-html'; export * from './notion-text'; +export { PdfAdapter } from './pdf'; export { BlockPlainTextAdapterExtension, type BlockPlainTextAdapterMatcher, diff --git a/blocksuite/affine/shared/src/adapters/pdf/css-utils.ts b/blocksuite/affine/shared/src/adapters/pdf/css-utils.ts new file mode 100644 index 0000000000..324073b34a --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/css-utils.ts @@ -0,0 +1,25 @@ +/** + * Resolve CSS variable color (var(--affine-xxx)) using computed styles + */ +export function resolveCssVariable(color: string): string | null { + if (!color || typeof color !== 'string') { + return null; + } + if (!color.startsWith('var(')) { + return color; + } + if (typeof document === 'undefined') { + return null; + } + const rootComputedStyle = getComputedStyle(document.documentElement); + const match = color.match(/var\(([^)]+)\)/); + if (!match || !match[1]) { + return null; + } + const variable = match[1].trim(); + if (!variable.startsWith('--')) { + return null; + } + const value = rootComputedStyle.getPropertyValue(variable).trim(); + return value || null; +} diff --git a/blocksuite/affine/shared/src/adapters/pdf/delta-converter.ts b/blocksuite/affine/shared/src/adapters/pdf/delta-converter.ts new file mode 100644 index 0000000000..1a993e0bae --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/delta-converter.ts @@ -0,0 +1,122 @@ +/** + * Delta to PDF content converter + */ + +import { resolveCssVariable } from './css-utils.js'; + +/** + * Extract text from delta operations, preserving inline properties + * Returns normalized format: string if simple, array if complex (with inline styles) + */ +export function extractTextWithInline( + props: Record, + configs: Map +): string | Array { + const delta = props?.text?.delta; + if (!Array.isArray(delta)) { + return ' '; + } + + const result: Array = []; + + for (const op of delta) { + if (typeof op.insert !== 'string') { + continue; + } + + const text = op.insert; + const attrs = op.attributes; + + if (!attrs || Object.keys(attrs).length === 0) { + result.push(text); + continue; + } + + const styleObj: { text: string; [key: string]: any } = { text }; + + if (attrs.bold === true) { + styleObj.bold = true; + } + if (attrs.italic === true) { + styleObj.italics = true; + } + const decorations: string[] = []; + if (attrs.strike === true) { + decorations.push('lineThrough'); + } + if (attrs.underline === true) { + decorations.push('underline'); + } + if (decorations.length > 0) { + styleObj.decoration = decorations; + } + if (attrs.code === true) { + styleObj.font = 'Inter'; + styleObj.background = '#f5f5f5'; + styleObj.fontSize = 10; + styleObj.text = ' ' + text + ' '; + } + if (attrs.color && typeof attrs.color === 'string') { + const resolved = resolveCssVariable(attrs.color); + if (resolved) { + styleObj.color = resolved; + } + } + if ( + attrs.background && + typeof attrs.background === 'string' && + !attrs.code + ) { + const resolvedBg = resolveCssVariable(attrs.background); + if (resolvedBg) { + styleObj.background = resolvedBg; + } + } + if (attrs.link) { + styleObj.link = attrs.link; + styleObj.color = '#0066cc'; + } + if (attrs.reference) { + const ref = attrs.reference; + if (ref.type === 'LinkedPage' || ref.type === 'Subpage') { + const docLinkBaseUrl = configs.get('docLinkBaseUrl') || ''; + const linkUrl = docLinkBaseUrl ? `${docLinkBaseUrl}/${ref.pageId}` : ''; + + const pageTitle = configs.get('title:' + ref.pageId); + const isPageFound = pageTitle !== undefined; + const displayTitle = pageTitle || 'Page not found'; + + if (!text || text.trim() === '' || text === ' ') { + styleObj.text = displayTitle; + } + styleObj.color = '#0066cc'; + if (!isPageFound && styleObj.decoration) { + if (!Array.isArray(styleObj.decoration)) { + styleObj.decoration = [styleObj.decoration]; + } + if (!styleObj.decoration.includes('lineThrough')) { + styleObj.decoration.push('lineThrough'); + } + } + if (linkUrl) { + styleObj.link = linkUrl; + } + } + } + if (attrs.latex) { + styleObj.text = attrs.latex; + styleObj.italics = true; + styleObj.color = '#666666'; + } + + result.push(styleObj); + } + + if (result.length === 0) { + return ' '; + } + if (result.length === 1 && typeof result[0] === 'string') { + return result[0] || ' '; + } + return result; +} diff --git a/blocksuite/affine/shared/src/adapters/pdf/image-utils.ts b/blocksuite/affine/shared/src/adapters/pdf/image-utils.ts new file mode 100644 index 0000000000..6e693a988f --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/image-utils.ts @@ -0,0 +1,114 @@ +/** + * Image dimension utilities + */ + +import { MAX_PAPER_HEIGHT, MAX_PAPER_WIDTH } from './utils.js'; + +/** + * Calculate image dimensions respecting props, original size, and paper constraints + */ +export function calculateImageDimensions( + blockWidth: number | undefined, + blockHeight: number | undefined, + originalWidth: number | undefined, + originalHeight: number | undefined +): { width?: number; height?: number } { + let targetWidth = + blockWidth && blockWidth > 0 + ? blockWidth + : originalWidth && originalWidth > 0 + ? originalWidth + : undefined; + + let targetHeight = + blockHeight && blockHeight > 0 + ? blockHeight + : originalHeight && originalHeight > 0 + ? originalHeight + : undefined; + + if (!targetWidth && !targetHeight) { + return {}; + } + + if (targetWidth && targetWidth > MAX_PAPER_WIDTH) { + const ratio = MAX_PAPER_WIDTH / targetWidth; + targetWidth = MAX_PAPER_WIDTH; + if (targetHeight) { + targetHeight = targetHeight * ratio; + } + } + + if (targetHeight && targetHeight > MAX_PAPER_HEIGHT) { + const ratio = MAX_PAPER_HEIGHT / targetHeight; + targetHeight = MAX_PAPER_HEIGHT; + if (targetWidth) { + targetWidth = targetWidth * ratio; + } + } + + return { + width: targetWidth, + height: targetHeight, + }; +} + +/** + * Extract dimensions from SVG + */ +export function extractSvgDimensions(svgText: string): { + width?: number; + height?: number; +} { + const widthMatch = svgText.match(/width\s*=\s*["']?(\d+(?:\.\d+)?)/i); + const heightMatch = svgText.match(/height\s*=\s*["']?(\d+(?:\.\d+)?)/i); + const viewBoxMatch = svgText.match( + /viewBox\s*=\s*["']?\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/i + ); + + let width: number | undefined; + let height: number | undefined; + + if (widthMatch) { + width = parseFloat(widthMatch[1]); + } + if (heightMatch) { + height = parseFloat(heightMatch[1]); + } + + if ((!width || !height) && viewBoxMatch) { + const viewBoxWidth = parseFloat(viewBoxMatch[1]); + const viewBoxHeight = parseFloat(viewBoxMatch[2]); + if (!width) width = viewBoxWidth; + if (!height) height = viewBoxHeight; + } + + return { width, height }; +} + +/** + * Extract dimensions from JPEG/PNG using Image API + */ +export async function extractImageDimensions( + blob: Blob +): Promise<{ width?: number; height?: number }> { + return new Promise(resolve => { + const img = new Image(); + const url = URL.createObjectURL(blob); + const timeout = setTimeout(() => { + URL.revokeObjectURL(url); + resolve({}); + }, 5000); + img.onload = () => { + clearTimeout(timeout); + URL.revokeObjectURL(url); + resolve({ width: img.width, height: img.height }); + }; + img.onerror = () => { + clearTimeout(timeout); + URL.revokeObjectURL(url); + resolve({}); + }; + img.src = url; + }); +} diff --git a/blocksuite/affine/shared/src/adapters/pdf/index.ts b/blocksuite/affine/shared/src/adapters/pdf/index.ts new file mode 100644 index 0000000000..eb96f29e16 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/index.ts @@ -0,0 +1,6 @@ +export * from './css-utils.js'; +export * from './delta-converter.js'; +export * from './image-utils.js'; +export * from './pdf.js'; +export * from './svg-utils.js'; +export * from './utils.js'; diff --git a/blocksuite/affine/shared/src/adapters/pdf/pdf.ts b/blocksuite/affine/shared/src/adapters/pdf/pdf.ts new file mode 100644 index 0000000000..f16f32372b --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/pdf.ts @@ -0,0 +1,1004 @@ +import type { + TableCellSerialized, + TableColumn, + TableRow, +} from '@blocksuite/affine-model'; +import type { ServiceProvider } from '@blocksuite/global/di'; +import { + BaseAdapter, + type BlockSnapshot, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, + type ToSliceSnapshotPayload, + type Transformer, +} from '@blocksuite/store'; +import DOMPurify from 'dompurify'; +import pdfMake from 'pdfmake/build/pdfmake'; +import type { + Content, + ContentText, + TDocumentDefinitions, +} from 'pdfmake/interfaces'; + +import { getNumberPrefix } from '../../utils'; +import { resolveCssVariable } from './css-utils.js'; +import { extractTextWithInline } from './delta-converter.js'; +import { + calculateImageDimensions, + extractImageDimensions, + extractSvgDimensions, +} from './image-utils.js'; +import { + getBulletIconSvg, + getCheckboxIconSvg, + getToggleIconSvg, +} from './svg-utils.js'; +import { + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, + getImagePlaceholder, + hasTextContent, + PDF_COLORS, + TABLE_LAYOUT_NO_BORDERS, + textContentToString, +} from './utils.js'; + +pdfMake.fonts = { + Inter: { + normal: 'https://cdn.affine.pro/fonts/Inter-Regular.woff', + bold: 'https://cdn.affine.pro/fonts/Inter-SemiBold.woff', + italics: 'https://cdn.affine.pro/fonts/Inter-Italic.woff', + bolditalics: 'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff', + }, + SarasaGothicCL: { + normal: 'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf', + bold: 'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf', + italics: 'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf', + bolditalics: 'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf', + }, +}; + +export type PdfAdapterFile = { + blob: Blob; + fileName: string; +}; + +/** + * PDF export adapter using pdfmake library. + * + * This adapter converts BlockSuite documents to PDF format. It is export-only + * and does not support importing from PDF. + * + * @example + * ```typescript + * const adapter = new PdfAdapter(job, provider); + * const result = await adapter.fromDocSnapshot({ snapshot, assets }); + * download(result.file.blob, result.file.fileName); + * ``` + */ +export class PdfAdapter extends BaseAdapter { + constructor(job: Transformer, provider: ServiceProvider) { + super(job, provider); + } + + async fromBlockSnapshot({ + snapshot, + assets, + }: FromBlockSnapshotPayload): Promise< + FromBlockSnapshotResult + > { + const content = await this._buildContent([snapshot], assets); + const definition = this._createDocDefinition(undefined, content); + const blob = await this._createPdfBlob(definition); + return { + file: { + blob, + fileName: 'block.pdf', + }, + assetsIds: [], + }; + } + + async fromDocSnapshot({ + snapshot, + assets, + }: FromDocSnapshotPayload): Promise> { + const content = await this._buildContent([snapshot.blocks], assets); + const definition = this._createDocDefinition(snapshot.meta?.title, content); + const blob = await this._createPdfBlob(definition); + return { + file: { + blob, + fileName: `${snapshot.meta?.title || 'Untitled'}.pdf`, + }, + assetsIds: [], + }; + } + + async fromSliceSnapshot({ + snapshot, + assets, + }: FromSliceSnapshotPayload): Promise< + FromSliceSnapshotResult + > { + const content = await this._buildContent(snapshot.content, assets); + const definition = this._createDocDefinition(undefined, content); + const blob = await this._createPdfBlob(definition); + return { + file: { + blob, + fileName: 'slice.pdf', + }, + assetsIds: [], + }; + } + + toBlockSnapshot( + _payload: ToBlockSnapshotPayload + ): BlockSnapshot { + throw new Error('PdfAdapter does not support importing blocks from PDF.'); + } + + toDocSnapshot(_payload: ToDocSnapshotPayload): DocSnapshot { + throw new Error('PdfAdapter does not support importing docs from PDF.'); + } + + toSliceSnapshot( + _payload: ToSliceSnapshotPayload + ): SliceSnapshot | null { + throw new Error('PdfAdapter does not support importing slices from PDF.'); + } + + /** + * Get the pdfmake document definition (for testing purposes) + */ + async getDocDefinition( + blocks: BlockSnapshot[], + title?: string, + assets?: FromDocSnapshotPayload['assets'] + ): Promise { + const content = await this._buildContent(blocks, assets); + return this._createDocDefinition(title, content); + } + + private async _buildContent( + blocks: BlockSnapshot[], + assets?: FromDocSnapshotPayload['assets'] + ): Promise { + const content: Content[] = []; + for (const block of blocks) { + const blockContent = await this._blockToContent(block, assets, 0, 0, 0); + content.push(...blockContent); + } + return content; + } + + private async _blockToContent( + block: BlockSnapshot, + assets?: FromDocSnapshotPayload['assets'], + depth: number = 0, + listNestingLevel: number = 0, + parentTextStart: number = 0 + ): Promise { + const content: Content[] = []; + const flavour = block.flavour; + const props = block.props as Record; + const textContent = extractTextWithInline(props, this.configs); + + const baseIndent = + parentTextStart > 0 + ? parentTextStart + : depth * BLOCK_CHILDREN_CONTAINER_PADDING_LEFT; + + if (flavour === 'affine:paragraph') { + content.push( + ...(await this._createParagraphContent( + props, + textContent, + baseIndent, + block, + assets, + depth + )) + ); + } else if (flavour === 'affine:list') { + content.push( + ...(await this._createListContent( + props, + textContent, + baseIndent, + listNestingLevel, + block + )) + ); + } else if (flavour === 'affine:code') { + content.push(...this._createCodeContent(props, textContent, baseIndent)); + } else if (flavour === 'affine:divider') { + content.push({ + canvas: [ + { + type: 'line', + x1: 0, + y1: 0, + x2: 515, + y2: 0, + lineWidth: 1, + lineColor: PDF_COLORS.border, + }, + ], + margin: [0, 10, 0, 10], + }); + } else if (flavour === 'affine:callout') { + const calloutContent = await this._createCalloutContent( + props, + textContent, + baseIndent, + block, + assets, + depth + ); + content.push(...calloutContent); + return content; + } else if (flavour === 'affine:bookmark') { + content.push({ + text: props.title || props.url || '', + link: props.url, + color: PDF_COLORS.link, + margin: [0, 2, 0, 2], + }); + } else if (flavour === 'affine:image') { + const imageContent = await this._createImageContent( + props.sourceId, + props.caption || '', + assets, + props.textAlign || 'center', + props.width, + props.height + ); + content.push(...imageContent); + } else if (flavour === 'affine:latex') { + content.push({ + text: props.latex || '', + margin: [baseIndent, 5, 0, 5], + italics: true, + color: PDF_COLORS.textMuted, + alignment: 'center', + }); + } else if (flavour === 'affine:database') { + content.push(...this._createDatabaseContent(props)); + return content; + } else if (flavour === 'affine:table') { + const tableContent = await this._createTableContent(props); + if (tableContent) { + content.push(tableContent); + } + } else if ( + flavour === 'affine:embed-linked-doc' || + flavour === 'affine:embed-synced-doc' + ) { + content.push(this._createLinkedDocContent(props, baseIndent)); + } else if (hasTextContent(textContent)) { + content.push({ + text: textContent, + margin: [0, 2, 0, 2], + }); + } + + if (block.children && block.children.length) { + const shouldIncrementDepth = + flavour !== 'affine:page' && flavour !== 'affine:note'; + const childDepth = shouldIncrementDepth ? depth + 1 : depth; + + const childListNestingLevel = + flavour === 'affine:list' + ? listNestingLevel + 1 + : parentTextStart > 0 + ? listNestingLevel + : 0; + + const childParentTextStart = + flavour === 'affine:list' + ? baseIndent + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT + : parentTextStart > 0 + ? parentTextStart + : 0; + + for (const child of block.children) { + const childContent = await this._blockToContent( + child, + assets, + childDepth, + childListNestingLevel, + childParentTextStart + ); + content.push(...childContent); + } + } + + return content; + } + + private async _createParagraphContent( + props: Record, + textContent: string | Array, + baseIndent: number, + block: BlockSnapshot, + assets?: FromDocSnapshotPayload['assets'], + depth: number = 0 + ): Promise { + const type = props.type || 'text'; + const textAlign = props.textAlign || 'left'; + const styleMap: Record = { + h1: 'header1', + h2: 'header2', + h3: 'header3', + h4: 'header4', + h5: 'header4', + h6: 'header4', + }; + const style = styleMap[type]; + + if (type === 'quote') { + return this._createQuoteContent( + textContent, + baseIndent, + block, + assets, + depth + ); + } + + const paragraphContent: Content = style + ? { text: textContent, style, margin: [baseIndent, 6, 0, 3] } + : { text: textContent, margin: [baseIndent, 2, 0, 2] }; + + if (textAlign && textAlign !== 'left') { + paragraphContent.alignment = textAlign; + } + + return [paragraphContent]; + } + + private async _createQuoteContent( + textContent: string | Array, + baseIndent: number, + block: BlockSnapshot, + assets?: FromDocSnapshotPayload['assets'], + depth: number = 0 + ): Promise { + const quoteContent: Content[] = []; + + if (hasTextContent(textContent)) { + quoteContent.push({ + text: textContent, + margin: [0, 5, 10, 5], + }); + } + + const childrenContent = await this._processChildrenWithMargins( + block, + assets, + depth, + 0, + 10 + ); + quoteContent.push(...childrenContent); + + return [ + { + table: { + widths: [2, '*'], + body: [ + [ + { text: ' ', fillColor: PDF_COLORS.border }, + { + stack: quoteContent.length > 0 ? quoteContent : [{ text: ' ' }], + margin: [10, 0, 0, 0], + }, + ], + ], + }, + margin: [baseIndent, 5, 0, 5], + layout: TABLE_LAYOUT_NO_BORDERS, + }, + ]; + } + + private async _createListContent( + props: Record, + textContent: string | Array, + baseIndent: number, + listNestingLevel: number, + block: BlockSnapshot + ): Promise { + const type = props.type || 'bulleted'; + const checked = props.checked || false; + const order = props.order; + + let prefixSvg: string | null = null; + let prefixText: string | null = null; + + if (type === 'numbered') { + const number = + order !== null && order !== undefined ? order : listNestingLevel + 1; + prefixText = `${getNumberPrefix(number, listNestingLevel)} `; + } else if (type === 'todo') { + prefixSvg = getCheckboxIconSvg(checked); + } else if (type === 'toggle') { + const hasChildren = block.children && block.children.length > 0; + prefixSvg = getToggleIconSvg(hasChildren); + } else { + prefixSvg = getBulletIconSvg(listNestingLevel); + } + + const listText = Array.isArray(textContent) + ? textContent.length === 0 + ? ' ' + : textContent + : textContent; + + const blueColor = resolveCssVariable('var(--affine-blue-700)') || '#1E96EB'; + + const iconCell: Content = prefixSvg + ? { + svg: prefixSvg, + width: 16, + margin: [0, 0, 4, 0], + } + : prefixText + ? { + text: prefixText, + color: blueColor, + alignment: 'left', + } + : { text: '' }; + + const textCell: Content = + typeof listText === 'string' + ? { text: listText } + : listText.length === 1 + ? typeof listText[0] === 'string' + ? { text: listText[0] } + : listText[0] + : { text: listText }; + + return [ + { + table: { + widths: [BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, '*'], + body: [[iconCell, textCell]], + }, + margin: [baseIndent, 2, 0, 2], + layout: TABLE_LAYOUT_NO_BORDERS, + }, + ]; + } + + private _createCodeContent( + props: Record, + textContent: string | Array, + baseIndent: number + ): Content[] { + const language = props.language || ''; + const lineNumber = props.lineNumber !== false; + const codeText = + typeof textContent === 'string' + ? textContent + : textContentToString(textContent); + const lines = codeText.split('\n'); + + const tableBody: any[][] = []; + if (lineNumber && lines.length > 1) { + const maxLineNumLength = lines.length.toString().length; + for (let i = 0; i < lines.length; i++) { + const lineNum = (i + 1).toString().padStart(maxLineNumLength, ' '); + const isFirstLine = i === 0; + const isLastLine = i === lines.length - 1; + + tableBody.push([ + { + text: lineNum, + style: 'code', + alignment: 'right', + fillColor: PDF_COLORS.codeBackground, + margin: [5, isFirstLine ? 20 : 0, 5, isLastLine ? 20 : 0], + }, + { + text: lines[i], + style: 'code', + fillColor: PDF_COLORS.codeBackground, + margin: [5, isFirstLine ? 20 : 0, 10, isLastLine ? 20 : 0], + }, + ]); + } + } else { + tableBody.push([ + { + text: codeText, + style: 'code', + fillColor: PDF_COLORS.codeBackground, + margin: [10, 5, 10, 5], + colSpan: 2, + }, + '', + ]); + } + + const codeBlockContent: Content[] = [ + { + table: { + widths: ['auto', '*'], + body: tableBody, + }, + margin: [baseIndent, 5, 0, 5], + layout: 'noBorders', + }, + ]; + + if (language) { + codeBlockContent.push({ + text: `Language: ${language}`, + fontSize: 9, + color: PDF_COLORS.textDisabled, + margin: [baseIndent + 10, 0, 0, 5], + italics: true, + }); + } + + return codeBlockContent; + } + + private async _createCalloutContent( + props: Record, + textContent: string | Array, + baseIndent: number, + block: BlockSnapshot, + assets?: FromDocSnapshotPayload['assets'], + depth: number = 0 + ): Promise { + const backgroundColorName = props.backgroundColorName || 'grey'; + const colorVar = + backgroundColorName === 'default' || backgroundColorName === 'grey' + ? 'var(--affine-v2-block-callout-background-grey)' + : `var(--affine-v2-block-callout-background-${backgroundColorName})`; + const backgroundColor = resolveCssVariable(colorVar) || '#f5f5f5'; + + const calloutContent: Content[] = []; + + if (hasTextContent(textContent)) { + calloutContent.push({ + text: textContent, + margin: [10, 5, 10, 0], + }); + } + + const childrenContent = await this._processChildrenWithMargins( + block, + assets, + depth, + 10, + 10 + ); + calloutContent.push(...childrenContent); + + return [ + { + table: { + widths: ['*'], + body: [ + [ + { + stack: + calloutContent.length > 0 ? calloutContent : [{ text: ' ' }], + fillColor: backgroundColor, + margin: [10, 5, 10, 5], + }, + ], + ], + }, + margin: [baseIndent, 5, 0, 5], + layout: 'noBorders', + }, + ]; + } + + private _createDatabaseContent(props: Record): Content[] { + let titleText: + | string + | Array = ''; + + if (props.title) { + if (props.title.delta && Array.isArray(props.title.delta)) { + titleText = extractTextWithInline( + { text: { delta: props.title.delta } }, + this.configs + ); + } else if (props.title.delta) { + titleText = extractTextWithInline({ text: props.title }, this.configs); + } + } + + const content: Content[] = []; + + if (hasTextContent(titleText)) { + content.push({ + text: titleText, + bold: true, + margin: [0, 5, 0, 2], + }); + } + + content.push({ + text: '[Data View - Not exported]', + italics: true, + color: PDF_COLORS.textDisabled, + margin: [0, 2, 0, 5], + }); + + return content; + } + + private _adjustMargins( + content: Content[], + leftAdjustment: number, + rightAdjustment: number + ): Content[] { + return content.map(item => { + if (typeof item === 'object' && 'margin' in item && item.margin) { + const margin = item.margin; + const marginArray = Array.isArray(margin) + ? margin + : [margin, margin, margin, margin]; + const marginTuple: [number, number, number, number] = [ + (marginArray[0] || 0) + leftAdjustment, + marginArray[1] || 0, + (marginArray[2] || 0) + rightAdjustment, + marginArray[3] || 0, + ]; + return { + ...item, + margin: marginTuple, + }; + } + return item; + }); + } + + private _createLinkedDocContent( + props: Record, + baseIndent: number + ): Content { + const pageId = props.pageId || ''; + const titleAlias = props.title; + const configTitle = this.configs.get('title:' + pageId); + const pageTitle = titleAlias || configTitle; + const isPageFound = configTitle !== undefined || titleAlias !== undefined; + const displayTitle = pageTitle || 'Page not found'; + + const docLinkBaseUrl = this.configs.get('docLinkBaseUrl') || ''; + const linkUrl = + docLinkBaseUrl && pageId ? `${docLinkBaseUrl}/${pageId}` : ''; + + const linkedDocContent: Content[] = [ + { + text: displayTitle, + bold: true, + fontSize: 14, + margin: [15, 10, 15, 5], + decoration: isPageFound ? undefined : 'lineThrough', + color: isPageFound ? PDF_COLORS.text : PDF_COLORS.textDisabled, + link: linkUrl || undefined, + }, + ]; + + if (isPageFound) { + linkedDocContent.push({ + text: 'Linked Document', + fontSize: 10, + color: PDF_COLORS.textMuted, + margin: [15, 0, 15, 10], + }); + } + + return { + table: { + widths: ['*'], + body: [ + [{ stack: linkedDocContent, fillColor: PDF_COLORS.cardBackground }], + ], + }, + margin: [baseIndent, 5, 0, 5], + layout: 'noBorders', + }; + } + + private async _createImageContent( + sourceId: string | undefined, + caption: string, + assets?: FromDocSnapshotPayload['assets'], + textAlign: string = 'center', + blockWidth?: number, + blockHeight?: number + ): Promise { + if (!sourceId) { + return [this._getImagePlaceholderContent(caption)]; + } + + try { + const manager = assets ?? this.job.assetsManager; + if (!manager) { + throw new Error('Asset manager not available'); + } + await manager.readFromBlob(sourceId); + const blob = manager.getAssets().get(sourceId); + if (!blob) { + throw new Error('Image asset not found'); + } + + const text = await blob.text(); + const trimmedText = text.trim(); + + if (trimmedText.startsWith('= 2 && data[0] === 0xff && data[1] === 0xd8; + const isPNG = + data.length >= 4 && + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47; + + if (!isJPEG && !isPNG) { + return [this._getImagePlaceholderContent(caption)]; + } + + const imageDimensions = await extractImageDimensions(blob); + const dimensions = calculateImageDimensions( + blockWidth, + blockHeight, + imageDimensions.width, + imageDimensions.height + ); + + // pdfmake (via pdfkit) accepts ArrayBuffer for images, though the types don't reflect this + const imageBuffer = arrayBuffer as never as string; + const content: Content[] = [ + { + image: imageBuffer, + ...(dimensions.width && { width: dimensions.width }), + ...(dimensions.height && { height: dimensions.height }), + margin: [0, 5, 0, 5], + alignment: textAlign as 'left' | 'center' | 'right', + }, + ]; + + if (caption) { + content.push({ + text: caption, + italics: true, + fontSize: 10, + color: PDF_COLORS.textMuted, + margin: [0, 2, 0, 10], + alignment: textAlign as 'left' | 'center' | 'right', + }); + } + + return content; + } catch { + return [this._getImagePlaceholderContent(caption)]; + } + } + + private async _createTableContent( + props: Record + ): Promise { + const columns: Record = props.columns || {}; + const rows: Record = props.rows || {}; + const cells: Record = props.cells || {}; + + const sortedColumns = Object.values(columns).sort((a, b) => + (a.order || '').localeCompare(b.order || '') + ); + const sortedRows = Object.values(rows).sort((a, b) => + (a.order || '').localeCompare(b.order || '') + ); + + if (sortedRows.length === 0 || sortedColumns.length === 0) { + return null; + } + + const tableBody: any[][] = []; + for (const row of sortedRows) { + const rowData: any[] = []; + for (const col of sortedColumns) { + const cellKey = `${(row as any).rowId}:${(col as any).columnId}`; + const cell = cells[cellKey]; + if (cell?.text?.delta) { + const cellText = extractTextWithInline( + { text: cell.text }, + this.configs + ); + rowData.push(cellText); + } else { + rowData.push(''); + } + } + tableBody.push(rowData); + } + + return { + table: { + headerRows: 0, + widths: Array(sortedColumns.length).fill('*'), + body: tableBody, + }, + margin: [0, 5, 0, 5], + layout: { + hLineWidth: (i: number, node: any) => { + if (i === 0 || i === node.table.body.length) return 1; + return 0.5; + }, + vLineWidth: () => 0.5, + hLineColor: () => PDF_COLORS.border, + vLineColor: () => PDF_COLORS.border, + paddingLeft: () => 5, + paddingRight: () => 5, + paddingTop: () => 5, + paddingBottom: () => 5, + }, + }; + } + + private async _processChildrenWithMargins( + block: BlockSnapshot, + assets: FromDocSnapshotPayload['assets'] | undefined, + depth: number, + leftAdjustment: number, + rightAdjustment: number + ): Promise { + const content: Content[] = []; + if (block.children && block.children.length) { + for (const child of block.children) { + const childContent = await this._blockToContent( + child, + assets, + depth, + 0, + 0 + ); + const adjustedContent = this._adjustMargins( + childContent, + leftAdjustment, + rightAdjustment + ); + content.push(...adjustedContent); + } + } + return content; + } + + private _getImagePlaceholderContent(caption: string): Content { + return { + text: getImagePlaceholder(caption), + italics: true, + color: PDF_COLORS.textMuted, + margin: [0, 5, 0, 5], + }; + } + + private _createDocDefinition( + title: string | undefined, + content: Content[] + ): TDocumentDefinitions { + const docContent = + title === undefined + ? content + : [ + { + text: title || 'Untitled', + style: 'title', + margin: [0, 0, 0, 20], + } as ContentText, + ...content, + ]; + + return { + content: docContent, + styles: { + title: { + fontSize: 24, + bold: true, + alignment: 'left', + }, + header1: { + fontSize: 20, + bold: true, + alignment: 'left', + }, + header2: { + fontSize: 18, + bold: true, + alignment: 'left', + }, + header3: { + fontSize: 16, + bold: true, + alignment: 'left', + }, + header4: { + fontSize: 14, + bold: true, + alignment: 'left', + }, + code: { + fontSize: 10, + font: 'Inter', + color: PDF_COLORS.text, + background: PDF_COLORS.codeBackground, + }, + }, + defaultStyle: { + font: 'SarasaGothicCL', + fontSize: 12, + lineHeight: 1.5, + }, + }; + } + + private async _createPdfBlob( + docDefinition: TDocumentDefinitions + ): Promise { + return new Promise((resolve, reject) => { + try { + const pdfDocGenerator = pdfMake.createPdf(docDefinition); + pdfDocGenerator.getBlob(blob => resolve(blob)); + } catch (error) { + reject(error); + } + }); + } +} diff --git a/blocksuite/affine/shared/src/adapters/pdf/svg-utils.ts b/blocksuite/affine/shared/src/adapters/pdf/svg-utils.ts new file mode 100644 index 0000000000..7000ac6cb9 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/svg-utils.ts @@ -0,0 +1,42 @@ +/** + * SVG icon generation utilities + */ + +import { resolveCssVariable } from './css-utils.js'; + +/** + * Get SVG string for bulleted list icon based on depth + */ +export function getBulletIconSvg(depth: number): string { + const bulletIndex = depth % 4; + const blueColor = resolveCssVariable('var(--affine-blue-700)') || '#1E96EB'; + const bulletSvgs = [ + ``, + ``, + ``, + ``, + ]; + return bulletSvgs[bulletIndex]; +} + +/** + * Get SVG string for checkbox icon (checked or unchecked) + */ +export function getCheckboxIconSvg(checked: boolean): string { + if (checked) { + return ''; + } else { + return ''; + } +} + +/** + * Get SVG string for toggle icon (down or right) + */ +export function getToggleIconSvg(expanded: boolean): string { + if (expanded) { + return ''; + } else { + return ''; + } +} diff --git a/blocksuite/affine/shared/src/adapters/pdf/utils.ts b/blocksuite/affine/shared/src/adapters/pdf/utils.ts new file mode 100644 index 0000000000..22d3f831e0 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/pdf/utils.ts @@ -0,0 +1,71 @@ +/** + * Pure utility functions for PDF adapter + */ + +// Layout constants +export const BLOCK_CHILDREN_CONTAINER_PADDING_LEFT = 24; +export const MAX_PAPER_WIDTH = 550; +export const MAX_PAPER_HEIGHT = 800; + +// Color constants +export const PDF_COLORS = { + /** Primary link color */ + link: '#0066cc', + /** Primary text color */ + text: '#333333', + /** Secondary/muted text color */ + textMuted: '#666666', + /** Tertiary/disabled text color */ + textDisabled: '#999999', + /** Border/divider color */ + border: '#cccccc', + /** Code block background */ + codeBackground: '#f5f5f5', + /** Card/container background */ + cardBackground: '#f9f9f9', +} as const; + +/** + * Table layout with no borders (for custom styled containers) + */ +export const TABLE_LAYOUT_NO_BORDERS = { + hLineWidth: () => 0, + vLineWidth: () => 0, + paddingLeft: () => 0, + paddingRight: () => 0, + paddingTop: () => 0, + paddingBottom: () => 0, +} as const; + +/** + * Generate placeholder text for images that cannot be rendered + */ +export function getImagePlaceholder(caption?: string): string { + return caption ? `[Image: ${caption}]` : '[Image]'; +} + +/** + * Check if text content has meaningful content + */ +export function hasTextContent( + textContent: string | Array +): boolean { + if (typeof textContent === 'string') { + return textContent.trim() !== ''; + } + return textContent.length > 0; +} + +/** + * Convert text content array to plain string + */ +export function textContentToString( + textContent: string | Array +): string { + if (typeof textContent === 'string') { + return textContent; + } + return textContent + .map(item => (typeof item === 'string' ? item : item.text)) + .join(''); +} diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index c0ecf54803..adacf4f53f 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -21,6 +21,7 @@ export interface BlockSuiteFlags { enable_table_virtual_scroll: boolean; enable_turbo_renderer: boolean; enable_dom_renderer: boolean; + enable_pdfmake_export: boolean; } export class FeatureFlagService extends StoreExtension { @@ -46,6 +47,7 @@ export class FeatureFlagService extends StoreExtension { enable_table_virtual_scroll: false, enable_turbo_renderer: false, enable_dom_renderer: false, + enable_pdfmake_export: false, }); setFlag(key: keyof BlockSuiteFlags, value: boolean) { diff --git a/blocksuite/affine/shared/src/utils/index.ts b/blocksuite/affine/shared/src/utils/index.ts index 09f88adf27..2c1862ccc8 100644 --- a/blocksuite/affine/shared/src/utils/index.ts +++ b/blocksuite/affine/shared/src/utils/index.ts @@ -15,6 +15,7 @@ export * from './insert'; export * from './is-abort-error'; export * from './math'; export * from './model'; +export * from './number-prefix'; export * from './popper-position'; export * from './print-to-pdf'; export * from './reference'; diff --git a/blocksuite/affine/blocks/list/src/utils/get-number-prefix.ts b/blocksuite/affine/shared/src/utils/number-prefix.ts similarity index 85% rename from blocksuite/affine/blocks/list/src/utils/get-number-prefix.ts rename to blocksuite/affine/shared/src/utils/number-prefix.ts index bb1fa5207b..bc716de091 100644 --- a/blocksuite/affine/blocks/list/src/utils/get-number-prefix.ts +++ b/blocksuite/affine/shared/src/utils/number-prefix.ts @@ -11,7 +11,7 @@ function number2letter(n: number) { } // Derive from https://gist.github.com/imilu/00f32c61e50b7ca296f91e9d96d8e976 -export function number2roman(num: number) { +function number2roman(num: number) { const lookup: Record = { M: 1000, CM: 900, @@ -28,12 +28,13 @@ export function number2roman(num: number) { I: 1, }; let romanStr = ''; - for (const i in lookup) { - while (num >= lookup[i]) { - romanStr += i; - num -= lookup[i]; + for (const [key, value] of Object.entries(lookup)) { + while (num >= value) { + romanStr += key; + num -= value; } } + return romanStr; } diff --git a/blocksuite/affine/widgets/linked-doc/src/transformers/index.ts b/blocksuite/affine/widgets/linked-doc/src/transformers/index.ts index e0766f80f3..28f8f826ec 100644 --- a/blocksuite/affine/widgets/linked-doc/src/transformers/index.ts +++ b/blocksuite/affine/widgets/linked-doc/src/transformers/index.ts @@ -2,5 +2,6 @@ export { DocxTransformer } from './docx.js'; export { HtmlTransformer } from './html.js'; export { MarkdownTransformer } from './markdown.js'; export { NotionHtmlTransformer } from './notion-html.js'; +export { PdfTransformer } from './pdf.js'; export { createAssetsArchive, download } from './utils.js'; export { ZipTransformer } from './zip.js'; diff --git a/blocksuite/affine/widgets/linked-doc/src/transformers/pdf.ts b/blocksuite/affine/widgets/linked-doc/src/transformers/pdf.ts new file mode 100644 index 0000000000..3ef41402fd --- /dev/null +++ b/blocksuite/affine/widgets/linked-doc/src/transformers/pdf.ts @@ -0,0 +1,32 @@ +import { + docLinkBaseURLMiddleware, + embedSyncedDocMiddleware, + PdfAdapter, + titleMiddleware, +} from '@blocksuite/affine-shared/adapters'; +import type { Store } from '@blocksuite/store'; + +import { download } from './utils.js'; + +async function exportDoc(doc: Store) { + const provider = doc.provider; + const job = doc.getTransformer([ + docLinkBaseURLMiddleware(doc.workspace.id), + titleMiddleware(doc.workspace.meta.docMetas), + embedSyncedDocMiddleware('content'), + ]); + const snapshot = job.docToSnapshot(doc); + if (!snapshot) { + return; + } + const adapter = new PdfAdapter(job, provider); + const { file } = await adapter.fromDocSnapshot({ + snapshot, + assets: job.assetsManager, + }); + download(file.blob, file.fileName); +} + +export const PdfTransformer = { + exportDoc, +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts index 5ea56c6748..cc2343cadc 100644 --- a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts +++ b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts @@ -323,6 +323,7 @@ export const useExportUsers = () => { }); dataToCopy.push(row); }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises navigator.clipboard.writeText(JSON.stringify(dataToCopy, null, 2)); callback?.(); }, diff --git a/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts b/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts index 099751177b..ed7b58fee2 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/playground/content.ts @@ -297,9 +297,11 @@ export class PlaygroundContent extends SignalWatcher( } }; + // eslint-disable-next-line @typescript-eslint/no-misused-promises button.addEventListener('click', handleSendClick); this._disposables.add(() => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises button.removeEventListener('click', handleSendClick); }); } diff --git a/packages/frontend/core/src/bootstrap/cleanup.ts b/packages/frontend/core/src/bootstrap/cleanup.ts index d97ab165f6..c3c97a87f6 100644 --- a/packages/frontend/core/src/bootstrap/cleanup.ts +++ b/packages/frontend/core/src/bootstrap/cleanup.ts @@ -4,25 +4,30 @@ function cleanupUnusedIndexedDB() { return; } - indexedDB.databases().then(databases => { - databases.forEach(database => { - if (database.name?.endsWith(':server-clock')) { - indexedDB.deleteDatabase(database.name); - } - if (database.name?.endsWith(':sync-metadata')) { - indexedDB.deleteDatabase(database.name); - } - if ( - database.name?.startsWith('idx:') && - (database.name.endsWith(':block') || database.name.endsWith(':doc')) - ) { - indexedDB.deleteDatabase(database.name); - } - if (database.name?.startsWith('jp:')) { - indexedDB.deleteDatabase(database.name); - } + indexedDB + .databases() + .then(databases => { + databases.forEach(database => { + if (database.name?.endsWith(':server-clock')) { + indexedDB.deleteDatabase(database.name); + } + if (database.name?.endsWith(':sync-metadata')) { + indexedDB.deleteDatabase(database.name); + } + if ( + database.name?.startsWith('idx:') && + (database.name.endsWith(':block') || database.name.endsWith(':doc')) + ) { + indexedDB.deleteDatabase(database.name); + } + if (database.name?.startsWith('jp:')) { + indexedDB.deleteDatabase(database.name); + } + }); + }) + .catch(error => { + console.error('Failed to cleanup unused IndexedDB databases:', error); }); - }); } cleanupUnusedIndexedDB(); diff --git a/packages/frontend/core/src/components/hooks/affine/use-export-page.ts b/packages/frontend/core/src/components/hooks/affine/use-export-page.ts index 9ce439f4e4..4baaf3ad3d 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-export-page.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-export-page.ts @@ -24,6 +24,7 @@ import { download, HtmlTransformer, MarkdownTransformer, + PdfTransformer, ZipTransformer, } from '@blocksuite/affine/widgets/linked-doc'; import { useLiveData, useService } from '@toeverything/infra'; @@ -32,7 +33,13 @@ import { nanoid } from 'nanoid'; import { useAsyncCallback } from '../affine-async-hooks'; -type ExportType = 'pdf' | 'html' | 'png' | 'markdown' | 'snapshot'; +type ExportType = + | 'pdf' + | 'html' + | 'png' + | 'markdown' + | 'snapshot' + | 'pdf-export'; interface ExportHandlerOptions { page: Store; @@ -164,6 +171,10 @@ async function exportHandler({ await editorRoot?.std.get(ExportManager).exportPng(); return; } + case 'pdf-export': { + await PdfTransformer.exportDoc(page); + return; + } } } diff --git a/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx b/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx index 279e46b646..f81f4d79f7 100644 --- a/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx +++ b/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx @@ -1,4 +1,5 @@ import { MenuItem, MenuSeparator, MenuSub } from '@affine/component'; +import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import { @@ -9,6 +10,7 @@ import { PageIcon, PrinterIcon, } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; import type { ReactNode } from 'react'; import { useCallback } from 'react'; @@ -24,7 +26,7 @@ interface ExportMenuItemProps { interface ExportProps { exportHandler: ( - type: 'pdf' | 'html' | 'png' | 'markdown' | 'snapshot' + type: 'pdf' | 'html' | 'png' | 'markdown' | 'snapshot' | 'pdf-export' ) => void; pageMode?: 'page' | 'edgeless'; className?: string; @@ -72,6 +74,11 @@ export const ExportMenuItems = ({ pageMode = 'page', }: ExportProps) => { const t = useI18n(); + const featureFlags = useService(FeatureFlagService).flags; + const enable_pdfmake_export = useLiveData( + featureFlags.enable_pdfmake_export.$ + ); + return ( <> } label={t['Export to Markdown']()} /> + {pageMode !== 'edgeless' && enable_pdfmake_export && ( + exportHandler('pdf-export')} + className={className} + type="pdf-export" + icon={} + label={t['Export to PDF']()} + /> + )} exportHandler('snapshot')} className={className} diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx index a7de24543e..17d1e474b6 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx @@ -158,6 +158,7 @@ const McpServerSetting = () => { variant="primary" onClick={() => { if (!code) return; + // eslint-disable-next-line @typescript-eslint/no-floating-promises navigator.clipboard.writeText(code); notify.success({ title: t['Copied to clipboard'](), diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index c1a212cb67..172e0ae68e 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -288,6 +288,15 @@ export const AFFINE_FLAGS = { configurable: isMobile, defaultState: false, }, + enable_pdfmake_export: { + category: 'blocksuite', + bsFlag: 'enable_pdfmake_export', + displayName: 'Enable PDF Export', + description: + 'Experimental export PDFs support, it may contain the wrong style.', + configurable: true, + defaultState: false, + }, } satisfies { [key in string]: FlagInfo }; // oxlint-disable-next-line no-redeclare diff --git a/packages/frontend/media-capture-playground/web/components/saved-recording-item.tsx b/packages/frontend/media-capture-playground/web/components/saved-recording-item.tsx index 493651f1fd..988f7308a1 100644 --- a/packages/frontend/media-capture-playground/web/components/saved-recording-item.tsx +++ b/packages/frontend/media-capture-playground/web/components/saved-recording-item.tsx @@ -727,6 +727,7 @@ export function SavedRecordingItem({ const handlePlayPause = React.useCallback(() => { if (audioRef.current) { if (audioRef.current.paused) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises void audioRef.current.play(); } else { audioRef.current.pause(); diff --git a/tests/kit/src/utils/keyboard.ts b/tests/kit/src/utils/keyboard.ts index 68025bce68..7ed9ff71ba 100644 --- a/tests/kit/src/utils/keyboard.ts +++ b/tests/kit/src/utils/keyboard.ts @@ -114,6 +114,7 @@ export async function writeTextToClipboard( // paste the url await page.evaluate( async ([text]) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises navigator.clipboard.writeText(''); const e = new ClipboardEvent('paste', { clipboardData: new DataTransfer(), diff --git a/yarn.lock b/yarn.lock index 7007750f37..f7092c1bf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3835,6 +3835,7 @@ __metadata: "@types/hast": "npm:^3.0.4" "@types/lodash-es": "npm:^4.17.12" "@types/mdast": "npm:^4.0.4" + "@types/pdfmake": "npm:^0.2.12" bytes: "npm:^3.1.2" dompurify: "npm:^3.3.0" fractional-indexing: "npm:^3.2.0" @@ -3852,6 +3853,7 @@ __metadata: micromark-extension-gfm-task-list-item: "npm:^2.1.0" micromark-util-combine-extensions: "npm:^2.0.0" minimatch: "npm:^10.1.1" + pdfmake: "npm:^0.2.20" quick-lru: "npm:^7.3.0" rehype-parse: "npm:^9.0.0" rehype-stringify: "npm:^10.0.0" @@ -5539,7 +5541,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.7.1": +"@emnapi/core@npm:^1.4.0, @emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.7.1": version: 1.7.1 resolution: "@emnapi/core@npm:1.7.1" dependencies: @@ -5549,7 +5551,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.2.0, @emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.5.0, @emnapi/runtime@npm:^1.7.1": +"@emnapi/runtime@npm:^1.2.0, @emnapi/runtime@npm:^1.4.0, @emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.5.0, @emnapi/runtime@npm:^1.7.1": version: 1.7.1 resolution: "@emnapi/runtime@npm:1.7.1" dependencies: @@ -6334,6 +6336,52 @@ __metadata: languageName: node linkType: hard +"@foliojs-fork/fontkit@npm:^1.9.2": + version: 1.9.2 + resolution: "@foliojs-fork/fontkit@npm:1.9.2" + dependencies: + "@foliojs-fork/restructure": "npm:^2.0.2" + brotli: "npm:^1.2.0" + clone: "npm:^1.0.4" + deep-equal: "npm:^1.0.0" + dfa: "npm:^1.2.0" + tiny-inflate: "npm:^1.0.2" + unicode-properties: "npm:^1.2.2" + unicode-trie: "npm:^2.0.0" + checksum: 10/143724742532e6fb8288958cff10c99855a44a48ab0fd32708e8c55a84a1ae25118035360fda4ac4e27797332923f2bbe8026b409eacf36dede6deeb79efe4a0 + languageName: node + linkType: hard + +"@foliojs-fork/linebreak@npm:^1.1.1, @foliojs-fork/linebreak@npm:^1.1.2": + version: 1.1.2 + resolution: "@foliojs-fork/linebreak@npm:1.1.2" + dependencies: + base64-js: "npm:1.3.1" + unicode-trie: "npm:^2.0.0" + checksum: 10/5af61cb29a5f6bd055941b6a0251a89f21bb97fb22f56a56b56a4f15dcf59c6088e052bfbce8bdc62d9cd7d6ec14a494292587e60f171f52615bc02bda56d0da + languageName: node + linkType: hard + +"@foliojs-fork/pdfkit@npm:^0.15.3": + version: 0.15.3 + resolution: "@foliojs-fork/pdfkit@npm:0.15.3" + dependencies: + "@foliojs-fork/fontkit": "npm:^1.9.2" + "@foliojs-fork/linebreak": "npm:^1.1.1" + crypto-js: "npm:^4.2.0" + jpeg-exif: "npm:^1.1.4" + png-js: "npm:^1.0.0" + checksum: 10/cefd13f5d2d4b4cb2f7e5f0f0852000c3b868d755dbc0761e59565f42d10e42385a158d8847defef9b255ec5d994a8106b5233c7f7b8aea609597866e3766129 + languageName: node + linkType: hard + +"@foliojs-fork/restructure@npm:^2.0.2": + version: 2.0.2 + resolution: "@foliojs-fork/restructure@npm:2.0.2" + checksum: 10/3b89107426b5887de2844424a4059ab07293715752fb81a1527887d2aafb2ab359b567c8a38ec9dd32a2c1781fc3ac4011a71980ef818cc9323ad80eb19d25ea + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -7590,21 +7638,21 @@ __metadata: languageName: node linkType: hard -"@inquirer/checkbox@npm:^4.3.2": - version: 4.3.2 - resolution: "@inquirer/checkbox@npm:4.3.2" +"@inquirer/checkbox@npm:^4.1.6": + version: 4.1.6 + resolution: "@inquirer/checkbox@npm:4.1.6" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/core": "npm:^10.3.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/figures": "npm:^1.0.11" + "@inquirer/type": "npm:^3.0.6" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/4ac5dd2679981e23f066c51c605cb1c63ccda9ea6e1ad895e675eb26702aaf6cf961bf5ca3acd832efba5edcf9883b6742002c801673d2b35c123a7fa7db7b23 + checksum: 10/28012e16e72393ad6cc5b659620685a75e3e0227c3a2c6d6d1b235742ed7cae0516479e0e1b974c002b8fc7bf49698e9af2900a22cc5b1a83257d9000802401b languageName: node linkType: hard @@ -7635,7 +7683,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^5.0.0, @inquirer/confirm@npm:^5.1.21": +"@inquirer/confirm@npm:^5.0.0, @inquirer/confirm@npm:^5.1.10": version: 5.1.21 resolution: "@inquirer/confirm@npm:5.1.21" dependencies: @@ -7737,19 +7785,19 @@ __metadata: languageName: node linkType: hard -"@inquirer/editor@npm:^4.2.23": - version: 4.2.23 - resolution: "@inquirer/editor@npm:4.2.23" +"@inquirer/editor@npm:^4.2.11": + version: 4.2.11 + resolution: "@inquirer/editor@npm:4.2.11" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/external-editor": "npm:^1.0.3" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/type": "npm:^3.0.6" + external-editor: "npm:^3.1.0" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/f91b9aadba6ea28a0f4ea5f075af421e076262aebbd737e1b9779f086fa9d559d064e9942a581544645d1dcf56d6b685e8063fe46677880fbca73f6de4e4e7c5 + checksum: 10/dcc65e6dc2cf25fd03939b54ff195521748114d3d2986296d708b4357d48d9ac5843e9774b1d02e0f77b9b0edbf4c8b10a77edd99910e1833864b379e5b66ced languageName: node linkType: hard @@ -7780,19 +7828,19 @@ __metadata: languageName: node linkType: hard -"@inquirer/expand@npm:^4.0.23": - version: 4.0.23 - resolution: "@inquirer/expand@npm:4.0.23" +"@inquirer/expand@npm:^4.0.13": + version: 4.0.13 + resolution: "@inquirer/expand@npm:4.0.13" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/type": "npm:^3.0.6" + yoctocolors-cjs: "npm:^2.1.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/73ad1d6376e5efe2a452c33494d6d16ee2670c638ae470a795fdff4acb59a8e032e38e141f87b603b6e96320977519b375dac6471d86d5e3087a9c1db40e3111 + checksum: 10/25ac3a84dbd0b7763aa85ce75c9f3d2022bcc307973a5a3e0b538e2c1e2a94b5eef0b786536589e5f1554a7654853887d150c80b66e3335cc831aa0a5e7d088a languageName: node linkType: hard @@ -7811,21 +7859,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/external-editor@npm:^1.0.3": - version: 1.0.3 - resolution: "@inquirer/external-editor@npm:1.0.3" - dependencies: - chardet: "npm:^2.1.1" - iconv-lite: "npm:^0.7.0" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/c95d7237a885b32031715089f92820525731d4d3c2bd7afdb826307dc296cc2b39e7a644b0bb265441963348cca42e7785feb29c3aaf18fd2b63131769bf6587 - languageName: node - linkType: hard - "@inquirer/external-editor@npm:^2.0.2": version: 2.0.2 resolution: "@inquirer/external-editor@npm:2.0.2" @@ -7841,7 +7874,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/figures@npm:^1.0.15, @inquirer/figures@npm:^1.0.6": +"@inquirer/figures@npm:^1.0.11, @inquirer/figures@npm:^1.0.15, @inquirer/figures@npm:^1.0.6": version: 1.0.15 resolution: "@inquirer/figures@npm:1.0.15" checksum: 10/3f858807f361ca29f41ec1076bbece4098cc140d86a06159d42c6e3f6e4d9bec9e10871ccfcbbaa367d6a8462b01dff89f2b1b157d9de6e8726bec85533f525c @@ -7865,18 +7898,18 @@ __metadata: languageName: node linkType: hard -"@inquirer/input@npm:^4.3.1": - version: 4.3.1 - resolution: "@inquirer/input@npm:4.3.1" +"@inquirer/input@npm:^4.1.10": + version: 4.1.10 + resolution: "@inquirer/input@npm:4.1.10" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/type": "npm:^3.0.6" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/713aaa4c94263299fbd7adfd65378f788cac1b5047f2b7e1ea349ca669db6c7c91b69ab6e2f6660cdbc28c7f7888c5c77ab4433bd149931597e43976d1ba5f34 + checksum: 10/61ea42f1171fc0113bfde9fd5b5a32a6f436011178fa08613685f337b3f3cb1bc60b1a76b3ab55fc2c895d87196526add2e1b0711249d539eb982428878566f2 languageName: node linkType: hard @@ -7905,18 +7938,18 @@ __metadata: languageName: node linkType: hard -"@inquirer/number@npm:^3.0.23": - version: 3.0.23 - resolution: "@inquirer/number@npm:3.0.23" +"@inquirer/number@npm:^3.0.13": + version: 3.0.13 + resolution: "@inquirer/number@npm:3.0.13" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/type": "npm:^3.0.6" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/50694807b71746e15ed69d100aae3c8014d83c90aa660e8a179fe0db1046f26d727947542f64e24cc8b969a61659cb89fe36208cc2b59c1816382b598e686dd2 + checksum: 10/6df930d3ef281dff5b6b59fbdc999bcfeaf49175e2a1739f9db80a4e10b10060045cb265fb2737c8382d8264f457ab2f647c20368e288562068d2bba36fdca54 languageName: node linkType: hard @@ -7946,19 +7979,19 @@ __metadata: languageName: node linkType: hard -"@inquirer/password@npm:^4.0.23": - version: 4.0.23 - resolution: "@inquirer/password@npm:4.0.23" +"@inquirer/password@npm:^4.0.13": + version: 4.0.13 + resolution: "@inquirer/password@npm:4.0.13" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/type": "npm:^3.0.6" + ansi-escapes: "npm:^4.3.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/97364970b01c85946a4a50ad876c53ef0c1857a9144e24fad65e5dfa4b4e5dd42564fbcdfa2b49bb049a25d127efbe0882cb18afcdd47b166ebd01c6c4b5e825 + checksum: 10/f45f51e12326586b205195f5b669ce6f529b7f0bfad9ab667fb90bd4858c86d9bfb372310e0deb7f600fccf2577bd3a992feaf3fcfbc86b86e715878c4ed52e5 languageName: node linkType: hard @@ -7997,25 +8030,25 @@ __metadata: linkType: hard "@inquirer/prompts@npm:^7.4.0, @inquirer/prompts@npm:^7.5.1": - version: 7.10.1 - resolution: "@inquirer/prompts@npm:7.10.1" + version: 7.5.1 + resolution: "@inquirer/prompts@npm:7.5.1" dependencies: - "@inquirer/checkbox": "npm:^4.3.2" - "@inquirer/confirm": "npm:^5.1.21" - "@inquirer/editor": "npm:^4.2.23" - "@inquirer/expand": "npm:^4.0.23" - "@inquirer/input": "npm:^4.3.1" - "@inquirer/number": "npm:^3.0.23" - "@inquirer/password": "npm:^4.0.23" - "@inquirer/rawlist": "npm:^4.1.11" - "@inquirer/search": "npm:^3.2.2" - "@inquirer/select": "npm:^4.4.2" + "@inquirer/checkbox": "npm:^4.1.6" + "@inquirer/confirm": "npm:^5.1.10" + "@inquirer/editor": "npm:^4.2.11" + "@inquirer/expand": "npm:^4.0.13" + "@inquirer/input": "npm:^4.1.10" + "@inquirer/number": "npm:^3.0.13" + "@inquirer/password": "npm:^4.0.13" + "@inquirer/rawlist": "npm:^4.1.1" + "@inquirer/search": "npm:^3.0.13" + "@inquirer/select": "npm:^4.2.1" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/b3e3386edd255e4e91c7908050674f8a2e69b043883c00feec2f87d697be37bc6e8cd4a360e7e3233a9825ae7ea044a2ac63d5700926d27f9959013d8566f890 + checksum: 10/febb8a1bb6e7ff63b0e6c88ac9af7f7a2daf621f80c0e720cc7a68bd9fa99c7253911271d547ba3b55f18b580298a58440f3f45c974b8e895cfae929fadec868 languageName: node linkType: hard @@ -8053,19 +8086,19 @@ __metadata: languageName: node linkType: hard -"@inquirer/rawlist@npm:^4.1.11": - version: 4.1.11 - resolution: "@inquirer/rawlist@npm:4.1.11" +"@inquirer/rawlist@npm:^4.1.1": + version: 4.1.1 + resolution: "@inquirer/rawlist@npm:4.1.1" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/type": "npm:^3.0.6" + yoctocolors-cjs: "npm:^2.1.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/0d8f6484cfc20749190e95eecfb2d034bafb3644ec4907b84b1673646f5dd71730e38e35565ea98dfd240d8851e3cff653edafcc4e0af617054b127b407e3229 + checksum: 10/e7c272f9f7a1576c9c1212a278c2d4bad7b394ddf512d3bbbf75902baa7a4fe4bde1b707f1d4c0cbe3963d0ba5a92e7fcbc4dffbb817ecec9b4fa70ac97b535d languageName: node linkType: hard @@ -8096,20 +8129,20 @@ __metadata: languageName: node linkType: hard -"@inquirer/search@npm:^3.2.2": - version: 3.2.2 - resolution: "@inquirer/search@npm:3.2.2" +"@inquirer/search@npm:^3.0.13": + version: 3.0.13 + resolution: "@inquirer/search@npm:3.0.13" dependencies: - "@inquirer/core": "npm:^10.3.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/figures": "npm:^1.0.11" + "@inquirer/type": "npm:^3.0.6" + yoctocolors-cjs: "npm:^2.1.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/abaed2df7763633ff4414b58d1c87233b69ed3cd2ac77629f0d54b72b8b585dc4806c7a2a8261daba58af5b0a2147e586d079fdc82060b6bcf56b75d3d03f3a7 + checksum: 10/2b8a94c5d83e4eced093caa680cb6561037d047702b91f77adc1ab56189d0c78974de0017946004b7acef9f8312772d7369ad227c0fbc59133ad3243981eff3d languageName: node linkType: hard @@ -8142,21 +8175,21 @@ __metadata: languageName: node linkType: hard -"@inquirer/select@npm:^4.4.2": - version: 4.4.2 - resolution: "@inquirer/select@npm:4.4.2" +"@inquirer/select@npm:^4.2.1": + version: 4.2.1 + resolution: "@inquirer/select@npm:4.2.1" dependencies: - "@inquirer/ansi": "npm:^1.0.2" - "@inquirer/core": "npm:^10.3.2" - "@inquirer/figures": "npm:^1.0.15" - "@inquirer/type": "npm:^3.0.10" - yoctocolors-cjs: "npm:^2.1.3" + "@inquirer/core": "npm:^10.1.11" + "@inquirer/figures": "npm:^1.0.11" + "@inquirer/type": "npm:^3.0.6" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/795ec0ac77d575f20bd6a12fb1c040093e62217ac0c80194829a8d3c3d1e09f70ad738e9a9dd6095cc8358fff4e13882209c09bdf8eb0864a86dcabef5b0a6a6 + checksum: 10/883ff2c359052efe9be021d5cf5133970c49f62ac07ba18fd949d71242a40608708f9a0651a1094c6e1dcbc914c40817646f57ac2b281b485fa331dd49232083 languageName: node linkType: hard @@ -9823,13 +9856,13 @@ __metadata: linkType: hard "@napi-rs/wasm-runtime@npm:^0.2.5, @napi-rs/wasm-runtime@npm:^0.2.7, @napi-rs/wasm-runtime@npm:^0.2.9": - version: 0.2.12 - resolution: "@napi-rs/wasm-runtime@npm:0.2.12" + version: 0.2.9 + resolution: "@napi-rs/wasm-runtime@npm:0.2.9" dependencies: - "@emnapi/core": "npm:^1.4.3" - "@emnapi/runtime": "npm:^1.4.3" - "@tybys/wasm-util": "npm:^0.10.0" - checksum: 10/5fd518182427980c28bc724adf06c5f32f9a8915763ef560b5f7d73607d30cd15ac86d0cbd2eb80d4cfab23fc80d0876d89ca36a9daadcb864bc00917c94187c + "@emnapi/core": "npm:^1.4.0" + "@emnapi/runtime": "npm:^1.4.0" + "@tybys/wasm-util": "npm:^0.9.0" + checksum: 10/8ebc7d85e11e1b8d71908d5615ff24b27ef7af8287d087fb5cff5a3e545915c7545998d976a9cd6a4315dab4ba0f609439fbe6408fec3afebd288efb0dbdc135 languageName: node linkType: hard @@ -15888,7 +15921,7 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": +"@tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" dependencies: @@ -16941,6 +16974,25 @@ __metadata: languageName: node linkType: hard +"@types/pdfkit@npm:*": + version: 0.17.3 + resolution: "@types/pdfkit@npm:0.17.3" + dependencies: + "@types/node": "npm:*" + checksum: 10/58208a7969be6a1219f211d590b2315f3c50667c0c11c7e153df93fdfc178fd24a34356c6f496d3d9473087d1ac499e3b43df67c86098cc8bc77bb294d426b10 + languageName: node + linkType: hard + +"@types/pdfmake@npm:^0.2.12": + version: 0.2.12 + resolution: "@types/pdfmake@npm:0.2.12" + dependencies: + "@types/node": "npm:*" + "@types/pdfkit": "npm:*" + checksum: 10/a1dff188ec30ac4f3aa995cb74a2d213e7ae229a2aa269390383e91b62e984da1061ce43ced52f358596e51e3b5b7d77a4d3db1234e74e632c6e765a397824d0 + languageName: node + linkType: hard + "@types/pg-pool@npm:2.0.6": version: 2.0.6 resolution: "@types/pg-pool@npm:2.0.6" @@ -18881,7 +18933,14 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": +"base64-js@npm:1.3.1": + version: 1.3.1 + resolution: "base64-js@npm:1.3.1" + checksum: 10/957b9ced0ea1b39588a117193f801b045a5fb2d6f1b9943dd304bcad46e5681bf837fe092105692b11653658e8443764139d6b11d3c4037093b96e8db4e1dbb2 + languageName: node + linkType: hard + +"base64-js@npm:^1.1.2, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -19133,6 +19192,15 @@ __metadata: languageName: node linkType: hard +"brotli@npm:^1.2.0": + version: 1.3.3 + resolution: "brotli@npm:1.3.3" + dependencies: + base64-js: "npm:^1.1.2" + checksum: 10/78b412f54be3c96b86e2d9805ddc26aa5a52bba45588ff7f8468b80aa84c90052c60eeb2e26ad032c39bab6baa58e0b0625cf4f738279961a31c34cbe4b4b490 + languageName: node + linkType: hard + "browser-fs-access@npm:^0.37.0": version: 0.37.0 resolution: "browser-fs-access@npm:0.37.0" @@ -20166,7 +20234,7 @@ __metadata: languageName: node linkType: hard -"clone@npm:^1.0.2": +"clone@npm:^1.0.2, clone@npm:^1.0.4": version: 1.0.4 resolution: "clone@npm:1.0.4" checksum: 10/d06418b7335897209e77bdd430d04f882189582e67bd1f75a04565f3f07f5b3f119a9d670c943b6697d0afb100f03b866b3b8a1f91d4d02d72c4ecf2bb64b5dd @@ -20953,6 +21021,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: 10/c7bcc56a6e01c3c397e95aa4a74e4241321f04677f9a618a8f48a63b5781617248afb9adb0629824792e7ec20ca0d4241a49b6b2938ae6f973ec4efc5c53c924 + languageName: node + linkType: hard + "css-declaration-sorter@npm:^7.2.0": version: 7.2.0 resolution: "css-declaration-sorter@npm:7.2.0" @@ -21735,6 +21810,15 @@ __metadata: languageName: node linkType: hard +"deep-equal@npm:@nolyfill/deep-equal@^1": + version: 1.0.44 + resolution: "@nolyfill/deep-equal@npm:1.0.44" + dependencies: + dequal: "npm:2.0.3" + checksum: 10/11f4d3f92b1a98d842a27982dc9d3c2ef7b78917917387acde7f056de55d1afa59b7ea4cc62fc5236246970a4b799d6592670ce1043bb8c97b38da662ea924b6 + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -21861,7 +21945,7 @@ __metadata: languageName: node linkType: hard -"dequal@npm:^2.0.0, dequal@npm:^2.0.3": +"dequal@npm:2.0.3, dequal@npm:^2.0.0, dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" checksum: 10/6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b @@ -21929,6 +22013,13 @@ __metadata: languageName: node linkType: hard +"dfa@npm:^1.2.0": + version: 1.2.0 + resolution: "dfa@npm:1.2.0" + checksum: 10/3b274fe6d2d70f41c1418ac961f7ae6e7f3b7445f20b98395a55943902100dd2491ef91a60c47c14a72645021f02248ccfad79fa65b10d6075bff34237a35bf8 + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -26583,6 +26674,13 @@ __metadata: languageName: node linkType: hard +"jpeg-exif@npm:^1.1.4": + version: 1.1.4 + resolution: "jpeg-exif@npm:1.1.4" + checksum: 10/75699bd7161de1be99e847166917957bfb405ed736655361c58f0390e7182cc28999b2cbc31e650b71b6d2ad38843789439a121ac64988b2ccaa00f6832bb3d9 + languageName: node + linkType: hard + "js-string-escape@npm:^1.0.1": version: 1.0.1 resolution: "js-string-escape@npm:1.0.1" @@ -30425,6 +30523,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:^0.2.5": + version: 0.2.9 + resolution: "pako@npm:0.2.9" + checksum: 10/627c6842e90af0b3a9ee47345bd66485a589aff9514266f4fa9318557ad819c46fedf97510f2cef9b6224c57913777966a05cb46caf6a9b31177a5401a06fe15 + languageName: node + linkType: hard + "pako@npm:^1.0.10, pako@npm:^1.0.11, pako@npm:^1.0.6, pako@npm:~1.0.2": version: 1.0.11 resolution: "pako@npm:1.0.11" @@ -30776,6 +30881,18 @@ __metadata: languageName: node linkType: hard +"pdfmake@npm:^0.2.20": + version: 0.2.20 + resolution: "pdfmake@npm:0.2.20" + dependencies: + "@foliojs-fork/linebreak": "npm:^1.1.2" + "@foliojs-fork/pdfkit": "npm:^0.15.3" + iconv-lite: "npm:^0.6.3" + xmldoc: "npm:^2.0.1" + checksum: 10/524807370eb9f9c2a55b7aadfb2dfbd183ac81b2d7dbcba8e4f79566f220fcbf8e73d3a28e5988d42b38094615c82b966d7a039a07159db836c800a7395b5d44 + languageName: node + linkType: hard + "pe-library@npm:^0.4.1": version: 0.4.1 resolution: "pe-library@npm:0.4.1" @@ -30995,6 +31112,13 @@ __metadata: languageName: node linkType: hard +"png-js@npm:^1.0.0": + version: 1.0.0 + resolution: "png-js@npm:1.0.0" + checksum: 10/7762c5ec06da1b1a3e99bc78599bb15e7f0b04b49b4507e71f606b280006148122bdf937e2e1cba81d279d1c9966694f5c8c34ceb82fb11f328fac06db5b17cb + languageName: node + linkType: hard + "points-on-curve@npm:0.2.0, points-on-curve@npm:^0.2.0": version: 0.2.0 resolution: "points-on-curve@npm:0.2.0" @@ -34969,6 +35093,13 @@ __metadata: languageName: node linkType: hard +"tiny-inflate@npm:^1.0.0, tiny-inflate@npm:^1.0.2": + version: 1.0.3 + resolution: "tiny-inflate@npm:1.0.3" + checksum: 10/f620114fb51ea4a16ea7b4c62d6dd753f8faf41808a133c53d431ed4bf2ca377b21443653a0096894f2be22ca11bb327f148e7e5431f9246068917724ec01ffc + languageName: node + linkType: hard + "tiny-invariant@npm:^1.3.3": version: 1.3.3 resolution: "tiny-invariant@npm:1.3.3" @@ -35095,9 +35226,9 @@ __metadata: linkType: hard "tmp@npm:^0.2.0": - version: 0.2.5 - resolution: "tmp@npm:0.2.5" - checksum: 10/dd4b78b32385eab4899d3ae296007b34482b035b6d73e1201c4a9aede40860e90997a1452c65a2d21aee73d53e93cd167d741c3db4015d90e63b6d568a93d7ec + version: 0.2.3 + resolution: "tmp@npm:0.2.3" + checksum: 10/7b13696787f159c9754793a83aa79a24f1522d47b87462ddb57c18ee93ff26c74cbb2b8d9138f571d2e0e765c728fb2739863a672b280528512c6d83d511c6fa languageName: node linkType: hard @@ -35624,6 +35755,26 @@ __metadata: languageName: node linkType: hard +"unicode-properties@npm:^1.2.2": + version: 1.4.1 + resolution: "unicode-properties@npm:1.4.1" + dependencies: + base64-js: "npm:^1.3.0" + unicode-trie: "npm:^2.0.0" + checksum: 10/f03d35036291b08aa2572dc51eff712e64fb1d8daaeb65e8add38a24c66c2b8bb3882ee19e6e8de424cfbbc6a4ebe14766816294c7f582b4bb5704402acbd089 + languageName: node + linkType: hard + +"unicode-trie@npm:^2.0.0": + version: 2.0.0 + resolution: "unicode-trie@npm:2.0.0" + dependencies: + pako: "npm:^0.2.5" + tiny-inflate: "npm:^1.0.0" + checksum: 10/60404411dbd363bdcca9e81c9327fa80469f2e685737bac88ec693225ff20b9b545ac37ca2da13ec02f1552167dd010dfefd7c58b72a73d44a89fab1ca9c2479 + languageName: node + linkType: hard + "unicorn-magic@npm:^0.1.0": version: 0.1.0 resolution: "unicorn-magic@npm:0.1.0" @@ -37001,6 +37152,15 @@ __metadata: languageName: node linkType: hard +"xmldoc@npm:^2.0.1": + version: 2.0.2 + resolution: "xmldoc@npm:2.0.2" + dependencies: + sax: "npm:^1.2.4" + checksum: 10/b62fe5b2de3c9f79e5d26d1737583a7988dcc19eb0bb1710480940b0500c50f5d565ebf9682b5be0b43658811ab919e05cab45b8ad5e8fee4bb9300a3e3cd758 + languageName: node + linkType: hard + "xmlhttprequest-ssl@npm:~2.1.1": version: 2.1.2 resolution: "xmlhttprequest-ssl@npm:2.1.2"