fix: pdf export (#8564)
* feat: pdf export * fix: tests * fix: tests --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
20e266c9bb
commit
b31c0195bc
23 changed files with 4287 additions and 62 deletions
|
|
@ -15,6 +15,9 @@
|
||||||
"build": "tsc --noEmit && tsdown",
|
"build": "tsc --noEmit && tsdown",
|
||||||
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
|
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
|
||||||
"start": "node --env-file=.env .",
|
"start": "node --env-file=.env .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=160",
|
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=160",
|
||||||
"check:types": "tsc --noEmit",
|
"check:types": "tsc --noEmit",
|
||||||
"check:format": "prettier . --cache --check",
|
"check:format": "prettier . --cache --check",
|
||||||
|
|
@ -25,6 +28,9 @@
|
||||||
"author": "Plane Software Inc.",
|
"author": "Plane Software Inc.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dotenvx/dotenvx": "catalog:",
|
"@dotenvx/dotenvx": "catalog:",
|
||||||
|
"@effect/platform": "^0.94.0",
|
||||||
|
"@effect/platform-node": "^0.104.0",
|
||||||
|
"@fontsource/inter": "5.2.8",
|
||||||
"@hocuspocus/extension-database": "2.15.2",
|
"@hocuspocus/extension-database": "2.15.2",
|
||||||
"@hocuspocus/extension-logger": "2.15.2",
|
"@hocuspocus/extension-logger": "2.15.2",
|
||||||
"@hocuspocus/extension-redis": "2.15.2",
|
"@hocuspocus/extension-redis": "2.15.2",
|
||||||
|
|
@ -34,6 +40,8 @@
|
||||||
"@plane/editor": "workspace:*",
|
"@plane/editor": "workspace:*",
|
||||||
"@plane/logger": "workspace:*",
|
"@plane/logger": "workspace:*",
|
||||||
"@plane/types": "workspace:*",
|
"@plane/types": "workspace:*",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
|
"@react-pdf/types": "^2.9.2",
|
||||||
"@sentry/node": "catalog:",
|
"@sentry/node": "catalog:",
|
||||||
"@sentry/profiling-node": "catalog:",
|
"@sentry/profiling-node": "catalog:",
|
||||||
"@tiptap/core": "catalog:",
|
"@tiptap/core": "catalog:",
|
||||||
|
|
@ -41,10 +49,13 @@
|
||||||
"axios": "catalog:",
|
"axios": "catalog:",
|
||||||
"compression": "1.8.1",
|
"compression": "1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"effect": "^3.16.3",
|
||||||
"express": "catalog:",
|
"express": "catalog:",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"ioredis": "5.7.0",
|
"ioredis": "5.7.0",
|
||||||
|
"react": "catalog:",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
"uuid": "catalog:",
|
"uuid": "catalog:",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"y-prosemirror": "^1.3.7",
|
"y-prosemirror": "^1.3.7",
|
||||||
|
|
@ -59,8 +70,13 @@
|
||||||
"@types/express": "4.17.23",
|
"@types/express": "4.17.23",
|
||||||
"@types/express-ws": "^3.0.5",
|
"@types/express-ws": "^3.0.5",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
|
"@types/react": "catalog:",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.8",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CollaborationController } from "./collaboration.controller";
|
import { CollaborationController } from "./collaboration.controller";
|
||||||
import { DocumentController } from "./document.controller";
|
import { DocumentController } from "./document.controller";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
|
import { PdfExportController } from "./pdf-export.controller";
|
||||||
|
|
||||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
|
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
|
||||||
|
|
|
||||||
136
apps/live/src/controllers/pdf-export.controller.ts
Normal file
136
apps/live/src/controllers/pdf-export.controller.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { Effect, Schema, Cause } from "effect";
|
||||||
|
import { Controller, Post } from "@plane/decorators";
|
||||||
|
import { logger } from "@plane/logger";
|
||||||
|
import { AppError } from "@/lib/errors";
|
||||||
|
import { PdfExportRequestBody, PdfValidationError, PdfAuthenticationError } from "@/schema/pdf-export";
|
||||||
|
import { PdfExportService, exportToPdf } from "@/services/pdf-export";
|
||||||
|
import type { PdfExportInput } from "@/services/pdf-export";
|
||||||
|
|
||||||
|
@Controller("/pdf-export")
|
||||||
|
export class PdfExportController {
|
||||||
|
/**
|
||||||
|
* Parses and validates the request, returning a typed input object
|
||||||
|
*/
|
||||||
|
private parseRequest(
|
||||||
|
req: Request,
|
||||||
|
requestId: string
|
||||||
|
): Effect.Effect<PdfExportInput, PdfValidationError | PdfAuthenticationError> {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const cookie = req.headers.cookie || "";
|
||||||
|
if (!cookie) {
|
||||||
|
return yield* Effect.fail(
|
||||||
|
new PdfAuthenticationError({
|
||||||
|
message: "Authentication required",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(cause) =>
|
||||||
|
new PdfValidationError({
|
||||||
|
message: "Invalid request body",
|
||||||
|
cause,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageId: body.pageId,
|
||||||
|
workspaceSlug: body.workspaceSlug,
|
||||||
|
projectId: body.projectId,
|
||||||
|
title: body.title,
|
||||||
|
author: body.author,
|
||||||
|
subject: body.subject,
|
||||||
|
pageSize: body.pageSize,
|
||||||
|
pageOrientation: body.pageOrientation,
|
||||||
|
fileName: body.fileName,
|
||||||
|
noAssets: body.noAssets,
|
||||||
|
cookie,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps domain errors to HTTP responses
|
||||||
|
*/
|
||||||
|
private mapErrorToHttpResponse(error: unknown): { status: number; error: string } {
|
||||||
|
if (error && typeof error === "object" && "_tag" in error) {
|
||||||
|
const tag = (error as { _tag: string })._tag;
|
||||||
|
const message = (error as { message?: string }).message || "Unknown error";
|
||||||
|
|
||||||
|
switch (tag) {
|
||||||
|
case "PdfValidationError":
|
||||||
|
return { status: 400, error: message };
|
||||||
|
case "PdfAuthenticationError":
|
||||||
|
return { status: 401, error: message };
|
||||||
|
case "PdfContentFetchError":
|
||||||
|
return {
|
||||||
|
status: message.includes("not found") ? 404 : 502,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
case "PdfTimeoutError":
|
||||||
|
return { status: 504, error: message };
|
||||||
|
case "PdfGenerationError":
|
||||||
|
return { status: 500, error: message };
|
||||||
|
case "PdfMetadataFetchError":
|
||||||
|
case "PdfImageProcessingError":
|
||||||
|
return { status: 502, error: message };
|
||||||
|
default:
|
||||||
|
return { status: 500, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: 500, error: "Failed to generate PDF" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
async exportToPdf(req: Request, res: Response) {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const effect = Effect.gen(this, function* () {
|
||||||
|
// Parse request
|
||||||
|
const input = yield* this.parseRequest(req, requestId);
|
||||||
|
|
||||||
|
// Delegate to service
|
||||||
|
return yield* exportToPdf(input);
|
||||||
|
}).pipe(
|
||||||
|
// Log errors before catching them
|
||||||
|
Effect.tapError((error) => Effect.logError("PDF_EXPORT: Export failed", { requestId, error })),
|
||||||
|
// Map all tagged errors to HTTP responses
|
||||||
|
Effect.catchAll((error) => Effect.succeed(this.mapErrorToHttpResponse(error))),
|
||||||
|
// Handle unexpected defects
|
||||||
|
Effect.catchAllDefect((defect) => {
|
||||||
|
const appError = new AppError(Cause.pretty(Cause.die(defect)), {
|
||||||
|
context: { requestId, operation: "exportToPdf" },
|
||||||
|
});
|
||||||
|
logger.error("PDF_EXPORT: Unexpected failure", appError);
|
||||||
|
return Effect.succeed({ status: 500, error: "Failed to generate PDF" });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default));
|
||||||
|
|
||||||
|
// Check if result is an error response
|
||||||
|
if ("error" in result && "status" in result) {
|
||||||
|
return res.status(result.status).json({ message: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - send PDF
|
||||||
|
const { pdfBuffer, outputFileName } = result;
|
||||||
|
|
||||||
|
// Sanitize filename for Content-Disposition header to prevent header injection
|
||||||
|
const sanitizedFileName = outputFileName
|
||||||
|
.replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF
|
||||||
|
.replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}`
|
||||||
|
);
|
||||||
|
res.setHeader("Content-Length", pdfBuffer.length);
|
||||||
|
return res.send(pdfBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
apps/live/src/lib/pdf/colors.ts
Normal file
225
apps/live/src/lib/pdf/colors.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
/**
|
||||||
|
* PDF Export Color Constants
|
||||||
|
*
|
||||||
|
* These colors are mapped from the editor CSS variables and tailwind-config tokens
|
||||||
|
* to ensure PDF exports match the editor's appearance.
|
||||||
|
*
|
||||||
|
* Source mappings:
|
||||||
|
* - Editor colors: packages/editor/src/styles/variables.css
|
||||||
|
* - Tailwind tokens: packages/tailwind-config/variables.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Editor text colors (from variables.css :root)
|
||||||
|
export const EDITOR_TEXT_COLORS = {
|
||||||
|
gray: "#5c5e63",
|
||||||
|
peach: "#ff5b59",
|
||||||
|
pink: "#f65385",
|
||||||
|
orange: "#fd9038",
|
||||||
|
green: "#0fc27b",
|
||||||
|
"light-blue": "#17bee9",
|
||||||
|
"dark-blue": "#266df0",
|
||||||
|
purple: "#9162f9",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Editor background colors - Light theme (from variables.css [data-theme*="light"])
|
||||||
|
export const EDITOR_BACKGROUND_COLORS_LIGHT = {
|
||||||
|
gray: "#d6d6d8",
|
||||||
|
peach: "#ffd5d7",
|
||||||
|
pink: "#fdd4e3",
|
||||||
|
orange: "#ffe3cd",
|
||||||
|
green: "#c3f0de",
|
||||||
|
"light-blue": "#c5eff9",
|
||||||
|
"dark-blue": "#c9dafb",
|
||||||
|
purple: "#e3d8fd",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Editor background colors - Dark theme (from variables.css [data-theme*="dark"])
|
||||||
|
export const EDITOR_BACKGROUND_COLORS_DARK = {
|
||||||
|
gray: "#404144",
|
||||||
|
peach: "#593032",
|
||||||
|
pink: "#562e3d",
|
||||||
|
orange: "#583e2a",
|
||||||
|
green: "#1d4a3b",
|
||||||
|
"light-blue": "#1f495c",
|
||||||
|
"dark-blue": "#223558",
|
||||||
|
purple: "#3d325a",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Use light theme colors by default for PDF exports
|
||||||
|
export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT;
|
||||||
|
|
||||||
|
// Color key type
|
||||||
|
export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a color key to its text color hex value
|
||||||
|
*/
|
||||||
|
export const getTextColorHex = (colorKey: string): string | null => {
|
||||||
|
if (colorKey in EDITOR_TEXT_COLORS) {
|
||||||
|
return EDITOR_TEXT_COLORS[colorKey as EditorColorKey];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a color key to its background color hex value
|
||||||
|
*/
|
||||||
|
export const getBackgroundColorHex = (colorKey: string): string | null => {
|
||||||
|
if (colorKey in EDITOR_BACKGROUND_COLORS) {
|
||||||
|
return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)")
|
||||||
|
*/
|
||||||
|
export const isCssVariable = (value: string): boolean => {
|
||||||
|
return value.startsWith("var(");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the color key from a CSS variable reference
|
||||||
|
* e.g., "var(--editor-colors-gray-text)" -> "gray"
|
||||||
|
* e.g., "var(--editor-colors-light-blue-background)" -> "light-blue"
|
||||||
|
*/
|
||||||
|
export const extractColorKeyFromCssVariable = (cssVar: string): string | null => {
|
||||||
|
// Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background)
|
||||||
|
const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a color value to a hex color for PDF rendering
|
||||||
|
* Handles both direct hex values and CSS variable references
|
||||||
|
*/
|
||||||
|
export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
// If it's already a hex color, return it
|
||||||
|
if (value.startsWith("#")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a CSS variable, extract the key and get the hex value
|
||||||
|
if (isCssVariable(value)) {
|
||||||
|
const colorKey = extractColorKeyFromCssVariable(value);
|
||||||
|
if (colorKey) {
|
||||||
|
return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's just a color key (e.g., "gray", "peach"), get the hex value
|
||||||
|
if (type === "text") {
|
||||||
|
return getTextColorHex(value);
|
||||||
|
}
|
||||||
|
return getBackgroundColorHex(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Semantic colors from tailwind-config (light theme)
|
||||||
|
// These are derived from the CSS variables in packages/tailwind-config/variables.css
|
||||||
|
|
||||||
|
// Neutral colors (light theme)
|
||||||
|
export const NEUTRAL_COLORS = {
|
||||||
|
white: "#ffffff",
|
||||||
|
100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa
|
||||||
|
200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5
|
||||||
|
300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0
|
||||||
|
400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb
|
||||||
|
500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5
|
||||||
|
600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9
|
||||||
|
700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc
|
||||||
|
800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c
|
||||||
|
900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a
|
||||||
|
1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363
|
||||||
|
1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d
|
||||||
|
1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f
|
||||||
|
black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Brand colors (light theme accent)
|
||||||
|
export const BRAND_COLORS = {
|
||||||
|
default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue
|
||||||
|
100: "#f5f8ff",
|
||||||
|
200: "#e8f0ff",
|
||||||
|
300: "#d1e1ff",
|
||||||
|
400: "#b3d0ff",
|
||||||
|
500: "#8ab8ff",
|
||||||
|
600: "#5c9aff",
|
||||||
|
700: "#3f76ff",
|
||||||
|
900: "#2952b3",
|
||||||
|
1000: "#1e3d80",
|
||||||
|
1100: "#142b5c",
|
||||||
|
1200: "#0d1f40",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Semantic text colors
|
||||||
|
export const TEXT_COLORS = {
|
||||||
|
primary: NEUTRAL_COLORS[1200], // --txt-primary
|
||||||
|
secondary: NEUTRAL_COLORS[1100], // --txt-secondary
|
||||||
|
tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary
|
||||||
|
placeholder: NEUTRAL_COLORS[900], // --txt-placeholder
|
||||||
|
disabled: NEUTRAL_COLORS[800], // --txt-disabled
|
||||||
|
accentPrimary: BRAND_COLORS.default, // --txt-accent-primary
|
||||||
|
linkPrimary: BRAND_COLORS.default, // --txt-link-primary
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Semantic background colors
|
||||||
|
export const BACKGROUND_COLORS = {
|
||||||
|
canvas: NEUTRAL_COLORS[300], // --bg-canvas
|
||||||
|
surface1: NEUTRAL_COLORS.white, // --bg-surface-1
|
||||||
|
surface2: NEUTRAL_COLORS[100], // --bg-surface-2
|
||||||
|
layer1: NEUTRAL_COLORS[200], // --bg-layer-1
|
||||||
|
layer2: NEUTRAL_COLORS.white, // --bg-layer-2
|
||||||
|
layer3: NEUTRAL_COLORS[300], // --bg-layer-3
|
||||||
|
accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Semantic border colors
|
||||||
|
export const BORDER_COLORS = {
|
||||||
|
subtle: NEUTRAL_COLORS[400], // --border-subtle
|
||||||
|
subtle1: NEUTRAL_COLORS[500], // --border-subtle-1
|
||||||
|
strong: NEUTRAL_COLORS[600], // --border-strong
|
||||||
|
strong1: NEUTRAL_COLORS[700], // --border-strong-1
|
||||||
|
accentStrong: BRAND_COLORS.default, // --border-accent-strong
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Code/inline code colors
|
||||||
|
export const CODE_COLORS = {
|
||||||
|
background: NEUTRAL_COLORS[200], // Similar to bg-layer-1
|
||||||
|
text: "#dc2626", // Red for inline code text (matches editor)
|
||||||
|
blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Link colors
|
||||||
|
export const LINK_COLORS = {
|
||||||
|
primary: BRAND_COLORS.default,
|
||||||
|
hover: BRAND_COLORS[900],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary)
|
||||||
|
export const MENTION_COLORS = {
|
||||||
|
background: "#e0e9ff", // accent-primary with ~20% opacity on white
|
||||||
|
text: BRAND_COLORS.default,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Success/Green colors
|
||||||
|
export const SUCCESS_COLORS = {
|
||||||
|
primary: "#10b981",
|
||||||
|
subtle: "#d1fae5",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Warning/Amber colors
|
||||||
|
export const WARNING_COLORS = {
|
||||||
|
primary: "#f59e0b",
|
||||||
|
subtle: "#fef3c7",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Danger/Red colors
|
||||||
|
export const DANGER_COLORS = {
|
||||||
|
primary: "#ef4444",
|
||||||
|
subtle: "#fee2e2",
|
||||||
|
} as const;
|
||||||
226
apps/live/src/lib/pdf/icons.tsx
Normal file
226
apps/live/src/lib/pdf/icons.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import { Circle, Path, Rect, Svg } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lightbulb icon for callouts (default)
|
||||||
|
export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M9 21h6M12 3a6 6 0 0 0-6 6c0 2.22 1.21 4.16 3 5.19V17a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2.81c1.79-1.03 3-2.97 3-5.19a6 6 0 0 0-6-6z"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Document/file icon for page embeds
|
||||||
|
export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<Path d="M14 2v6h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<Path d="M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link icon for page links and external links
|
||||||
|
export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Paperclip icon for attachments (default)
|
||||||
|
export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Image icon for image attachments
|
||||||
|
export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Rect x={3} y={3} width={18} height={18} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Circle cx={8.5} cy={8.5} r={1.5} fill={color} />
|
||||||
|
<Path d="M21 15l-5-5L5 21" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Video icon for video attachments
|
||||||
|
export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Rect x={2} y={4} width={15} height={16} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Path d="M17 10l5-3v10l-5-3z" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Music/audio icon
|
||||||
|
export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path d="M9 18V5l12-2v13" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
<Circle cx={6} cy={18} r={3} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Circle cx={18} cy={16} r={3} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// File-text icon for PDFs and documents
|
||||||
|
export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<Path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table/spreadsheet icon
|
||||||
|
export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Path d="M3 9h18M3 15h18M9 3v18M15 3v18" fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Presentation icon
|
||||||
|
export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Rect x={2} y={3} width={20} height={14} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Path d="M8 21l4-4 4 4M12 17v-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Archive/zip icon
|
||||||
|
export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M21 8v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M23 3H1v5h22V3zM10 12h4"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Globe icon for external embeds (rich cards)
|
||||||
|
export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Circle cx={12} cy={12} r={10} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Path
|
||||||
|
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clipboard icon for whiteboards
|
||||||
|
export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<Rect x={8} y={2} width={8} height={4} rx={1} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ruler/diagram icon for diagrams
|
||||||
|
export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path
|
||||||
|
d="M14 3v4a1 1 0 0 0 1 1h4"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Path d="M9 9h1M9 13h6M9 17h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Work item / task icon
|
||||||
|
export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||||
|
<Path d="M9 12l2 2 4-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Checkmark icon for checked task items
|
||||||
|
export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||||
|
<Path d="M20 6L9 17l-5-5" fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to get file icon component based on file type
|
||||||
|
export const getFileIcon = (fileType: string, size = 16, color = "#374151") => {
|
||||||
|
if (fileType.startsWith("image/")) return <ImageIcon size={size} color={color} />;
|
||||||
|
if (fileType.startsWith("video/")) return <VideoIcon size={size} color={color} />;
|
||||||
|
if (fileType.startsWith("audio/")) return <MusicIcon size={size} color={color} />;
|
||||||
|
if (fileType.includes("pdf")) return <FileTextIcon size={size} color="#dc2626" />;
|
||||||
|
if (fileType.includes("spreadsheet") || fileType.includes("excel")) return <TableIcon size={size} color="#16a34a" />;
|
||||||
|
if (fileType.includes("document") || fileType.includes("word")) return <FileTextIcon size={size} color="#2563eb" />;
|
||||||
|
if (fileType.includes("presentation") || fileType.includes("powerpoint"))
|
||||||
|
return <PresentationIcon size={size} color="#ea580c" />;
|
||||||
|
if (fileType.includes("zip") || fileType.includes("archive")) return <ArchiveIcon size={size} color={color} />;
|
||||||
|
return <PaperclipIcon size={size} color={color} />;
|
||||||
|
};
|
||||||
18
apps/live/src/lib/pdf/index.ts
Normal file
18
apps/live/src/lib/pdf/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter";
|
||||||
|
export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers";
|
||||||
|
export { markRenderers, applyMarks } from "./mark-renderers";
|
||||||
|
export { pdfStyles } from "./styles";
|
||||||
|
export type {
|
||||||
|
KeyGenerator,
|
||||||
|
MarkRendererRegistry,
|
||||||
|
NodeRendererRegistry,
|
||||||
|
PDFExportMetadata,
|
||||||
|
PDFExportOptions,
|
||||||
|
PDFMarkRenderer,
|
||||||
|
PDFNodeRenderer,
|
||||||
|
PDFRenderContext,
|
||||||
|
PDFUserMention,
|
||||||
|
TipTapDocument,
|
||||||
|
TipTapMark,
|
||||||
|
TipTapNode,
|
||||||
|
} from "./types";
|
||||||
138
apps/live/src/lib/pdf/mark-renderers.ts
Normal file
138
apps/live/src/lib/pdf/mark-renderers.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { Style } from "@react-pdf/types";
|
||||||
|
import {
|
||||||
|
BACKGROUND_COLORS,
|
||||||
|
CODE_COLORS,
|
||||||
|
EDITOR_BACKGROUND_COLORS,
|
||||||
|
EDITOR_TEXT_COLORS,
|
||||||
|
LINK_COLORS,
|
||||||
|
resolveColorForPdf,
|
||||||
|
} from "./colors";
|
||||||
|
import type { MarkRendererRegistry, TipTapMark } from "./types";
|
||||||
|
|
||||||
|
export const markRenderers: MarkRendererRegistry = {
|
||||||
|
bold: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
fontWeight: "bold",
|
||||||
|
}),
|
||||||
|
|
||||||
|
italic: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}),
|
||||||
|
|
||||||
|
underline: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}),
|
||||||
|
|
||||||
|
strike: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
textDecoration: "line-through",
|
||||||
|
}),
|
||||||
|
|
||||||
|
code: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
fontFamily: "Courier",
|
||||||
|
fontSize: 10,
|
||||||
|
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||||
|
color: CODE_COLORS.text,
|
||||||
|
}),
|
||||||
|
|
||||||
|
link: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
color: LINK_COLORS.primary,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}),
|
||||||
|
|
||||||
|
textStyle: (mark: TipTapMark, style: Style): Style => {
|
||||||
|
const attrs = mark.attrs || {};
|
||||||
|
const newStyle: Style = { ...style };
|
||||||
|
|
||||||
|
if (attrs.color && typeof attrs.color === "string") {
|
||||||
|
newStyle.color = attrs.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") {
|
||||||
|
newStyle.backgroundColor = attrs.backgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newStyle;
|
||||||
|
},
|
||||||
|
|
||||||
|
highlight: (mark: TipTapMark, style: Style): Style => {
|
||||||
|
const attrs = mark.attrs || {};
|
||||||
|
return {
|
||||||
|
...style,
|
||||||
|
backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
subscript: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
fontSize: 8,
|
||||||
|
}),
|
||||||
|
|
||||||
|
superscript: (_mark: TipTapMark, style: Style): Style => ({
|
||||||
|
...style,
|
||||||
|
fontSize: 8,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom color mark handler
|
||||||
|
* Handles the customColor extension which stores colors as data-text-color and data-background-color attributes
|
||||||
|
* The colors can be either:
|
||||||
|
* 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST)
|
||||||
|
* 2. Direct hex values for custom colors
|
||||||
|
* 3. CSS variable references like "var(--editor-colors-gray-text)"
|
||||||
|
*/
|
||||||
|
customColor: (mark: TipTapMark, style: Style): Style => {
|
||||||
|
const attrs = mark.attrs || {};
|
||||||
|
const newStyle: Style = { ...style };
|
||||||
|
|
||||||
|
// Handle text color (stored in 'color' attribute)
|
||||||
|
const textColor = attrs.color as string | undefined;
|
||||||
|
if (textColor) {
|
||||||
|
const resolvedColor = resolveColorForPdf(textColor, "text");
|
||||||
|
if (resolvedColor) {
|
||||||
|
newStyle.color = resolvedColor;
|
||||||
|
} else if (textColor.startsWith("#") || textColor.startsWith("rgb")) {
|
||||||
|
// Direct color value
|
||||||
|
newStyle.color = textColor;
|
||||||
|
} else if (textColor in EDITOR_TEXT_COLORS) {
|
||||||
|
// Color key lookup
|
||||||
|
newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle background color (stored in 'backgroundColor' attribute)
|
||||||
|
const backgroundColor = attrs.backgroundColor as string | undefined;
|
||||||
|
if (backgroundColor) {
|
||||||
|
const resolvedColor = resolveColorForPdf(backgroundColor, "background");
|
||||||
|
if (resolvedColor) {
|
||||||
|
newStyle.backgroundColor = resolvedColor;
|
||||||
|
} else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) {
|
||||||
|
// Direct color value
|
||||||
|
newStyle.backgroundColor = backgroundColor;
|
||||||
|
} else if (backgroundColor in EDITOR_BACKGROUND_COLORS) {
|
||||||
|
// Color key lookup
|
||||||
|
newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newStyle;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => {
|
||||||
|
if (!marks || marks.length === 0) {
|
||||||
|
return baseStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return marks.reduce((style, mark) => {
|
||||||
|
const renderer = markRenderers[mark.type];
|
||||||
|
if (renderer) {
|
||||||
|
return renderer(mark, style);
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}, baseStyle);
|
||||||
|
};
|
||||||
439
apps/live/src/lib/pdf/node-renderers.tsx
Normal file
439
apps/live/src/lib/pdf/node-renderers.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
import { Image, Link, Text, View } from "@react-pdf/renderer";
|
||||||
|
import type { Style } from "@react-pdf/types";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { CORE_EXTENSIONS } from "@plane/editor";
|
||||||
|
import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors";
|
||||||
|
import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons";
|
||||||
|
import { applyMarks } from "./mark-renderers";
|
||||||
|
import { pdfStyles } from "./styles";
|
||||||
|
import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types";
|
||||||
|
|
||||||
|
const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => {
|
||||||
|
const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined;
|
||||||
|
const iconName = node.attrs?.["data-icon-name"] as string | undefined;
|
||||||
|
const iconColor = (node.attrs?.["data-icon-color"] as string) || color;
|
||||||
|
|
||||||
|
if (logoInUse === "emoji") {
|
||||||
|
const emojiUnicode = node.attrs?.["data-emoji-unicode"] as string | undefined;
|
||||||
|
if (emojiUnicode) {
|
||||||
|
return <Text style={{ fontSize: 14 }}>{emojiUnicode}</Text>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconName) {
|
||||||
|
switch (iconName) {
|
||||||
|
case "FileText":
|
||||||
|
case "File":
|
||||||
|
return <DocumentIcon size={16} color={iconColor} />;
|
||||||
|
case "Link":
|
||||||
|
return <LinkIcon size={16} color={iconColor} />;
|
||||||
|
case "Globe":
|
||||||
|
return <GlobeIcon size={16} color={iconColor} />;
|
||||||
|
case "Clipboard":
|
||||||
|
return <ClipboardIcon size={16} color={iconColor} />;
|
||||||
|
case "CheckSquare":
|
||||||
|
case "Check":
|
||||||
|
return <CheckIcon size={16} color={iconColor} />;
|
||||||
|
case "Lightbulb":
|
||||||
|
default:
|
||||||
|
return <LightbulbIcon size={16} color={iconColor} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LightbulbIcon size={16} color={color} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createKeyGenerator = (): KeyGenerator => {
|
||||||
|
let counter = 0;
|
||||||
|
return () => `node-${counter++}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => {
|
||||||
|
const style = applyMarks(node.marks, {});
|
||||||
|
const hasLink = node.marks?.find((m) => m.type === "link");
|
||||||
|
|
||||||
|
if (hasLink) {
|
||||||
|
const href = (hasLink.attrs?.href as string) || "#";
|
||||||
|
return (
|
||||||
|
<Link key={getKey()} src={href} style={{ ...pdfStyles.link, ...style }}>
|
||||||
|
{node.text || ""}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text key={getKey()} style={style}>
|
||||||
|
{node.text || ""}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextAlignStyle = (textAlign: string | null | undefined): Style => {
|
||||||
|
if (!textAlign) return {};
|
||||||
|
return {
|
||||||
|
textAlign: textAlign as "left" | "right" | "center" | "justify",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFlexAlignStyle = (textAlign: string | null | undefined): Style => {
|
||||||
|
if (!textAlign) return {};
|
||||||
|
if (textAlign === "right") return { alignItems: "flex-end" };
|
||||||
|
if (textAlign === "center") return { alignItems: "center" };
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodeRenderers: NodeRendererRegistry = {
|
||||||
|
doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||||
|
<View key={ctx.getKey()}>{children}</View>
|
||||||
|
),
|
||||||
|
|
||||||
|
text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement =>
|
||||||
|
renderTextWithMarks(node, ctx.getKey),
|
||||||
|
|
||||||
|
paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const textAlign = node.attrs?.textAlign as string | null;
|
||||||
|
const background = node.attrs?.backgroundColor as string | undefined;
|
||||||
|
const alignStyle = getTextAlignStyle(textAlign);
|
||||||
|
const flexStyle = getFlexAlignStyle(textAlign);
|
||||||
|
const resolvedBgColor =
|
||||||
|
background && background !== "default" ? resolveColorForPdf(background, "background") : null;
|
||||||
|
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.paragraphWrapper, flexStyle, bgStyle]}>
|
||||||
|
<Text style={[pdfStyles.paragraph, alignStyle, bgStyle]}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const level = (node.attrs?.level as number) || 1;
|
||||||
|
const styleKey = `heading${level}` as keyof typeof pdfStyles;
|
||||||
|
const style = pdfStyles[styleKey] || pdfStyles.heading1;
|
||||||
|
const textAlign = node.attrs?.textAlign as string | null;
|
||||||
|
const alignStyle = getTextAlignStyle(textAlign);
|
||||||
|
const flexStyle = getFlexAlignStyle(textAlign);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={flexStyle}>
|
||||||
|
<Text style={[style, alignStyle]}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||||
|
<View key={ctx.getKey()} style={pdfStyles.blockquote} wrap={false}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
|
||||||
|
codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const codeContent = node.content?.map((c) => c.text || "").join("") || "";
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={pdfStyles.codeBlock} wrap={false}>
|
||||||
|
<Text>{codeContent}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
|
||||||
|
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.bulletList, indentStyle]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
|
||||||
|
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.orderedList, indentStyle]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const isOrdered = node.attrs?._parentType === "orderedList";
|
||||||
|
const index = (node.attrs?._listItemIndex as number) || 0;
|
||||||
|
|
||||||
|
const bullet = isOrdered ? `${index}.` : "•";
|
||||||
|
|
||||||
|
const textAlign = node.attrs?._textAlign as string | null;
|
||||||
|
const flexStyle = getFlexAlignStyle(textAlign);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.listItem, flexStyle]} wrap={false}>
|
||||||
|
<View style={pdfStyles.listItemBullet}>
|
||||||
|
<Text>{bullet}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.listItemContent}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||||
|
<View key={ctx.getKey()} style={pdfStyles.taskList}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
|
||||||
|
taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const checked = node.attrs?.checked === true;
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={pdfStyles.taskItem} wrap={false}>
|
||||||
|
<View style={checked ? [pdfStyles.taskCheckbox, pdfStyles.taskCheckboxChecked] : pdfStyles.taskCheckbox}>
|
||||||
|
{checked && <CheckIcon size={8} color="#ffffff" />}
|
||||||
|
</View>
|
||||||
|
<View style={pdfStyles.listItemContent}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||||
|
<View key={ctx.getKey()} style={pdfStyles.table}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
|
||||||
|
tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const isHeader = node.attrs?._isHeader === true;
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={isHeader ? pdfStyles.tableHeaderRow : pdfStyles.tableRow} wrap={false}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const colwidth = node.attrs?.colwidth as number[] | undefined;
|
||||||
|
const background = node.attrs?.background as string | undefined;
|
||||||
|
const width = colwidth?.[0];
|
||||||
|
const widthStyle = width ? { width, flex: undefined } : {};
|
||||||
|
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
|
||||||
|
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.tableHeaderCell, widthStyle, bgStyle]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const colwidth = node.attrs?.colwidth as number[] | undefined;
|
||||||
|
const background = node.attrs?.background as string | undefined;
|
||||||
|
const width = colwidth?.[0];
|
||||||
|
const widthStyle = width ? { width, flex: undefined } : {};
|
||||||
|
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
|
||||||
|
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.tableCell, widthStyle, bgStyle]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||||
|
<View key={ctx.getKey()} style={pdfStyles.horizontalRule} />
|
||||||
|
),
|
||||||
|
|
||||||
|
hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||||
|
<Text key={ctx.getKey()}>{"\n"}</Text>
|
||||||
|
),
|
||||||
|
|
||||||
|
image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
if (ctx.metadata?.noAssets) {
|
||||||
|
return <View key={ctx.getKey()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = (node.attrs?.src as string) || "";
|
||||||
|
const width = node.attrs?.width as number | undefined;
|
||||||
|
const alignment = (node.attrs?.alignment as string) || "left";
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return <View key={ctx.getKey()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignmentStyle =
|
||||||
|
alignment === "center"
|
||||||
|
? { alignItems: "center" as const }
|
||||||
|
: alignment === "right"
|
||||||
|
? { alignItems: "flex-end" as const }
|
||||||
|
: { alignItems: "flex-start" as const };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
style={[pdfStyles.image, width ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
if (ctx.metadata?.noAssets) {
|
||||||
|
return <View key={ctx.getKey()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetId = (node.attrs?.src as string) || "";
|
||||||
|
const rawWidth = node.attrs?.width;
|
||||||
|
const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined);
|
||||||
|
const alignment = (node.attrs?.alignment as string) || "left";
|
||||||
|
|
||||||
|
if (!assetId) {
|
||||||
|
return <View key={ctx.getKey()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedSrc = assetId;
|
||||||
|
if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) {
|
||||||
|
resolvedSrc = ctx.metadata.resolvedImageUrls[assetId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignmentStyle =
|
||||||
|
alignment === "center"
|
||||||
|
? { alignItems: "center" as const }
|
||||||
|
: alignment === "right"
|
||||||
|
? { alignItems: "flex-end" as const }
|
||||||
|
: { alignItems: "flex-start" as const };
|
||||||
|
|
||||||
|
if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) {
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
|
||||||
|
<Text style={pdfStyles.imagePlaceholderText}>[Image: {assetId.slice(0, 8)}...]</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
|
||||||
|
<Image src={resolvedSrc} style={[pdfStyles.image, imageStyle]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const backgroundKey = (node.attrs?.["data-background"] as string) || "gray";
|
||||||
|
const backgroundColor =
|
||||||
|
EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={ctx.getKey()} style={[pdfStyles.callout, { backgroundColor }]}>
|
||||||
|
<View style={pdfStyles.calloutIconContainer}>{getCalloutIcon(node, TEXT_COLORS.primary)}</View>
|
||||||
|
<View style={[pdfStyles.calloutContent, { color: TEXT_COLORS.primary }]}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||||
|
const id = (node.attrs?.id as string) || "";
|
||||||
|
const entityIdentifier = (node.attrs?.entity_identifier as string) || "";
|
||||||
|
const entityName = (node.attrs?.entity_name as string) || "";
|
||||||
|
|
||||||
|
let displayText = entityName || id || entityIdentifier;
|
||||||
|
|
||||||
|
if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) {
|
||||||
|
const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id);
|
||||||
|
if (userMention) {
|
||||||
|
displayText = userMention.display_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text key={ctx.getKey()} style={pdfStyles.mention}>
|
||||||
|
@{displayText}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalRenderContext = {
|
||||||
|
parentType?: string;
|
||||||
|
nestingLevel: number;
|
||||||
|
listItemIndex: number;
|
||||||
|
textAlign?: string | null;
|
||||||
|
pdfContext: PDFRenderContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => {
|
||||||
|
const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context;
|
||||||
|
|
||||||
|
const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST;
|
||||||
|
|
||||||
|
let childTextAlign = textAlign;
|
||||||
|
if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) {
|
||||||
|
childTextAlign = node.attrs.textAlign as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeWithContext = {
|
||||||
|
...node,
|
||||||
|
attrs: {
|
||||||
|
...node.attrs,
|
||||||
|
_parentType: parentType,
|
||||||
|
_nestingLevel: nestingLevel,
|
||||||
|
_listItemIndex: listItemIndex,
|
||||||
|
_textAlign: childTextAlign,
|
||||||
|
_isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let childNestingLevel = nestingLevel;
|
||||||
|
if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) {
|
||||||
|
childNestingLevel = nestingLevel + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentListItemIndex = 0;
|
||||||
|
const children: ReactElement[] =
|
||||||
|
node.content?.map((child) => {
|
||||||
|
const childContext: InternalRenderContext = {
|
||||||
|
parentType: node.type,
|
||||||
|
nestingLevel: childNestingLevel,
|
||||||
|
listItemIndex: 0,
|
||||||
|
textAlign: childTextAlign,
|
||||||
|
pdfContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) {
|
||||||
|
currentListItemIndex++;
|
||||||
|
childContext.listItemIndex = currentListItemIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderNodeWithContext(child, childContext);
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const renderer = nodeRenderers[node.type];
|
||||||
|
if (renderer) {
|
||||||
|
return renderer(nodeWithContext, children, pdfContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length > 0) {
|
||||||
|
return <View key={pdfContext.getKey()}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View key={pdfContext.getKey()} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderNode = (
|
||||||
|
node: TipTapNode,
|
||||||
|
parentType?: string,
|
||||||
|
_index?: number,
|
||||||
|
metadata?: PDFExportMetadata,
|
||||||
|
getKey?: KeyGenerator
|
||||||
|
): ReactElement => {
|
||||||
|
const keyGen = getKey ?? createKeyGenerator();
|
||||||
|
|
||||||
|
return renderNodeWithContext(node, {
|
||||||
|
parentType,
|
||||||
|
nestingLevel: 0,
|
||||||
|
listItemIndex: 0,
|
||||||
|
pdfContext: { getKey: keyGen, metadata },
|
||||||
|
});
|
||||||
|
};
|
||||||
82
apps/live/src/lib/pdf/plane-pdf-exporter.tsx
Normal file
82
apps/live/src/lib/pdf/plane-pdf-exporter.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { createRequire } from "module";
|
||||||
|
import path from "path";
|
||||||
|
import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer";
|
||||||
|
import { createKeyGenerator, renderNode } from "./node-renderers";
|
||||||
|
import { pdfStyles } from "./styles";
|
||||||
|
import type { PDFExportOptions, TipTapDocument } from "./types";
|
||||||
|
|
||||||
|
// Use createRequire for ESM compatibility to resolve font file paths
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Resolve local font file paths from @fontsource/inter package
|
||||||
|
const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json"));
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: "Inter",
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
src: path.join(interFontDir, "files/inter-latin-400-normal.woff"),
|
||||||
|
fontWeight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: path.join(interFontDir, "files/inter-latin-400-italic.woff"),
|
||||||
|
fontWeight: 400,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: path.join(interFontDir, "files/inter-latin-600-normal.woff"),
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: path.join(interFontDir, "files/inter-latin-600-italic.woff"),
|
||||||
|
fontWeight: 600,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: path.join(interFontDir, "files/inter-latin-700-normal.woff"),
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: path.join(interFontDir, "files/inter-latin-700-italic.woff"),
|
||||||
|
fontWeight: 700,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => {
|
||||||
|
const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options;
|
||||||
|
|
||||||
|
// Merge noAssets into metadata for use in node renderers
|
||||||
|
const mergedMetadata = { ...metadata, noAssets };
|
||||||
|
|
||||||
|
const content = doc.content || [];
|
||||||
|
const getKey = createKeyGenerator();
|
||||||
|
const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document title={title} author={author} subject={subject}>
|
||||||
|
<Page size={pageSize} orientation={pageOrientation} style={pdfStyles.page}>
|
||||||
|
{title && <Text style={pdfStyles.title}>{title}</Text>}
|
||||||
|
{renderedContent}
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderPlaneDocToPdfBuffer = async (
|
||||||
|
doc: TipTapDocument,
|
||||||
|
options: PDFExportOptions = {}
|
||||||
|
): Promise<Buffer> => {
|
||||||
|
const pdfDocument = createPdfDocument(doc, options);
|
||||||
|
const pdfInstance = pdf(pdfDocument);
|
||||||
|
const blob = await pdfInstance.toBlob();
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise<Blob> => {
|
||||||
|
const pdfDocument = createPdfDocument(doc, options);
|
||||||
|
const pdfInstance = pdf(pdfDocument);
|
||||||
|
return await pdfInstance.toBlob();
|
||||||
|
};
|
||||||
245
apps/live/src/lib/pdf/styles.ts
Normal file
245
apps/live/src/lib/pdf/styles.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { StyleSheet } from "@react-pdf/renderer";
|
||||||
|
import {
|
||||||
|
BACKGROUND_COLORS,
|
||||||
|
BORDER_COLORS,
|
||||||
|
BRAND_COLORS,
|
||||||
|
CODE_COLORS,
|
||||||
|
LINK_COLORS,
|
||||||
|
MENTION_COLORS,
|
||||||
|
NEUTRAL_COLORS,
|
||||||
|
TEXT_COLORS,
|
||||||
|
} from "./colors";
|
||||||
|
|
||||||
|
export const pdfStyles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
padding: 40,
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 20,
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
},
|
||||||
|
heading1: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
},
|
||||||
|
heading2: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 14,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
},
|
||||||
|
heading3: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
},
|
||||||
|
heading4: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: TEXT_COLORS.secondary,
|
||||||
|
},
|
||||||
|
heading5: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: TEXT_COLORS.secondary,
|
||||||
|
},
|
||||||
|
heading6: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: TEXT_COLORS.tertiary,
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
paragraphWrapper: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong
|
||||||
|
paddingLeft: 12,
|
||||||
|
marginLeft: 0,
|
||||||
|
marginVertical: 8,
|
||||||
|
fontStyle: "normal", // Matches editor: font-style: normal
|
||||||
|
fontWeight: 400, // Matches editor: font-weight: 400
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
breakInside: "avoid",
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: "Courier",
|
||||||
|
fontSize: 10,
|
||||||
|
marginVertical: 8,
|
||||||
|
color: TEXT_COLORS.primary,
|
||||||
|
breakInside: "avoid",
|
||||||
|
},
|
||||||
|
codeInline: {
|
||||||
|
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||||
|
padding: 2,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
fontFamily: "Courier",
|
||||||
|
fontSize: 10,
|
||||||
|
color: CODE_COLORS.text, // Red for inline code
|
||||||
|
},
|
||||||
|
bulletList: {
|
||||||
|
marginVertical: 8,
|
||||||
|
paddingLeft: 0,
|
||||||
|
},
|
||||||
|
orderedList: {
|
||||||
|
marginVertical: 8,
|
||||||
|
paddingLeft: 0,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingRight: 10,
|
||||||
|
breakInside: "avoid",
|
||||||
|
},
|
||||||
|
listItemBullet: {},
|
||||||
|
listItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
taskList: {
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
taskItem: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingRight: 10,
|
||||||
|
breakInside: "avoid",
|
||||||
|
},
|
||||||
|
taskCheckbox: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: BORDER_COLORS.strong, // Matches editor: border-strong
|
||||||
|
borderRadius: 2,
|
||||||
|
marginTop: 2,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
taskCheckboxChecked: {
|
||||||
|
backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary
|
||||||
|
borderColor: BRAND_COLORS.default, // --border-color-accent-strong
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
marginVertical: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: BORDER_COLORS.subtle1, // border-subtle-1
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: BORDER_COLORS.subtle1,
|
||||||
|
breakInside: "avoid",
|
||||||
|
},
|
||||||
|
tableHeaderRow: {
|
||||||
|
backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white
|
||||||
|
flexDirection: "row",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: BORDER_COLORS.subtle1,
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
padding: 8,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: BORDER_COLORS.subtle1,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tableHeaderCell: {
|
||||||
|
padding: 8,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: BORDER_COLORS.subtle1,
|
||||||
|
flex: 1,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
horizontalRule: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1
|
||||||
|
marginVertical: 16,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
maxWidth: "100%",
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
imagePlaceholder: {
|
||||||
|
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginVertical: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: BORDER_COLORS.subtle,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
},
|
||||||
|
imagePlaceholderText: {
|
||||||
|
color: TEXT_COLORS.tertiary,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
callout: {
|
||||||
|
backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background)
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginVertical: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
breakInside: "avoid",
|
||||||
|
},
|
||||||
|
calloutIconContainer: {
|
||||||
|
marginRight: 10,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
calloutContent: {
|
||||||
|
flex: 1,
|
||||||
|
color: TEXT_COLORS.primary, // text-primary
|
||||||
|
},
|
||||||
|
mention: {
|
||||||
|
backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent
|
||||||
|
color: MENTION_COLORS.text, // text-accent-primary
|
||||||
|
padding: 2,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: LINK_COLORS.primary, // --txt-link-primary
|
||||||
|
textDecoration: "underline",
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
italic: {
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
underline: {
|
||||||
|
textDecoration: "underline",
|
||||||
|
},
|
||||||
|
strike: {
|
||||||
|
textDecoration: "line-through",
|
||||||
|
},
|
||||||
|
});
|
||||||
67
apps/live/src/lib/pdf/types.ts
Normal file
67
apps/live/src/lib/pdf/types.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Style } from "@react-pdf/types";
|
||||||
|
|
||||||
|
export type TipTapMark = {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TipTapNode = {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
content?: TipTapNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: TipTapMark[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TipTapDocument = {
|
||||||
|
type: "doc";
|
||||||
|
content?: TipTapNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KeyGenerator = () => string;
|
||||||
|
|
||||||
|
export type PDFRenderContext = {
|
||||||
|
getKey: KeyGenerator;
|
||||||
|
metadata?: PDFExportMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PDFNodeRenderer = (
|
||||||
|
node: TipTapNode,
|
||||||
|
children: React.ReactElement[],
|
||||||
|
context: PDFRenderContext
|
||||||
|
) => React.ReactElement;
|
||||||
|
|
||||||
|
export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style;
|
||||||
|
|
||||||
|
export type NodeRendererRegistry = Record<string, PDFNodeRenderer>;
|
||||||
|
|
||||||
|
export type MarkRendererRegistry = Record<string, PDFMarkRenderer>;
|
||||||
|
|
||||||
|
export type PDFExportOptions = {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
subject?: string;
|
||||||
|
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||||
|
pageOrientation?: "portrait" | "landscape";
|
||||||
|
metadata?: PDFExportMetadata;
|
||||||
|
/** When true, images and other assets are excluded from the PDF */
|
||||||
|
noAssets?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for resolving entity references in PDF export
|
||||||
|
*/
|
||||||
|
export type PDFExportMetadata = {
|
||||||
|
/** User mentions (user_mention in mention node) */
|
||||||
|
userMentions?: PDFUserMention[];
|
||||||
|
/** Resolved image URLs: Map of asset ID to presigned URL */
|
||||||
|
resolvedImageUrls?: Record<string, string>;
|
||||||
|
/** When true, images and other assets are excluded from the PDF */
|
||||||
|
noAssets?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PDFUserMention = {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
61
apps/live/src/schema/pdf-export.ts
Normal file
61
apps/live/src/schema/pdf-export.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
export const PdfExportRequestBody = Schema.Struct({
|
||||||
|
pageId: Schema.NonEmptyTrimmedString,
|
||||||
|
workspaceSlug: Schema.NonEmptyTrimmedString,
|
||||||
|
projectId: Schema.optional(Schema.NonEmptyTrimmedString),
|
||||||
|
title: Schema.optional(Schema.String),
|
||||||
|
author: Schema.optional(Schema.String),
|
||||||
|
subject: Schema.optional(Schema.String),
|
||||||
|
pageSize: Schema.optional(Schema.Literal("A4", "A3", "A2", "LETTER", "LEGAL", "TABLOID")),
|
||||||
|
pageOrientation: Schema.optional(Schema.Literal("portrait", "landscape")),
|
||||||
|
fileName: Schema.optional(Schema.String),
|
||||||
|
noAssets: Schema.optional(Schema.Boolean),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TPdfExportRequestBody = Schema.Schema.Type<typeof PdfExportRequestBody>;
|
||||||
|
|
||||||
|
export class PdfValidationError extends Schema.TaggedError<PdfValidationError>()("PdfValidationError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
cause: Schema.optional(Schema.Unknown),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class PdfAuthenticationError extends Schema.TaggedError<PdfAuthenticationError>()("PdfAuthenticationError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class PdfContentFetchError extends Schema.TaggedError<PdfContentFetchError>()("PdfContentFetchError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
cause: Schema.optional(Schema.Unknown),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class PdfMetadataFetchError extends Schema.TaggedError<PdfMetadataFetchError>()("PdfMetadataFetchError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
source: Schema.Literal("user-mentions"),
|
||||||
|
cause: Schema.optional(Schema.Unknown),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class PdfImageProcessingError extends Schema.TaggedError<PdfImageProcessingError>()("PdfImageProcessingError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
assetId: Schema.NonEmptyTrimmedString,
|
||||||
|
cause: Schema.optional(Schema.Unknown),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class PdfGenerationError extends Schema.TaggedError<PdfGenerationError>()("PdfGenerationError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
cause: Schema.optional(Schema.Unknown),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class PdfTimeoutError extends Schema.TaggedError<PdfTimeoutError>()("PdfTimeoutError", {
|
||||||
|
message: Schema.NonEmptyTrimmedString,
|
||||||
|
operation: Schema.NonEmptyTrimmedString,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export type PdfExportError =
|
||||||
|
| PdfValidationError
|
||||||
|
| PdfAuthenticationError
|
||||||
|
| PdfContentFetchError
|
||||||
|
| PdfMetadataFetchError
|
||||||
|
| PdfImageProcessingError
|
||||||
|
| PdfGenerationError
|
||||||
|
| PdfTimeoutError;
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
import { logger } from "@plane/logger";
|
import { logger } from "@plane/logger";
|
||||||
import type { TDocumentPayload, TPage } from "@plane/types";
|
import type { TPage } from "@plane/types";
|
||||||
// services
|
// services
|
||||||
import { AppError } from "@/lib/errors";
|
import { AppError } from "@/lib/errors";
|
||||||
import { APIService } from "../api.service";
|
import { APIService } from "../api.service";
|
||||||
|
|
||||||
|
export type TPageDescriptionPayload = {
|
||||||
|
description_binary: string;
|
||||||
|
description_html: string;
|
||||||
|
description: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUserMention = {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export abstract class PageCoreService extends APIService {
|
export abstract class PageCoreService extends APIService {
|
||||||
protected abstract basePath: string;
|
protected abstract basePath: string;
|
||||||
|
|
||||||
|
|
@ -12,35 +24,41 @@ export abstract class PageCoreService extends APIService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchDetails(pageId: string): Promise<TPage> {
|
async fetchDetails(pageId: string): Promise<TPage> {
|
||||||
return this.get(`${this.basePath}/pages/${pageId}/`, {
|
try {
|
||||||
|
const response = await this.get(`${this.basePath}/pages/${pageId}/`, {
|
||||||
headers: this.getHeader(),
|
headers: this.getHeader(),
|
||||||
})
|
});
|
||||||
.then((response) => response?.data)
|
return response?.data as TPage;
|
||||||
.catch((error) => {
|
} catch (error) {
|
||||||
const appError = new AppError(error, {
|
const appError = new AppError(error, {
|
||||||
context: { operation: "fetchDetails", pageId },
|
context: { operation: "fetchDetails", pageId },
|
||||||
});
|
});
|
||||||
logger.error("Failed to fetch page details", appError);
|
logger.error("Failed to fetch page details", appError);
|
||||||
throw appError;
|
throw appError;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchDescriptionBinary(pageId: string): Promise<any> {
|
async fetchDescriptionBinary(pageId: string): Promise<Buffer> {
|
||||||
return this.get(`${this.basePath}/pages/${pageId}/description/`, {
|
try {
|
||||||
|
const response = await this.get(`${this.basePath}/pages/${pageId}/description/`, {
|
||||||
headers: {
|
headers: {
|
||||||
...this.getHeader(),
|
...this.getHeader(),
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
},
|
},
|
||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
})
|
});
|
||||||
.then((response) => response?.data)
|
const data = response?.data;
|
||||||
.catch((error) => {
|
if (!Buffer.isBuffer(data)) {
|
||||||
|
throw new Error("Expected response to be a Buffer");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
const appError = new AppError(error, {
|
const appError = new AppError(error, {
|
||||||
context: { operation: "fetchDescriptionBinary", pageId },
|
context: { operation: "fetchDescriptionBinary", pageId },
|
||||||
});
|
});
|
||||||
logger.error("Failed to fetch page description binary", appError);
|
logger.error("Failed to fetch page description binary", appError);
|
||||||
throw appError;
|
throw appError;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,17 +115,113 @@ export abstract class PageCoreService extends APIService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise<any> {
|
async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise<any> {
|
||||||
return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
|
try {
|
||||||
|
const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
|
||||||
headers: this.getHeader(),
|
headers: this.getHeader(),
|
||||||
})
|
});
|
||||||
.then((response) => response?.data)
|
return response?.data as unknown;
|
||||||
.catch((error) => {
|
} catch (error) {
|
||||||
const appError = new AppError(error, {
|
const appError = new AppError(error, {
|
||||||
context: { operation: "updateDescriptionBinary", pageId },
|
context: { operation: "updateDescriptionBinary", pageId },
|
||||||
});
|
});
|
||||||
logger.error("Failed to update page description binary", appError);
|
logger.error("Failed to update page description binary", appError);
|
||||||
throw appError;
|
throw appError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user mentions for a page
|
||||||
|
* @param pageId - The page ID
|
||||||
|
* @returns Array of user mentions
|
||||||
|
*/
|
||||||
|
async fetchUserMentions(pageId: string): Promise<TUserMention[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.get(`${this.basePath}/pages/${pageId}/mentions/`, {
|
||||||
|
headers: this.getHeader(),
|
||||||
|
params: {
|
||||||
|
mention_type: "user_mention",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return (response?.data as TUserMention[]) ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
const appError = new AppError(error, {
|
||||||
|
context: { operation: "fetchUserMentions", pageId },
|
||||||
|
});
|
||||||
|
logger.error("Failed to fetch user mentions", appError);
|
||||||
|
throw appError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an image asset ID to its actual URL by following the 302 redirect
|
||||||
|
* @param workspaceSlug - The workspace slug
|
||||||
|
* @param assetId - The asset UUID
|
||||||
|
* @param projectId - Optional project ID for project-specific assets
|
||||||
|
* @returns The resolved image URL (presigned S3 URL)
|
||||||
|
*/
|
||||||
|
async resolveImageAssetUrl(
|
||||||
|
workspaceSlug: string,
|
||||||
|
assetId: string,
|
||||||
|
projectId?: string | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
const path = projectId
|
||||||
|
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
|
||||||
|
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.get(path, {
|
||||||
|
headers: this.getHeader(),
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status: number) => status >= 200 && status < 400,
|
||||||
|
});
|
||||||
|
// If we get a 302, the Location header contains the presigned URL
|
||||||
|
if (response.status === 302 || response.status === 301) {
|
||||||
|
return response.headers?.location || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
// Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error
|
||||||
|
if ((error as any).response?.status === 302 || (error as any).response?.status === 301) {
|
||||||
|
return (error as any).response.headers?.location || null;
|
||||||
|
}
|
||||||
|
logger.error("Failed to resolve image asset URL", {
|
||||||
|
assetId,
|
||||||
|
workspaceSlug,
|
||||||
|
error: (error as any).message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves multiple image asset IDs to their actual URLs
|
||||||
|
* @param workspaceSlug - The workspace slug
|
||||||
|
* @param assetIds - Array of asset UUIDs
|
||||||
|
* @param projectId - Optional project ID for project-specific assets
|
||||||
|
* @returns Map of assetId to resolved URL
|
||||||
|
*/
|
||||||
|
async resolveImageAssetUrls(
|
||||||
|
workspaceSlug: string,
|
||||||
|
assetIds: string[],
|
||||||
|
projectId?: string | null
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const urlMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// Resolve all asset URLs in parallel
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
assetIds.map(async (assetId) => {
|
||||||
|
const url = await this.resolveImageAssetUrl(workspaceSlug, assetId, projectId);
|
||||||
|
return { assetId, url };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === "fulfilled" && result.value.url) {
|
||||||
|
urlMap.set(result.value.assetId, result.value.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlMap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
apps/live/src/services/pdf-export/effect-utils.ts
Normal file
50
apps/live/src/services/pdf-export/effect-utils.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Effect, Duration, Schedule, pipe } from "effect";
|
||||||
|
import { PdfTimeoutError } from "@/schema/pdf-export";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an effect with timeout and exponential backoff retry logic.
|
||||||
|
* Preserves the environment type R for proper dependency injection.
|
||||||
|
*/
|
||||||
|
export const withTimeoutAndRetry =
|
||||||
|
(operation: string, { timeoutMs = 5000, maxRetries = 2 }: { timeoutMs?: number; maxRetries?: number } = {}) =>
|
||||||
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | PdfTimeoutError, R> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.timeoutFail({
|
||||||
|
duration: Duration.millis(timeoutMs),
|
||||||
|
onTimeout: () =>
|
||||||
|
new PdfTimeoutError({
|
||||||
|
message: `Operation "${operation}" timed out after ${timeoutMs}ms`,
|
||||||
|
operation,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Effect.retry(
|
||||||
|
pipe(
|
||||||
|
Schedule.exponential(Duration.millis(200)),
|
||||||
|
Schedule.compose(Schedule.recurs(maxRetries)),
|
||||||
|
Schedule.tapInput((error: E | PdfTimeoutError) =>
|
||||||
|
Effect.logWarning("PDF_EXPORT: Retrying operation", { operation, error })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovers from any error with a default fallback value.
|
||||||
|
* Logs the error before recovering.
|
||||||
|
*/
|
||||||
|
export const recoverWithDefault =
|
||||||
|
<A>(fallback: A) =>
|
||||||
|
<E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, never, R> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.tapError((error) => Effect.logWarning("PDF_EXPORT: Operation failed, using fallback", { error })),
|
||||||
|
Effect.catchAll(() => Effect.succeed(fallback))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a promise-returning function with proper Effect error handling
|
||||||
|
*/
|
||||||
|
export const tryAsync = <A, E>(fn: () => Promise<A>, onError: (cause: unknown) => E): Effect.Effect<A, E> =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: fn,
|
||||||
|
catch: onError,
|
||||||
|
});
|
||||||
3
apps/live/src/services/pdf-export/index.ts
Normal file
3
apps/live/src/services/pdf-export/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { PdfExportService, exportToPdf } from "./pdf-export.service";
|
||||||
|
export * from "./effect-utils";
|
||||||
|
export * from "./types";
|
||||||
373
apps/live/src/services/pdf-export/pdf-export.service.ts
Normal file
373
apps/live/src/services/pdf-export/pdf-export.service.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib";
|
||||||
|
import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf";
|
||||||
|
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
|
||||||
|
import { getPageService } from "@/services/page/handler";
|
||||||
|
import type { TDocumentTypes } from "@/types";
|
||||||
|
import {
|
||||||
|
PdfContentFetchError,
|
||||||
|
PdfGenerationError,
|
||||||
|
PdfImageProcessingError,
|
||||||
|
PdfTimeoutError,
|
||||||
|
} from "@/schema/pdf-export";
|
||||||
|
import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "./effect-utils";
|
||||||
|
import type { PdfExportInput, PdfExportResult, PageContent, MetadataResult } from "./types";
|
||||||
|
|
||||||
|
const IMAGE_CONCURRENCY = 4;
|
||||||
|
const IMAGE_TIMEOUT_MS = 8000;
|
||||||
|
const CONTENT_FETCH_TIMEOUT_MS = 7000;
|
||||||
|
const PDF_RENDER_TIMEOUT_MS = 15000;
|
||||||
|
const IMAGE_MAX_DIMENSION = 1200;
|
||||||
|
|
||||||
|
type TipTapNode = {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
content?: TipTapNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF Export Service
|
||||||
|
*/
|
||||||
|
export class PdfExportService extends Effect.Service<PdfExportService>()("PdfExportService", {
|
||||||
|
sync: () => ({
|
||||||
|
/**
|
||||||
|
* Determines document type
|
||||||
|
*/
|
||||||
|
getDocumentType: (_input: PdfExportInput): TDocumentTypes => {
|
||||||
|
return "project_page";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts image asset IDs from document content
|
||||||
|
*/
|
||||||
|
extractImageAssetIds: (doc: TipTapNode): string[] => {
|
||||||
|
const assetIds: string[] = [];
|
||||||
|
|
||||||
|
const traverse = (node: TipTapNode) => {
|
||||||
|
if ((node.type === "imageComponent" || node.type === "image") && node.attrs?.src) {
|
||||||
|
const src = node.attrs.src as string;
|
||||||
|
if (src && !src.startsWith("http") && !src.startsWith("data:")) {
|
||||||
|
assetIds.push(src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.content) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
traverse(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(doc);
|
||||||
|
return [...new Set(assetIds)];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches page content (description binary) and parses it
|
||||||
|
*/
|
||||||
|
fetchPageContent: (
|
||||||
|
pageService: ReturnType<typeof getPageService>,
|
||||||
|
pageId: string,
|
||||||
|
requestId: string
|
||||||
|
): Effect.Effect<PageContent, PdfContentFetchError | PdfTimeoutError> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.logDebug("PDF_EXPORT: Fetching page content", { requestId, pageId });
|
||||||
|
|
||||||
|
const descriptionBinary = yield* tryAsync(
|
||||||
|
() => pageService.fetchDescriptionBinary(pageId),
|
||||||
|
(cause) =>
|
||||||
|
new PdfContentFetchError({
|
||||||
|
message: "Failed to fetch page content",
|
||||||
|
cause,
|
||||||
|
})
|
||||||
|
).pipe(
|
||||||
|
withTimeoutAndRetry("fetch page content", {
|
||||||
|
timeoutMs: CONTENT_FETCH_TIMEOUT_MS,
|
||||||
|
maxRetries: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!descriptionBinary) {
|
||||||
|
return yield* Effect.fail(
|
||||||
|
new PdfContentFetchError({
|
||||||
|
message: "Page content not found",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryData = new Uint8Array(descriptionBinary);
|
||||||
|
const { contentJSON, titleHTML } = getAllDocumentFormatsFromDocumentEditorBinaryData(binaryData, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentJSON: contentJSON as TipTapDocument,
|
||||||
|
titleHTML: titleHTML || null,
|
||||||
|
descriptionBinary,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user mentions for the page
|
||||||
|
*/
|
||||||
|
fetchUserMentions: (
|
||||||
|
pageService: ReturnType<typeof getPageService>,
|
||||||
|
pageId: string,
|
||||||
|
requestId: string
|
||||||
|
): Effect.Effect<MetadataResult> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.logDebug("PDF_EXPORT: Fetching user mentions", { requestId });
|
||||||
|
|
||||||
|
const userMentionsRaw = yield* tryAsync(
|
||||||
|
async () => {
|
||||||
|
if (pageService.fetchUserMentions) {
|
||||||
|
return await pageService.fetchUserMentions(pageId);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
() => []
|
||||||
|
).pipe(recoverWithDefault([] as Array<{ id: string; display_name: string; avatar_url?: string }>));
|
||||||
|
|
||||||
|
return {
|
||||||
|
userMentions: userMentionsRaw.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
display_name: u.display_name,
|
||||||
|
avatar_url: u.avatar_url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves and processes images for PDF embedding
|
||||||
|
*/
|
||||||
|
processImages: (
|
||||||
|
pageService: ReturnType<typeof getPageService>,
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string | undefined,
|
||||||
|
assetIds: string[],
|
||||||
|
requestId: string
|
||||||
|
): Effect.Effect<Record<string, string>> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (assetIds.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.logDebug("PDF_EXPORT: Processing images", {
|
||||||
|
requestId,
|
||||||
|
count: assetIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve URLs first
|
||||||
|
const resolvedUrlMap = yield* tryAsync(
|
||||||
|
async () => {
|
||||||
|
const urlMap = new Map<string, string>();
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId);
|
||||||
|
if (url) urlMap.set(assetId, url);
|
||||||
|
}
|
||||||
|
return urlMap;
|
||||||
|
},
|
||||||
|
() => new Map<string, string>()
|
||||||
|
).pipe(recoverWithDefault(new Map<string, string>()));
|
||||||
|
|
||||||
|
if (resolvedUrlMap.size === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each image
|
||||||
|
const processSingleImage = ([assetId, url]: [string, string]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* tryAsync(
|
||||||
|
() => fetch(url),
|
||||||
|
(cause) =>
|
||||||
|
new PdfImageProcessingError({
|
||||||
|
message: "Failed to fetch image",
|
||||||
|
assetId,
|
||||||
|
cause,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return yield* Effect.fail(
|
||||||
|
new PdfImageProcessingError({
|
||||||
|
message: `Image fetch returned ${response.status}`,
|
||||||
|
assetId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = yield* tryAsync(
|
||||||
|
() => response.arrayBuffer(),
|
||||||
|
(cause) =>
|
||||||
|
new PdfImageProcessingError({
|
||||||
|
message: "Failed to read image body",
|
||||||
|
assetId,
|
||||||
|
cause,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const processedBuffer = yield* tryAsync(
|
||||||
|
() =>
|
||||||
|
sharp(Buffer.from(arrayBuffer))
|
||||||
|
.rotate()
|
||||||
|
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
||||||
|
.resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toBuffer(),
|
||||||
|
(cause) =>
|
||||||
|
new PdfImageProcessingError({
|
||||||
|
message: "Failed to process image",
|
||||||
|
assetId,
|
||||||
|
cause,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const base64 = processedBuffer.toString("base64");
|
||||||
|
return [assetId, `data:image/jpeg;base64,${base64}`] as const;
|
||||||
|
}).pipe(
|
||||||
|
withTimeoutAndRetry(`process image ${assetId}`, {
|
||||||
|
timeoutMs: IMAGE_TIMEOUT_MS,
|
||||||
|
maxRetries: 1,
|
||||||
|
}),
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.logWarning("PDF_EXPORT: Image processing failed", {
|
||||||
|
requestId,
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null))
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = Array.from(resolvedUrlMap.entries());
|
||||||
|
const pairs = yield* Effect.forEach(entries, processSingleImage, {
|
||||||
|
concurrency: IMAGE_CONCURRENCY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = pairs.filter((p): p is readonly [string, string] => p !== null);
|
||||||
|
return Object.fromEntries(filtered);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders document to PDF buffer
|
||||||
|
*/
|
||||||
|
renderPdf: (
|
||||||
|
contentJSON: TipTapDocument,
|
||||||
|
metadata: PDFExportMetadata,
|
||||||
|
options: {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
subject?: string;
|
||||||
|
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||||
|
pageOrientation?: "portrait" | "landscape";
|
||||||
|
noAssets?: boolean;
|
||||||
|
},
|
||||||
|
requestId: string
|
||||||
|
): Effect.Effect<Buffer, PdfGenerationError | PdfTimeoutError> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.logDebug("PDF_EXPORT: Rendering PDF", { requestId });
|
||||||
|
|
||||||
|
const pdfBuffer = yield* tryAsync(
|
||||||
|
() =>
|
||||||
|
renderPlaneDocToPdfBuffer(contentJSON, {
|
||||||
|
title: options.title,
|
||||||
|
author: options.author,
|
||||||
|
subject: options.subject,
|
||||||
|
pageSize: options.pageSize,
|
||||||
|
pageOrientation: options.pageOrientation,
|
||||||
|
metadata,
|
||||||
|
noAssets: options.noAssets,
|
||||||
|
}),
|
||||||
|
(cause) =>
|
||||||
|
new PdfGenerationError({
|
||||||
|
message: "Failed to render PDF",
|
||||||
|
cause,
|
||||||
|
})
|
||||||
|
).pipe(withTimeoutAndRetry("render PDF", { timeoutMs: PDF_RENDER_TIMEOUT_MS, maxRetries: 0 }));
|
||||||
|
|
||||||
|
yield* Effect.logInfo("PDF_EXPORT: PDF rendered successfully", {
|
||||||
|
requestId,
|
||||||
|
size: pdfBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return pdfBuffer;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main export pipeline - orchestrates the entire PDF export process
|
||||||
|
* Separate function to avoid circular dependency in service definition
|
||||||
|
*/
|
||||||
|
export const exportToPdf = (
|
||||||
|
input: PdfExportInput
|
||||||
|
): Effect.Effect<PdfExportResult, PdfContentFetchError | PdfGenerationError | PdfTimeoutError, PdfExportService> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* PdfExportService;
|
||||||
|
const { requestId, pageId, workspaceSlug, projectId, noAssets } = input;
|
||||||
|
|
||||||
|
yield* Effect.logInfo("PDF_EXPORT: Starting export", { requestId, pageId, workspaceSlug });
|
||||||
|
|
||||||
|
// Create page service
|
||||||
|
const documentType = service.getDocumentType(input);
|
||||||
|
const pageService = getPageService(documentType, {
|
||||||
|
workspaceSlug,
|
||||||
|
projectId: projectId || null,
|
||||||
|
cookie: input.cookie,
|
||||||
|
documentType,
|
||||||
|
userId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch content
|
||||||
|
const content = yield* service.fetchPageContent(pageService, pageId, requestId);
|
||||||
|
|
||||||
|
// Extract image asset IDs
|
||||||
|
const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode);
|
||||||
|
|
||||||
|
// Fetch user mentions
|
||||||
|
let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId);
|
||||||
|
|
||||||
|
// Process images if needed
|
||||||
|
if (!noAssets && imageAssetIds.length > 0) {
|
||||||
|
const resolvedImages = yield* service.processImages(
|
||||||
|
pageService,
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
imageAssetIds,
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
metadata = { ...metadata, resolvedImageUrls: resolvedImages };
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", {
|
||||||
|
requestId,
|
||||||
|
userMentions: metadata.userMentions?.length ?? 0,
|
||||||
|
resolvedImages: Object.keys(metadata.resolvedImageUrls ?? {}).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render PDF
|
||||||
|
const documentTitle = input.title || content.titleHTML || undefined;
|
||||||
|
const pdfBuffer = yield* service.renderPdf(
|
||||||
|
content.contentJSON,
|
||||||
|
metadata,
|
||||||
|
{
|
||||||
|
title: documentTitle,
|
||||||
|
author: input.author,
|
||||||
|
subject: input.subject,
|
||||||
|
pageSize: input.pageSize,
|
||||||
|
pageOrientation: input.pageOrientation,
|
||||||
|
noAssets,
|
||||||
|
},
|
||||||
|
requestId
|
||||||
|
);
|
||||||
|
|
||||||
|
yield* Effect.logInfo("PDF_EXPORT: Export complete", {
|
||||||
|
requestId,
|
||||||
|
pageId,
|
||||||
|
size: pdfBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pdfBuffer,
|
||||||
|
outputFileName: input.fileName || `page-${pageId}.pdf`,
|
||||||
|
pageId,
|
||||||
|
};
|
||||||
|
});
|
||||||
36
apps/live/src/services/pdf-export/types.ts
Normal file
36
apps/live/src/services/pdf-export/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { TipTapDocument, PDFUserMention } from "@/lib/pdf";
|
||||||
|
|
||||||
|
export interface PdfExportInput {
|
||||||
|
readonly pageId: string;
|
||||||
|
readonly workspaceSlug: string;
|
||||||
|
readonly projectId?: string;
|
||||||
|
readonly title?: string;
|
||||||
|
readonly author?: string;
|
||||||
|
readonly subject?: string;
|
||||||
|
readonly pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||||
|
readonly pageOrientation?: "portrait" | "landscape";
|
||||||
|
readonly fileName?: string;
|
||||||
|
readonly noAssets?: boolean;
|
||||||
|
readonly cookie: string;
|
||||||
|
readonly requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfExportResult {
|
||||||
|
readonly pdfBuffer: Buffer;
|
||||||
|
readonly outputFileName: string;
|
||||||
|
readonly pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageContent {
|
||||||
|
readonly contentJSON: TipTapDocument;
|
||||||
|
readonly titleHTML: string | null;
|
||||||
|
readonly descriptionBinary: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata - includes user mentions
|
||||||
|
*/
|
||||||
|
export interface MetadataResult {
|
||||||
|
readonly userMentions: PDFUserMention[];
|
||||||
|
readonly resolvedImageUrls?: Record<string, string>;
|
||||||
|
}
|
||||||
727
apps/live/tests/lib/pdf/pdf-rendering.test.ts
Normal file
727
apps/live/tests/lib/pdf/pdf-rendering.test.ts
Normal file
|
|
@ -0,0 +1,727 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { PDFParse } from "pdf-parse";
|
||||||
|
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
|
||||||
|
import type { TipTapDocument, PDFExportMetadata } from "@/lib/pdf";
|
||||||
|
|
||||||
|
const PDF_HEADER = "%PDF-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to extract text content from a PDF buffer
|
||||||
|
*/
|
||||||
|
async function extractPdfText(buffer: Buffer): Promise<string> {
|
||||||
|
const uint8 = new Uint8Array(buffer);
|
||||||
|
const parser = new PDFParse(uint8);
|
||||||
|
const result = await parser.getText();
|
||||||
|
return result.pages.map((p) => p.text).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PDF Rendering Integration", () => {
|
||||||
|
describe("renderPlaneDocToPdfBuffer", () => {
|
||||||
|
it("should render empty document to valid PDF", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
|
||||||
|
expect(buffer).toBeInstanceOf(Buffer);
|
||||||
|
expect(buffer.length).toBeGreaterThan(0);
|
||||||
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render document with title and verify content", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello World" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||||
|
title: "Test Document",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buffer).toBeInstanceOf(Buffer);
|
||||||
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||||
|
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
expect(text).toContain("Hello World");
|
||||||
|
// Title is rendered in PDF content when provided
|
||||||
|
expect(text).toContain("Test Document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render heading nodes and verify text", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: [{ type: "text", text: "Main Heading" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Subheading" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Main Heading");
|
||||||
|
expect(text).toContain("Subheading");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render paragraph with text and verify content", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "This is a test paragraph with some content." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("This is a test paragraph with some content.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render bullet list with all items", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "First item" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Second item" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Third item" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("First item");
|
||||||
|
expect(text).toContain("Second item");
|
||||||
|
expect(text).toContain("Third item");
|
||||||
|
// Bullet points should be present
|
||||||
|
expect(text).toContain("•");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render ordered list with numbers", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "orderedList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Step one" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Step two" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Step one");
|
||||||
|
expect(text).toContain("Step two");
|
||||||
|
// Numbers should be present
|
||||||
|
expect(text).toMatch(/1\./);
|
||||||
|
expect(text).toMatch(/2\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render task list with task text", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "taskList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "taskItem",
|
||||||
|
attrs: { checked: true },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Completed task" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "taskItem",
|
||||||
|
attrs: { checked: false },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Pending task" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Completed task");
|
||||||
|
expect(text).toContain("Pending task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render code block with code content", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "codeBlock",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "const greeting = 'Hello';\n" },
|
||||||
|
{ type: "text", text: "console.log(greeting);" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("const greeting");
|
||||||
|
expect(text).toContain("console.log");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render blockquote with quoted text", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "blockquote",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "This is a quoted text." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("This is a quoted text.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render table with all cell content", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableHeader",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Header 1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tableHeader",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Header 2" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableCell",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Cell 1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tableCell",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Cell 2" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Header 1");
|
||||||
|
expect(text).toContain("Header 2");
|
||||||
|
expect(text).toContain("Cell 1");
|
||||||
|
expect(text).toContain("Cell 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render horizontal rule with surrounding text", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Before rule" }],
|
||||||
|
},
|
||||||
|
{ type: "horizontalRule" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "After rule" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Before rule");
|
||||||
|
expect(text).toContain("After rule");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render text with marks (bold, italic) preserving content", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Normal " },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "bold",
|
||||||
|
marks: [{ type: "bold" }],
|
||||||
|
},
|
||||||
|
{ type: "text", text: " and " },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "italic",
|
||||||
|
marks: [{ type: "italic" }],
|
||||||
|
},
|
||||||
|
{ type: "text", text: " text." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Normal");
|
||||||
|
expect(text).toContain("bold");
|
||||||
|
expect(text).toContain("italic");
|
||||||
|
expect(text).toContain("text.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render link marks with link text", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Click " },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "here",
|
||||||
|
marks: [{ type: "link", attrs: { href: "https://example.com" } }],
|
||||||
|
},
|
||||||
|
{ type: "text", text: " to visit." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Click");
|
||||||
|
expect(text).toContain("here");
|
||||||
|
expect(text).toContain("to visit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("page options", () => {
|
||||||
|
it("should support different page sizes and verify content renders", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Page size test content" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const a4Buffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "A4" });
|
||||||
|
const letterBuffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "LETTER" });
|
||||||
|
|
||||||
|
const a4Text = await extractPdfText(a4Buffer);
|
||||||
|
const letterText = await extractPdfText(letterBuffer);
|
||||||
|
|
||||||
|
expect(a4Text).toContain("Page size test content");
|
||||||
|
expect(letterText).toContain("Page size test content");
|
||||||
|
// Different page sizes should produce different PDF sizes
|
||||||
|
expect(a4Buffer.length).not.toBe(letterBuffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support landscape orientation and verify content", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Landscape content here" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const portraitBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "portrait" });
|
||||||
|
const landscapeBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "landscape" });
|
||||||
|
|
||||||
|
const portraitText = await extractPdfText(portraitBuffer);
|
||||||
|
const landscapeText = await extractPdfText(landscapeBuffer);
|
||||||
|
|
||||||
|
expect(portraitText).toContain("Landscape content here");
|
||||||
|
expect(landscapeText).toContain("Landscape content here");
|
||||||
|
expect(portraitBuffer.length).not.toBe(landscapeBuffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include author metadata in PDF", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Document content" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||||
|
author: "Test Author",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify PDF is valid and contains content
|
||||||
|
expect(buffer).toBeInstanceOf(Buffer);
|
||||||
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||||
|
// Author metadata is embedded in PDF info dict (checked via raw bytes)
|
||||||
|
const pdfString = buffer.toString("latin1");
|
||||||
|
expect(pdfString).toContain("/Author");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include subject metadata in PDF", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Document content" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||||
|
subject: "Technical Documentation",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify PDF is valid
|
||||||
|
expect(buffer).toBeInstanceOf(Buffer);
|
||||||
|
expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER);
|
||||||
|
// Subject metadata is embedded in PDF info dict
|
||||||
|
const pdfString = buffer.toString("latin1");
|
||||||
|
expect(pdfString).toContain("/Subject");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metadata rendering", () => {
|
||||||
|
it("should render user mentions with resolved display name", async () => {
|
||||||
|
const metadata: PDFExportMetadata = {
|
||||||
|
userMentions: [{ id: "user-123", display_name: "John Doe" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Hello " },
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
entity_name: "user_mention",
|
||||||
|
entity_identifier: "user-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata });
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Hello");
|
||||||
|
expect(text).toContain("John Doe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("complex documents", () => {
|
||||||
|
it("should render a full document with mixed content and verify all sections", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: [{ type: "text", text: "Project Overview" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "This document describes the " },
|
||||||
|
{ type: "text", text: "key features", marks: [{ type: "bold" }] },
|
||||||
|
{ type: "text", text: " of the project." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Features" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Feature A - Core functionality" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Feature B - Advanced options" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Code Example" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "codeBlock",
|
||||||
|
content: [{ type: "text", text: "function hello() {\n return 'world';\n}" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "blockquote",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Important: Review before deployment." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "horizontalRule" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "End of document." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc, {
|
||||||
|
title: "Project Overview",
|
||||||
|
author: "Development Team",
|
||||||
|
subject: "Technical Documentation",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
// Verify metadata is embedded in PDF
|
||||||
|
const pdfString = buffer.toString("latin1");
|
||||||
|
expect(pdfString).toContain("/Title");
|
||||||
|
expect(pdfString).toContain("/Author");
|
||||||
|
expect(pdfString).toContain("/Subject");
|
||||||
|
|
||||||
|
// Verify all content sections are present
|
||||||
|
expect(text).toContain("Project Overview");
|
||||||
|
expect(text).toContain("This document describes the");
|
||||||
|
expect(text).toContain("key features");
|
||||||
|
expect(text).toContain("Features");
|
||||||
|
expect(text).toContain("Feature A - Core functionality");
|
||||||
|
expect(text).toContain("Feature B - Advanced options");
|
||||||
|
expect(text).toContain("Code Example");
|
||||||
|
expect(text).toContain("function hello");
|
||||||
|
expect(text).toContain("Important: Review before deployment");
|
||||||
|
expect(text).toContain("End of document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render deeply nested lists with all levels", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Level 1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Level 2" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Level 3" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc);
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Level 1");
|
||||||
|
expect(text).toContain("Level 2");
|
||||||
|
expect(text).toContain("Level 3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("noAssets option", () => {
|
||||||
|
it("should render text but skip images when noAssets is true", async () => {
|
||||||
|
const doc: TipTapDocument = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
attrs: { src: "https://example.com/image.png" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Text after image" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await renderPlaneDocToPdfBuffer(doc, { noAssets: true });
|
||||||
|
const text = await extractPdfText(buffer);
|
||||||
|
|
||||||
|
expect(text).toContain("Text after image");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
149
apps/live/tests/services/pdf-export/effect-utils.test.ts
Normal file
149
apps/live/tests/services/pdf-export/effect-utils.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { describe, it, expect, assert } from "vitest";
|
||||||
|
import { Effect, Duration, Either } from "effect";
|
||||||
|
import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "@/services/pdf-export/effect-utils";
|
||||||
|
import { PdfTimeoutError } from "@/schema/pdf-export";
|
||||||
|
|
||||||
|
describe("effect-utils", () => {
|
||||||
|
describe("withTimeoutAndRetry", () => {
|
||||||
|
it("should succeed when effect completes within timeout", async () => {
|
||||||
|
const effect = Effect.succeed("success");
|
||||||
|
const wrapped = withTimeoutAndRetry("test-operation")(effect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(wrapped);
|
||||||
|
expect(result).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with PdfTimeoutError when effect exceeds timeout", async () => {
|
||||||
|
const slowEffect = Effect.gen(function* () {
|
||||||
|
yield* Effect.sleep(Duration.millis(500));
|
||||||
|
return "success";
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = withTimeoutAndRetry("test-operation", {
|
||||||
|
timeoutMs: 50,
|
||||||
|
maxRetries: 0,
|
||||||
|
})(slowEffect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.either(wrapped));
|
||||||
|
|
||||||
|
assert(Either.isLeft(result), "Expected Left but got Right");
|
||||||
|
expect(result.left).toBeInstanceOf(PdfTimeoutError);
|
||||||
|
expect((result.left as PdfTimeoutError).operation).toBe("test-operation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retry on failure up to maxRetries times", async () => {
|
||||||
|
const attemptCounter = { count: 0 };
|
||||||
|
|
||||||
|
const flakyEffect = Effect.gen(function* () {
|
||||||
|
attemptCounter.count++;
|
||||||
|
if (attemptCounter.count < 3) {
|
||||||
|
return yield* Effect.fail(new Error("transient failure"));
|
||||||
|
}
|
||||||
|
return "success";
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = withTimeoutAndRetry("test-operation", {
|
||||||
|
timeoutMs: 5000,
|
||||||
|
maxRetries: 3,
|
||||||
|
})(flakyEffect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(wrapped);
|
||||||
|
|
||||||
|
expect(result).toBe("success");
|
||||||
|
expect(attemptCounter.count).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail after exhausting retries", async () => {
|
||||||
|
const effect = Effect.fail(new Error("permanent failure"));
|
||||||
|
|
||||||
|
const wrapped = withTimeoutAndRetry("test-operation", {
|
||||||
|
timeoutMs: 5000,
|
||||||
|
maxRetries: 2,
|
||||||
|
})(effect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.either(wrapped));
|
||||||
|
|
||||||
|
expect(result._tag).toBe("Left");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recoverWithDefault", () => {
|
||||||
|
it("should return success value when effect succeeds", async () => {
|
||||||
|
const effect = Effect.succeed("success");
|
||||||
|
const wrapped = recoverWithDefault("fallback")(effect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(wrapped);
|
||||||
|
expect(result).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return fallback value when effect fails", async () => {
|
||||||
|
const effect = Effect.fail(new Error("failure"));
|
||||||
|
const wrapped = recoverWithDefault("fallback")(effect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(wrapped);
|
||||||
|
expect(result).toBe("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log warning when recovering from error", async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
const effect = Effect.fail(new Error("test error")).pipe(
|
||||||
|
recoverWithDefault("fallback"),
|
||||||
|
Effect.tap(() => Effect.sync(() => logs.push("after recovery")))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(effect);
|
||||||
|
|
||||||
|
expect(result).toBe("fallback");
|
||||||
|
expect(logs).toContain("after recovery");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work with complex fallback objects", async () => {
|
||||||
|
const fallback = { items: [], count: 0, metadata: { version: 1 } };
|
||||||
|
|
||||||
|
const effect = Effect.fail(new Error("failure"));
|
||||||
|
const wrapped = recoverWithDefault(fallback)(effect);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(wrapped);
|
||||||
|
expect(result).toEqual(fallback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tryAsync", () => {
|
||||||
|
it("should wrap successful promise", async () => {
|
||||||
|
const effect = tryAsync(
|
||||||
|
() => Promise.resolve("success"),
|
||||||
|
(err) => new Error(`wrapped: ${err}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(effect);
|
||||||
|
expect(result).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wrap rejected promise with custom error", async () => {
|
||||||
|
const effect = tryAsync(
|
||||||
|
() => Promise.reject(new Error("original")),
|
||||||
|
(err) => new Error(`wrapped: ${(err as Error).message}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.either(effect));
|
||||||
|
|
||||||
|
assert(Either.isLeft(result), "Expected Left but got Right");
|
||||||
|
expect(result.left.message).toBe("wrapped: original");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle synchronous throws", async () => {
|
||||||
|
const effect = tryAsync(
|
||||||
|
() => {
|
||||||
|
throw new Error("sync error");
|
||||||
|
},
|
||||||
|
(err) => new Error(`caught: ${(err as Error).message}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(Effect.either(effect));
|
||||||
|
|
||||||
|
assert(Either.isLeft(result), "Expected Left but got Right");
|
||||||
|
expect(result.left.message).toBe("caught: sync error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"noImplicitOverride": false,
|
"noImplicitOverride": false,
|
||||||
"noImplicitReturns": false,
|
"noImplicitReturns": false,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
|
|
@ -14,6 +15,6 @@
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "tests"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
apps/live/vitest.config.ts
Normal file
21
apps/live/vitest.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
include: ["tests/**/*.test.ts", "tests/**/*.spec.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
include: ["src/**/*.ts"],
|
||||||
|
exclude: ["src/**/*.d.ts", "src/**/types.ts"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -200,6 +200,7 @@ export const DescriptionInput = observer(function DescriptionInput(props: Props)
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
key={entityId}
|
||||||
editable={!disabled}
|
editable={!disabled}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
id={entityId}
|
id={entityId}
|
||||||
|
|
|
||||||
1140
pnpm-lock.yaml
generated
1140
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue