/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ 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"); }); }); });