From 67d2a3277ef9db1adee53589755a53627cfeda2f Mon Sep 17 00:00:00 2001 From: Palanikannan M Date: Mon, 9 Feb 2026 14:59:27 +0530 Subject: [PATCH] fix: add better algorithm --- apps/live/src/controllers/index.ts | 3 +- .../src/controllers/pdf-export.controller.ts | 135 ++++ apps/live/src/lib/pdf/colors.ts | 225 ++++++ apps/live/src/lib/pdf/icons.tsx | 226 ++++++ apps/live/src/lib/pdf/index.ts | 18 + apps/live/src/lib/pdf/mark-renderers.ts | 138 ++++ apps/live/src/lib/pdf/node-renderers.tsx | 438 +++++++++++ apps/live/src/lib/pdf/plane-pdf-exporter.tsx | 82 ++ apps/live/src/lib/pdf/styles.ts | 245 ++++++ apps/live/src/lib/pdf/types.ts | 67 ++ apps/live/src/schema/pdf-export.ts | 65 ++ apps/live/src/services/page/core.service.ts | 153 ++++ .../src/services/pdf-export/effect-utils.ts | 50 ++ apps/live/src/services/pdf-export/index.ts | 3 + .../services/pdf-export/pdf-export.service.ts | 383 +++++++++ apps/live/src/services/pdf-export/types.ts | 39 + apps/live/tests/lib/pdf/pdf-rendering.test.ts | 727 ++++++++++++++++++ .../services/pdf-export/effect-utils.test.ts | 149 ++++ apps/live/tsconfig.json | 1 + .../pages/editor/toolbar/options-dropdown.tsx | 26 +- .../pages/modals/export-page-modal.tsx | 57 +- apps/web/core/services/live.service.ts | 42 + 22 files changed, 3231 insertions(+), 41 deletions(-) create mode 100644 apps/live/src/controllers/pdf-export.controller.ts create mode 100644 apps/live/src/lib/pdf/colors.ts create mode 100644 apps/live/src/lib/pdf/icons.tsx create mode 100644 apps/live/src/lib/pdf/index.ts create mode 100644 apps/live/src/lib/pdf/mark-renderers.ts create mode 100644 apps/live/src/lib/pdf/node-renderers.tsx create mode 100644 apps/live/src/lib/pdf/plane-pdf-exporter.tsx create mode 100644 apps/live/src/lib/pdf/styles.ts create mode 100644 apps/live/src/lib/pdf/types.ts create mode 100644 apps/live/src/schema/pdf-export.ts create mode 100644 apps/live/src/services/pdf-export/effect-utils.ts create mode 100644 apps/live/src/services/pdf-export/index.ts create mode 100644 apps/live/src/services/pdf-export/pdf-export.service.ts create mode 100644 apps/live/src/services/pdf-export/types.ts create mode 100644 apps/live/tests/lib/pdf/pdf-rendering.test.ts create mode 100644 apps/live/tests/services/pdf-export/effect-utils.test.ts create mode 100644 apps/web/core/services/live.service.ts diff --git a/apps/live/src/controllers/index.ts b/apps/live/src/controllers/index.ts index 3b45cb1ed9..da116df7cd 100644 --- a/apps/live/src/controllers/index.ts +++ b/apps/live/src/controllers/index.ts @@ -1,5 +1,6 @@ import { CollaborationController } from "./collaboration.controller"; import { DocumentController } from "./document.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]; diff --git a/apps/live/src/controllers/pdf-export.controller.ts b/apps/live/src/controllers/pdf-export.controller.ts new file mode 100644 index 0000000000..99b7979d14 --- /dev/null +++ b/apps/live/src/controllers/pdf-export.controller.ts @@ -0,0 +1,135 @@ +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, + PdfContentFetchError, + PdfGenerationError, + PdfTimeoutError, +} from "@/schema/pdf-export"; +import { PdfExportService, exportToPdf } from "@/services/pdf-export"; +import type { PdfExportInput } from "@/services/pdf-export"; + +type HttpErrorResponse = { status: number; error: string }; + +@Controller("/pdf-export") +export class PdfExportController { + /** + * Parses and validates the request, returning a typed input object + */ + private parseRequest( + req: Request, + requestId: string + ): Effect.Effect { + 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, + }) + ) + ); + + // Get baseUrl from request body or fall back to origin header + const baseUrl = body.baseUrl || req.headers.origin || ""; + + 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, + baseUrl, + apiBaseUrl: body.apiBaseUrl, + cookie, + requestId, + }; + }); + } + + @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 (fat model) + return yield* exportToPdf(input); + }).pipe( + // Log errors before catching them - serialize error properly + Effect.tapError((error) => { + const errorInfo = + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : error; + return Effect.logError("PDF_EXPORT: Export failed", { requestId, error: errorInfo }); + }), + // Map tagged errors to HTTP responses using catchTags + Effect.catchTags({ + PdfValidationError: (e: PdfValidationError): Effect.Effect => + Effect.succeed({ status: 400, error: e.message }), + PdfAuthenticationError: (e: PdfAuthenticationError): Effect.Effect => + Effect.succeed({ status: 401, error: e.message }), + PdfContentFetchError: (e: PdfContentFetchError): Effect.Effect => + Effect.succeed({ status: e.message.includes("not found") ? 404 : 502, error: e.message }), + PdfTimeoutError: (e: PdfTimeoutError): Effect.Effect => + Effect.succeed({ status: 504, error: e.message }), + PdfGenerationError: (e: PdfGenerationError): Effect.Effect => + Effect.succeed({ status: 500, error: e.message }), + }), + // 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); + } +} diff --git a/apps/live/src/lib/pdf/colors.ts b/apps/live/src/lib/pdf/colors.ts new file mode 100644 index 0000000000..0d966c9fc1 --- /dev/null +++ b/apps/live/src/lib/pdf/colors.ts @@ -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; diff --git a/apps/live/src/lib/pdf/icons.tsx b/apps/live/src/lib/pdf/icons.tsx new file mode 100644 index 0000000000..66e0af4848 --- /dev/null +++ b/apps/live/src/lib/pdf/icons.tsx @@ -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) => ( + + + +); + +// Document/file icon for page embeds +export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => ( + + + + + +); + +// Link icon for page links and external links +export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => ( + + + + +); + +// Paperclip icon for attachments (default) +export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + +); + +// Image icon for image attachments +export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + + +); + +// Video icon for video attachments +export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Music/audio icon +export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + + +); + +// File-text icon for PDFs and documents +export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Table/spreadsheet icon +export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Presentation icon +export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Archive/zip icon +export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Globe icon for external embeds (rich cards) +export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => ( + + + + +); + +// Clipboard icon for whiteboards +export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => ( + + + + +); + +// Ruler/diagram icon for diagrams +export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => ( + + + + + +); + +// Work item / task icon +export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => ( + + + + +); + +// Checkmark icon for checked task items +export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => ( + + + +); + +// Helper to get file icon component based on file type +export const getFileIcon = (fileType: string, size = 16, color = "#374151") => { + if (fileType.startsWith("image/")) return ; + if (fileType.startsWith("video/")) return ; + if (fileType.startsWith("audio/")) return ; + if (fileType.includes("pdf")) return ; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) return ; + if (fileType.includes("document") || fileType.includes("word")) return ; + if (fileType.includes("presentation") || fileType.includes("powerpoint")) + return ; + if (fileType.includes("zip") || fileType.includes("archive")) return ; + return ; +}; diff --git a/apps/live/src/lib/pdf/index.ts b/apps/live/src/lib/pdf/index.ts new file mode 100644 index 0000000000..1de0f5d574 --- /dev/null +++ b/apps/live/src/lib/pdf/index.ts @@ -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"; diff --git a/apps/live/src/lib/pdf/mark-renderers.ts b/apps/live/src/lib/pdf/mark-renderers.ts new file mode 100644 index 0000000000..7b98abf6bb --- /dev/null +++ b/apps/live/src/lib/pdf/mark-renderers.ts @@ -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); +}; diff --git a/apps/live/src/lib/pdf/node-renderers.tsx b/apps/live/src/lib/pdf/node-renderers.tsx new file mode 100644 index 0000000000..6433cd4cac --- /dev/null +++ b/apps/live/src/lib/pdf/node-renderers.tsx @@ -0,0 +1,438 @@ +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") { + // react-pdf doesn't support emoji rendering (PDF fonts lack emoji glyphs) + // Fall back to a generic icon instead + return ; + } + + if (iconName) { + switch (iconName) { + case "FileText": + case "File": + return ; + case "Link": + return ; + case "Globe": + return ; + case "Clipboard": + return ; + case "CheckSquare": + case "Check": + return ; + case "Lightbulb": + default: + return ; + } + } + + return ; +}; + +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 ( + + {node.text || ""} + + ); + } + + return ( + + {node.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 => ( + {children} + ), + + 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 ( + + {children} + + ); + }, + + 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 ( + + {children} + + ); + }, + + blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const codeContent = node.content?.map((c) => c.text || "").join("") || ""; + return ( + + {codeContent} + + ); + }, + + bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const nestingLevel = (node.attrs?._nestingLevel as number) || 0; + const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {}; + return ( + + {children} + + ); + }, + + orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const nestingLevel = (node.attrs?._nestingLevel as number) || 0; + const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {}; + return ( + + {children} + + ); + }, + + 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 ( + + + {bullet} + + {children} + + ); + }, + + taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const checked = node.attrs?.checked === true; + return ( + + + {checked && } + + {children} + + ); + }, + + table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const isHeader = node.attrs?._isHeader === true; + return ( + + {children} + + ); + }, + + 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 ( + + {children} + + ); + }, + + 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 ( + + {children} + + ); + }, + + horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + ), + + hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + {"\n"} + ), + + image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + if (ctx.metadata?.noAssets) { + return ; + } + + 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 ; + } + + const alignmentStyle = + alignment === "center" + ? { alignItems: "center" as const } + : alignment === "right" + ? { alignItems: "flex-end" as const } + : { alignItems: "flex-start" as const }; + + return ( + + + + ); + }, + + imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + if (ctx.metadata?.noAssets) { + return ; + } + + 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 ; + } + + 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 ( + + [Image: {assetId.slice(0, 8)}...] + + ); + } + + const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }; + + return ( + + + + ); + }, + + 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 ( + + {getCalloutIcon(node, TEXT_COLORS.primary)} + {children} + + ); + }, + + 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 ( + + @{displayText} + + ); + }, + +}; + +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 {children}; + } + + return ; +}; + +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 }, + }); +}; diff --git a/apps/live/src/lib/pdf/plane-pdf-exporter.tsx b/apps/live/src/lib/pdf/plane-pdf-exporter.tsx new file mode 100644 index 0000000000..e9cd71c5cc --- /dev/null +++ b/apps/live/src/lib/pdf/plane-pdf-exporter.tsx @@ -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 ( + + + {title && {title}} + {renderedContent} + + + ); +}; + +export const renderPlaneDocToPdfBuffer = async ( + doc: TipTapDocument, + options: PDFExportOptions = {} +): Promise => { + 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 => { + const pdfDocument = createPdfDocument(doc, options); + const pdfInstance = pdf(pdfDocument); + return await pdfInstance.toBlob(); +}; diff --git a/apps/live/src/lib/pdf/styles.ts b/apps/live/src/lib/pdf/styles.ts new file mode 100644 index 0000000000..b0d36e4163 --- /dev/null +++ b/apps/live/src/lib/pdf/styles.ts @@ -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", + }, +}); diff --git a/apps/live/src/lib/pdf/types.ts b/apps/live/src/lib/pdf/types.ts new file mode 100644 index 0000000000..bdbe3268a3 --- /dev/null +++ b/apps/live/src/lib/pdf/types.ts @@ -0,0 +1,67 @@ +import type { Style } from "@react-pdf/types"; + +export type TipTapMark = { + type: string; + attrs?: Record; +}; + +export type TipTapNode = { + type: string; + attrs?: Record; + 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; + +export type MarkRendererRegistry = Record; + +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; + /** When true, images and other assets are excluded from the PDF */ + noAssets?: boolean; +}; + +export type PDFUserMention = { + id: string; + display_name: string; + avatar_url?: string; +}; diff --git a/apps/live/src/schema/pdf-export.ts b/apps/live/src/schema/pdf-export.ts new file mode 100644 index 0000000000..e8fa5d350a --- /dev/null +++ b/apps/live/src/schema/pdf-export.ts @@ -0,0 +1,65 @@ +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), + baseUrl: Schema.optional(Schema.String), + // API base URL for asset resolution (e.g., "https://plane.example.com/api" or "https://api.plane.example.com") + // Used to generate correct presigned URLs for images in self-hosted environments + apiBaseUrl: Schema.optional(Schema.String), +}); + +export type TPdfExportRequestBody = Schema.Schema.Type; + +export class PdfValidationError extends Schema.TaggedError()("PdfValidationError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfAuthenticationError extends Schema.TaggedError()("PdfAuthenticationError", { + message: Schema.NonEmptyTrimmedString, +}) {} + +export class PdfContentFetchError extends Schema.TaggedError()("PdfContentFetchError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfMetadataFetchError extends Schema.TaggedError()("PdfMetadataFetchError", { + message: Schema.NonEmptyTrimmedString, + source: Schema.Literal("user-mentions"), + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfImageProcessingError extends Schema.TaggedError()("PdfImageProcessingError", { + message: Schema.NonEmptyTrimmedString, + assetId: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfGenerationError extends Schema.TaggedError()("PdfGenerationError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfTimeoutError extends Schema.TaggedError()("PdfTimeoutError", { + message: Schema.NonEmptyTrimmedString, + operation: Schema.NonEmptyTrimmedString, +}) {} + +export type PdfExportError = + | PdfValidationError + | PdfAuthenticationError + | PdfContentFetchError + | PdfMetadataFetchError + | PdfImageProcessingError + | PdfGenerationError + | PdfTimeoutError; diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts index 04a0640912..d17ab8b532 100644 --- a/apps/live/src/services/page/core.service.ts +++ b/apps/live/src/services/page/core.service.ts @@ -1,9 +1,32 @@ +import type { AxiosError } from "axios"; import { logger } from "@plane/logger"; import type { TPage } from "@plane/types"; // services import { AppError } from "@/lib/errors"; import { APIService } from "../api.service"; +/** + * Type guard to check if an error is an Axios error with a response + */ +function isAxiosErrorWithResponse( + error: unknown +): error is AxiosError & { response: NonNullable } { + return ( + typeof error === "object" && + error !== null && + "isAxiosError" in error && + (error as AxiosError).isAxiosError === true && + "response" in error && + (error as AxiosError).response !== undefined + ); +} + +export type TUserMention = { + id: string; + display_name: string; + avatar_url?: string; +}; + export type TPageDescriptionPayload = { description_binary: string; description_html: string; @@ -116,4 +139,134 @@ export abstract class PageCoreService extends APIService { throw appError; }); } + + /** + * Fetches user mentions for a page + * @param pageId - The page ID + * @returns Array of user mentions + */ + async fetchUserMentions(pageId: string): Promise { + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/user-mentions/`, { + headers: this.getHeader(), + }); + return (response?.data as TUserMention[]) || []; + } catch (error) { + logger.warn("Failed to fetch user mentions", { + pageId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + /** + * Resolves an image asset ID to its actual URL (presigned URL) + * @param workspaceSlug - The workspace slug + * @param assetId - The asset UUID + * @param projectId - Optional project ID for project-specific assets + * @param apiBaseUrl - Optional API base URL for generating correct presigned URLs + * @returns The resolved URL or null if resolution fails + */ + async resolveImageAssetUrl( + workspaceSlug: string, + assetId: string, + projectId?: string | null, + apiBaseUrl?: string + ): Promise { + const assetPath = projectId + ? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline` + : `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`; + + try { + logger.debug("Resolving image asset URL", { + assetId, + workspaceSlug, + projectId: projectId ?? "(workspace-level)", + assetPath, + apiBaseUrl: apiBaseUrl ?? "(default)", + }); + + // If apiBaseUrl is provided and non-empty, use fetch directly to that URL + // This ensures the API sees the public host and generates correct presigned URLs + if (apiBaseUrl && apiBaseUrl.trim() !== "") { + const fullUrl = `${apiBaseUrl.replace(/\/$/, "")}${assetPath}`; + const response = await fetch(fullUrl, { + method: "GET", + headers: this.getHeader(), + redirect: "manual", + }); + + if (response.status === 302 || response.status === 301) { + const resolvedUrl = response.headers.get("location"); + logger.debug("Image asset URL resolved (via apiBaseUrl)", { + assetId, + resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null, + }); + return resolvedUrl; + } + logger.warn("Unexpected response status when resolving asset URL", { + assetId, + status: response.status, + fullUrl, + }); + return null; + } + + // Fallback to axios-based request using internal API_BASE_URL + const path = projectId + ? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline` + : `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`; + 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) { + const resolvedUrl = response.headers?.location || null; + logger.debug("Image asset URL resolved", { + assetId, + resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null, + }); + return resolvedUrl; + } + logger.warn("Unexpected response status when resolving asset URL", { + assetId, + status: response.status, + apiPath: path, + }); + return null; + } catch (error) { + // Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error + if (isAxiosErrorWithResponse(error)) { + const { status, headers } = error.response; + if (status === 302 || status === 301) { + const resolvedUrl = (headers?.location as string) || null; + logger.debug("Image asset URL resolved (from redirect error)", { + assetId, + resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null, + }); + return resolvedUrl; + } + logger.error("Failed to resolve image asset URL", { + assetId, + workspaceSlug, + projectId: projectId ?? "(workspace-level)", + assetPath, + status, + error: error.message, + }); + return null; + } + logger.error("Failed to resolve image asset URL", { + assetId, + workspaceSlug, + projectId: projectId ?? "(workspace-level)", + assetPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } } diff --git a/apps/live/src/services/pdf-export/effect-utils.ts b/apps/live/src/services/pdf-export/effect-utils.ts new file mode 100644 index 0000000000..6838f5df4c --- /dev/null +++ b/apps/live/src/services/pdf-export/effect-utils.ts @@ -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 } = {}) => + (effect: Effect.Effect): Effect.Effect => + 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 = + (fallback: A) => + (effect: Effect.Effect): Effect.Effect => + 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 = (fn: () => Promise, onError: (cause: unknown) => E): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: onError, + }); diff --git a/apps/live/src/services/pdf-export/index.ts b/apps/live/src/services/pdf-export/index.ts new file mode 100644 index 0000000000..b7c3f7f291 --- /dev/null +++ b/apps/live/src/services/pdf-export/index.ts @@ -0,0 +1,3 @@ +export { PdfExportService, exportToPdf } from "./pdf-export.service"; +export * from "./effect-utils"; +export * from "./types"; diff --git a/apps/live/src/services/pdf-export/pdf-export.service.ts b/apps/live/src/services/pdf-export/pdf-export.service.ts new file mode 100644 index 0000000000..c6966b885d --- /dev/null +++ b/apps/live/src/services/pdf-export/pdf-export.service.ts @@ -0,0 +1,383 @@ +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; + content?: TipTapNode[]; +}; + +/** + * PDF Export Service + */ +export class PdfExportService extends Effect.Service()("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, + pageId: string, + requestId: string + ): Effect.Effect => + 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, + pageId: string, + requestId: string + ): Effect.Effect => + 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, + workspaceSlug: string, + projectId: string | undefined, + assetIds: string[], + requestId: string, + apiBaseUrl?: string, + baseUrl?: string + ): Effect.Effect> => + Effect.gen(function* () { + if (assetIds.length === 0) { + return {}; + } + + yield* Effect.logDebug("PDF_EXPORT: Processing images", { + requestId, + count: assetIds.length, + }); + + // Resolve URLs first - pass apiBaseUrl (or baseUrl as fallback) for correct presigned URL generation + const effectiveApiBaseUrl = apiBaseUrl && apiBaseUrl.trim() !== "" ? apiBaseUrl : baseUrl; + const resolvedUrlMap = yield* tryAsync( + async () => { + const urlMap = new Map(); + for (const assetId of assetIds) { + const url = await pageService.resolveImageAssetUrl?.( + workspaceSlug, + assetId, + projectId, + effectiveApiBaseUrl + ); + if (url) urlMap.set(assetId, url); + } + return urlMap; + }, + () => new Map() + ).pipe(recoverWithDefault(new Map())); + + 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 => + 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 => + 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, + input.apiBaseUrl, + input.baseUrl + ); + 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, + }; + }); diff --git a/apps/live/src/services/pdf-export/types.ts b/apps/live/src/services/pdf-export/types.ts new file mode 100644 index 0000000000..a29b4bc375 --- /dev/null +++ b/apps/live/src/services/pdf-export/types.ts @@ -0,0 +1,39 @@ +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 baseUrl?: string; + /** API base URL for asset resolution (e.g., "https://plane.example.com/api") */ + readonly apiBaseUrl?: string; + 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; +} diff --git a/apps/live/tests/lib/pdf/pdf-rendering.test.ts b/apps/live/tests/lib/pdf/pdf-rendering.test.ts new file mode 100644 index 0000000000..93498a4206 --- /dev/null +++ b/apps/live/tests/lib/pdf/pdf-rendering.test.ts @@ -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 { + 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"); + }); + + }); +}); diff --git a/apps/live/tests/services/pdf-export/effect-utils.test.ts b/apps/live/tests/services/pdf-export/effect-utils.test.ts new file mode 100644 index 0000000000..0a1a542519 --- /dev/null +++ b/apps/live/tests/services/pdf-export/effect-utils.test.ts @@ -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"); + }); + }); +}); diff --git a/apps/live/tsconfig.json b/apps/live/tsconfig.json index a3a901c9c2..2f8e5156ec 100644 --- a/apps/live/tsconfig.json +++ b/apps/live/tsconfig.json @@ -6,6 +6,7 @@ "noImplicitOverride": false, "noImplicitReturns": false, "noUnusedLocals": false, + "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], diff --git a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx index 572f0babec..aabbdae607 100644 --- a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx +++ b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { ArrowUpToLine, Clipboard, History } from "lucide-react"; // plane imports import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { ToggleSwitch } from "@plane/ui"; +import { Switch } from "@plane/propel/switch"; import { copyTextToClipboard } from "@plane/utils"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; @@ -50,7 +50,7 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: customContent: ( <> Full width - {}} /> + {}} /> ), className: "flex items-center justify-between gap-2", @@ -61,7 +61,7 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: customContent: ( <> Sticky toolbar - {}} /> + {}} /> ), className: "flex items-center justify-between gap-2", @@ -71,13 +71,12 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: key: "copy-markdown", action: () => { if (!editorRef) return; - copyTextToClipboard(editorRef.getMarkDown()).then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Markdown copied to clipboard.", - }) - ); + editorRef.copyMarkdownToClipboard(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Markdown copied to clipboard.", + }); }, title: "Copy markdown", icon: Clipboard, @@ -127,18 +126,19 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: isOpen={isExportModalOpen} onClose={() => setIsExportModalOpen(false)} pageTitle={name ?? ""} + pageId={page.id ?? ""} /> void; pageTitle: string; + pageId: string; }; type TExportFormats = "pdf" | "markdown"; -type TPageFormats = Exclude; +type TPageFormats = "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; type TContentVariety = "everything" | "no-assets"; type TFormValues = { @@ -96,7 +97,7 @@ const defaultValues: TFormValues = { }; export function ExportPageModal(props: Props) { - const { editorRef, isOpen, onClose, pageTitle } = props; + const { editorRef, isOpen, onClose, pageTitle, pageId } = props; // states const [isExporting, setIsExporting] = useState(false); // params @@ -105,8 +106,8 @@ export function ExportPageModal(props: Props) { const { control, reset, watch } = useForm({ defaultValues, }); - // parse editor content - const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } = useParseEditorContent({ + // parse editor content (used for markdown export) + const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent({ projectId, workspaceSlug: workspaceSlug ?? "", }); @@ -138,20 +139,22 @@ export function ExportPageModal(props: Props) { }, 1000); }; - // handle export as a PDF + // handle export as a PDF via live server const handleExportAsPDF = async () => { - try { - const pageContent = `

${pageTitle}

${editorRef?.getDocument().html ?? "

"}`; - const parsedPageContent = await replaceCustomComponentsFromHTMLContent({ - htmlContent: pageContent, - noAssets: selectedContentVariety === "no-assets", - }); + if (!workspaceSlug) throw new Error("Workspace slug is required"); - const blob = await pdf().toBlob(); - initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`); - } catch (error) { - throw new Error(`Error in exporting as a PDF: ${error}`); - } + const blob = await liveService.exportToPdf({ + pageId, + workspaceSlug: workspaceSlug.toString(), + projectId: projectId?.toString(), + title: pageTitle, + pageSize: selectedPageFormat, + fileName: `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`, + noAssets: selectedContentVariety === "no-assets", + apiBaseUrl: API_URL, + }); + + initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`); }; // handle export as markdown const handleExportAsMarkdown = async () => { @@ -200,10 +203,10 @@ export function ExportPageModal(props: Props) {
-

Export page

+

Export page

-
Export format
+
Export format
-
Include content
+
Include content
{isPDFSelected && (
-
Page format
+
Page format
-
- -
diff --git a/apps/web/core/services/live.service.ts b/apps/web/core/services/live.service.ts new file mode 100644 index 0000000000..833716ed4a --- /dev/null +++ b/apps/web/core/services/live.service.ts @@ -0,0 +1,42 @@ +import { LIVE_URL } from "@plane/constants"; +import { APIService } from "@/services/api.service"; + +export class LiveService extends APIService { + constructor() { + super(LIVE_URL); + } + + /** + * Exports a page to PDF via the live server + * @param params - PDF export parameters + * @returns Blob of the generated PDF + */ + async exportToPdf(params: { + pageId: string; + workspaceSlug: string; + projectId?: string; + title?: string; + pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + pageOrientation?: "portrait" | "landscape"; + fileName?: string; + noAssets?: boolean; + /** API base URL for asset resolution (e.g., "https://plane.example.com/api") */ + apiBaseUrl?: string; + }): Promise { + const response = await this.post( + `/pdf-export`, + { + ...params, + baseUrl: window.location.origin, + }, + { + withCredentials: true, + responseType: "blob", + } + ); + return response.data; + } +} + +// Create a singleton instance +export const liveService = new LiveService();