${filterCount === 0
? ''
@@ -134,28 +147,66 @@ const createSettingMenus = (
${ArrowRightSmallIcon()}`,
select: () => {
if (!filterTrait.filter$.value.conditions.length) {
- popCreateFilter(target, {
- vars: view.vars$,
- onBack: reopen,
- onSelect: filter => {
- filterTrait.filterSet({
- ...(filterTrait.filter$.value ?? emptyFilterGroup),
- conditions: [...filterTrait.filter$.value.conditions, filter],
- });
- popFilterRoot(target, {
- filterTrait: filterTrait,
- onBack: reopen,
- dataViewLogic: dataViewLogic,
- });
- dataViewLogic.eventTrace('CreateDatabaseFilter', {});
+ popCreateFilter(
+ target,
+ {
+ vars: view.vars$,
+ onBack: reopen,
+ onClose: closeMenu,
+ onSelect: filter => {
+ filterTrait.filterSet({
+ ...(filterTrait.filter$.value ?? emptyFilterGroup),
+ conditions: [
+ ...filterTrait.filter$.value.conditions,
+ filter,
+ ],
+ });
+ popFilterRoot(
+ target,
+ {
+ filterTrait: filterTrait,
+ onBack: reopen,
+ onClose: closeMenu,
+ dataViewLogic: dataViewLogic,
+ },
+ [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ]
+ );
+ dataViewLogic.eventTrace('CreateDatabaseFilter', {});
+ },
},
- });
+ {
+ middleware: [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ],
+ }
+ );
} else {
- popFilterRoot(target, {
- filterTrait: filterTrait,
- onBack: reopen,
- dataViewLogic: dataViewLogic,
- });
+ popFilterRoot(
+ target,
+ {
+ filterTrait: filterTrait,
+ onBack: reopen,
+ onClose: closeMenu,
+ dataViewLogic: dataViewLogic,
+ },
+ [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ]
+ );
}
},
})
@@ -168,6 +219,7 @@ const createSettingMenus = (
menu.action({
name: 'Sort',
prefix: SortIcon(),
+ closeOnSelect: false,
postfix: html`
${sortCount === 0
? ''
@@ -183,18 +235,42 @@ const createSettingMenus = (
dataViewLogic.eventTrace
);
if (!sortList.length) {
- popCreateSort(target, {
- sortUtils: sortUtils,
- onBack: reopen,
- });
- } else {
- popSortRoot(target, {
- sortUtils: sortUtils,
- title: {
- text: 'Sort',
+ popCreateSort(
+ target,
+ {
+ sortUtils: sortUtils,
onBack: reopen,
+ onClose: closeMenu,
},
- });
+ {
+ middleware: [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ],
+ }
+ );
+ } else {
+ popSortRoot(
+ target,
+ {
+ sortUtils: sortUtils,
+ title: {
+ text: 'Sort',
+ onBack: reopen,
+ onClose: closeMenu,
+ },
+ },
+ [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ]
+ );
}
},
})
@@ -206,6 +282,7 @@ const createSettingMenus = (
menu.action({
name: 'Group',
prefix: GroupingIcon(),
+ closeOnSelect: false,
postfix: html`
${groupTrait.property$.value?.name$.value ?? ''}
@@ -213,12 +290,37 @@ const createSettingMenus = (
select: () => {
const groupBy = groupTrait.property$.value;
if (!groupBy) {
- popSelectGroupByProperty(target, groupTrait, {
- onSelect: () => popGroupSetting(target, groupTrait, reopen),
- onBack: reopen,
- });
+ popSelectGroupByProperty(
+ target,
+ groupTrait,
+ {
+ onSelect: () =>
+ popGroupSetting(target, groupTrait, reopen, closeMenu, [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ]),
+ onBack: reopen,
+ onClose: closeMenu,
+ },
+ [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ]
+ );
} else {
- popGroupSetting(target, groupTrait, reopen);
+ popGroupSetting(target, groupTrait, reopen, closeMenu, [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ]);
}
},
})
@@ -308,7 +410,7 @@ export const popViewOptions = (
>`;
};
});
- popMenu(target, {
+ const subHandler = popMenu(target, {
options: {
title: {
onBack: reopen,
@@ -338,7 +440,15 @@ export const popViewOptions = (
// }),
],
},
+ middleware: [
+ autoPlacement({
+ allowedPlacements: ['bottom-start', 'top-start'],
+ }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ],
});
+ subHandler.menu.menuElement.style.minHeight = '550px';
},
prefix: LayoutIcon(),
}),
@@ -348,7 +458,9 @@ export const popViewOptions = (
items.push(
menu.group({
- items: createSettingMenus(target, dataViewLogic, reopen),
+ items: createSettingMenus(target, dataViewLogic, reopen, () =>
+ handler.close()
+ ),
})
);
items.push(
@@ -357,6 +469,7 @@ export const popViewOptions = (
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
+ closeOnSelect: false,
select: () => {
view.duplicate();
},
@@ -364,6 +477,7 @@ export const popViewOptions = (
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
+ closeOnSelect: false,
select: () => {
view.delete();
},
@@ -372,13 +486,22 @@ export const popViewOptions = (
],
})
);
- popMenu(target, {
+ let handler: ReturnType
;
+ handler = popMenu(target, {
options: {
title: {
text: 'View settings',
+ onClose: () => handler.close(),
},
items,
onClose: onClose,
},
+ middleware: [
+ autoPlacement({ allowedPlacements: ['bottom-start'] }),
+ offset({ mainAxis: 15, crossAxis: -162 }),
+ shift({ crossAxis: true }),
+ ],
});
+ handler.menu.menuElement.style.minHeight = '550px';
+ return handler;
};
diff --git a/blocksuite/affine/rich-text/src/conversion.ts b/blocksuite/affine/rich-text/src/conversion.ts
index 61612c8b5ed8a..a48250bea8104 100644
--- a/blocksuite/affine/rich-text/src/conversion.ts
+++ b/blocksuite/affine/rich-text/src/conversion.ts
@@ -26,6 +26,7 @@ export interface TextConversionConfig {
description?: string;
hotkey: string[] | null;
icon: TemplateResult<1>;
+ searchAlias?: string[];
}
export const textConversionConfigs: TextConversionConfig[] = [
@@ -106,6 +107,7 @@ export const textConversionConfigs: TextConversionConfig[] = [
type: 'todo',
name: 'To-do List',
description: 'Add tasks to a to-do list.',
+ searchAlias: ['checkbox'],
hotkey: null,
icon: CheckBoxIcon,
},
diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json
index 0693687733b18..82a44d4cbc244 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 dab76bd0f3b5a..1c9333a2d5dda 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 0000000000000..324073b34a223
--- /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 0000000000000..1a993e0baea26
--- /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 0000000000000..6e693a988f124
--- /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 0000000000000..eb96f29e1627b
--- /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 0000000000000..f16f32372b1e9
--- /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('