Skip to content

Commit 809ddbc

Browse files
author
Ryan Roland Dabao
committed
refactor(export): split export layer and fix PDF truncation (Phase 2)
The export route was a 200-line monolith that owned auth, DOCX generation, and a hand-rolled PDF builder that SILENTLY TRUNCATED content at the bottom of the page (if (y < 50) break). Changes: - src/lib/export/types.ts: shared ExportRequest + MAX_CONTENT_LENGTH (50k) + filename sanitizer - src/lib/export/docx.ts: DOCX builder extracted (SRP) - src/lib/export/pdf.ts: PDF builder extracted AND paginated. Long documents now flow onto new pages instead of being cut off — fixes the data-loss bug. - src/app/api/export/route.ts: shrunk to ~50 lines — auth, validation, type dispatch, and a content-length guard only. Kept the manual Courier PDF approach (no external fonts) to avoid the pdfkit font-resolution issues noted in the original code, rather than adding a new dependency. Tests: add 11 unit tests (pdf pagination/no-truncation, docx validity, helpers). tsc --noEmit and eslint clean.
1 parent 53c6f39 commit 809ddbc

6 files changed

Lines changed: 317 additions & 179 deletions

File tree

__tests__/unit/export-docx.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generateDocx } from '@/lib/export/docx';
3+
4+
describe('generateDocx', () => {
5+
it('returns a non-empty buffer for simple content', async () => {
6+
const buffer = await generateDocx('# Heading\nSome body text\n- bullet', 'Doc');
7+
expect(Buffer.isBuffer(buffer)).toBe(true);
8+
expect(buffer.length).toBeGreaterThan(0);
9+
});
10+
11+
it('produces a valid DOCX zip (PK magic bytes)', async () => {
12+
const buffer = await generateDocx('Hello docx', 'Title');
13+
// DOCX files are ZIP archives starting with "PK".
14+
expect(buffer[0]).toBe(0x50); // P
15+
expect(buffer[1]).toBe(0x4b); // K
16+
});
17+
18+
it('handles empty content without throwing', async () => {
19+
const buffer = await generateDocx('', 'Empty');
20+
expect(buffer.length).toBeGreaterThan(0);
21+
});
22+
});

__tests__/unit/export-pdf.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generatePdfBuffer } from '@/lib/export/pdf';
3+
import { MAX_CONTENT_LENGTH, sanitizeFilename } from '@/lib/export/types';
4+
5+
function countPdfPages(pdf: Buffer): number {
6+
// Each page object is declared as `/Type /Page /Parent 2 0 R`.
7+
const text = pdf.toString('latin1');
8+
return (text.match(/\/Type \/Page \/Parent/g) || []).length;
9+
}
10+
11+
describe('generatePdfBuffer', () => {
12+
it('produces a valid PDF header and trailer', () => {
13+
const pdf = generatePdfBuffer('Hello world', 'Test');
14+
expect(pdf.slice(0, 8).toString()).toContain('%PDF-1.4');
15+
expect(pdf.toString('latin1')).toContain('%%EOF');
16+
});
17+
18+
it('contains the rendered text content', () => {
19+
const pdf = generatePdfBuffer('UniqueMarkerText line', 'Title');
20+
expect(pdf.toString('latin1')).toContain('UniqueMarkerText line');
21+
});
22+
23+
it('fits short content on a single page', () => {
24+
const pdf = generatePdfBuffer('one\ntwo\nthree', 'Short');
25+
expect(countPdfPages(pdf)).toBe(1);
26+
});
27+
28+
it('paginates long content instead of truncating', () => {
29+
// 1000 lines of unique text -> must span multiple pages (no data loss).
30+
const lines: string[] = [];
31+
for (let i = 0; i < 1000; i++) lines.push(`Line ${i} unique content`);
32+
const content = lines.join('\n');
33+
const pdf = generatePdfBuffer(content, 'Long');
34+
35+
const pages = countPdfPages(pdf);
36+
expect(pages).toBeGreaterThan(1);
37+
38+
// Every unique line must be present in the output (no truncation).
39+
for (let i = 0; i < 1000; i += 37) {
40+
expect(pdf.toString('latin1')).toContain(`Line ${i} unique content`);
41+
}
42+
});
43+
44+
it('escapes parentheses in content', () => {
45+
const pdf = generatePdfBuffer('Text with (parens) and \\ slash', 'Esc');
46+
expect(pdf.toString('latin1')).toContain('Text with \\(parens\\) and \\\\ slash');
47+
});
48+
});
49+
50+
describe('sanitizeFilename', () => {
51+
it('replaces non-alphanumeric chars with underscores', () => {
52+
expect(sanitizeFilename('My Resume! @2026')).toBe('My_Resume___2026');
53+
});
54+
it('falls back when title is empty', () => {
55+
expect(sanitizeFilename('')).toBe('document');
56+
expect(sanitizeFilename(undefined)).toBe('document');
57+
});
58+
});
59+
60+
describe('MAX_CONTENT_LENGTH', () => {
61+
it('is defined and reasonable', () => {
62+
expect(MAX_CONTENT_LENGTH).toBe(50_000);
63+
});
64+
});

src/app/api/export/route.ts

Lines changed: 29 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextRequest, NextResponse } from 'next/server';
2-
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
32
import { getUserFromRequest } from '@/lib/auth-helpers';
3+
import { generateDocx } from '@/lib/export/docx';
4+
import { generatePdfBuffer } from '@/lib/export/pdf';
5+
import { MAX_CONTENT_LENGTH, sanitizeFilename, type ExportRequest } from '@/lib/export/types';
46

57
export async function POST(request: NextRequest) {
68
try {
@@ -9,192 +11,40 @@ export async function POST(request: NextRequest) {
911
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
1012
}
1113

12-
const body = await request.json();
14+
const body = (await request.json()) as Partial<ExportRequest>;
1315
const { type, content, title } = body;
1416

15-
if (type === 'docx') {
16-
return await generateDocx(content, title);
17-
} else if (type === 'pdf') {
18-
return await generatePdf(content, title);
17+
if (!type || !content) {
18+
return NextResponse.json({ error: 'Type and content are required' }, { status: 400 });
1919
}
20-
21-
return NextResponse.json({ error: 'Invalid export type' }, { status: 400 });
22-
} catch (error) {
23-
console.error('Export error:', error);
24-
return NextResponse.json({ error: 'Failed to generate export' }, { status: 500 });
25-
}
26-
}
27-
28-
async function generateDocx(content: string, title: string) {
29-
const lines = content.split('\n');
30-
const children: Paragraph[] = [];
31-
32-
// Add title
33-
children.push(new Paragraph({
34-
children: [new TextRun({ text: title || 'Document', bold: true, size: 36 })],
35-
heading: HeadingLevel.HEADING_1,
36-
alignment: AlignmentType.CENTER,
37-
}));
38-
39-
children.push(new Paragraph({ text: '' }));
40-
41-
for (const line of lines) {
42-
const trimmed = line.trim();
43-
if (!trimmed) {
44-
children.push(new Paragraph({ text: '' }));
45-
continue;
20+
if (type !== 'docx' && type !== 'pdf') {
21+
return NextResponse.json({ error: 'Invalid export type' }, { status: 400 });
4622
}
47-
48-
// Detect headings (lines starting with #)
49-
if (trimmed.startsWith('### ')) {
50-
children.push(new Paragraph({
51-
children: [new TextRun({ text: trimmed.replace('### ', ''), bold: true, size: 22 })],
52-
heading: HeadingLevel.HEADING_3,
53-
}));
54-
} else if (trimmed.startsWith('## ')) {
55-
children.push(new Paragraph({
56-
children: [new TextRun({ text: trimmed.replace('## ', ''), bold: true, size: 26 })],
57-
heading: HeadingLevel.HEADING_2,
58-
}));
59-
} else if (trimmed.startsWith('# ')) {
60-
children.push(new Paragraph({
61-
children: [new TextRun({ text: trimmed.replace('# ', ''), bold: true, size: 28 })],
62-
heading: HeadingLevel.HEADING_1,
63-
}));
64-
} else if (trimmed.startsWith('- ') || trimmed.startsWith('• ')) {
65-
children.push(new Paragraph({
66-
children: [new TextRun({ text: `\u2022 ${trimmed.replace(/^[-]\s*/, '')}`, size: 22 })],
67-
indent: { left: 360 },
68-
}));
69-
} else {
70-
children.push(new Paragraph({
71-
children: [new TextRun({ text: trimmed, size: 22 })],
72-
}));
23+
if (content.length > MAX_CONTENT_LENGTH) {
24+
return NextResponse.json({ error: 'Content is too long' }, { status: 400 });
7325
}
74-
}
75-
76-
const doc = new Document({
77-
sections: [{ properties: {}, children }],
78-
});
79-
80-
const buffer = await Packer.toBuffer(doc);
81-
82-
return new NextResponse(new Uint8Array(buffer), {
83-
headers: {
84-
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
85-
'Content-Disposition': `attachment; filename="${(title || 'document').replace(/[^a-zA-Z0-9]/g, '_')}.docx"`,
86-
},
87-
});
88-
}
89-
90-
async function generatePdf(content: string, title: string) {
91-
// Generate PDF manually to avoid pdfkit font resolution issues in Next.js bundled environment.
92-
const lines = content.split('\n');
93-
const allLines: Array<{ text: string; size: number; bold: boolean; indent: number; color: string }> = [];
9426

95-
// Title
96-
allLines.push({ text: title || 'Document', size: 24, bold: true, indent: 0, color: '0 0 0' });
97-
allLines.push({ text: '', size: 8, bold: false, indent: 0, color: '0 0 0' });
27+
const filename = sanitizeFilename(title);
9828

99-
for (const line of lines) {
100-
const trimmed = line.trim();
101-
if (!trimmed) {
102-
allLines.push({ text: '', size: 4, bold: false, indent: 0, color: '0 0 0' });
103-
continue;
104-
}
105-
106-
if (trimmed.startsWith('### ')) {
107-
allLines.push({ text: trimmed.replace('### ', ''), size: 12, bold: true, indent: 0, color: '0 0 0' });
108-
} else if (trimmed.startsWith('## ')) {
109-
allLines.push({ text: trimmed.replace('## ', ''), size: 14, bold: true, indent: 0, color: '0 0 0' });
110-
} else if (trimmed.startsWith('# ')) {
111-
allLines.push({ text: trimmed.replace('# ', ''), size: 16, bold: true, indent: 0, color: '0 0 0' });
112-
} else if (trimmed.startsWith('- ') || trimmed.startsWith('• ')) {
113-
allLines.push({ text: `\u2022 ${trimmed.replace(/^[-]\s*/, '')}`, size: 11, bold: false, indent: 20, color: '0 0 0' });
114-
} else {
115-
allLines.push({ text: trimmed, size: 11, bold: false, indent: 0, color: '0 0 0' });
29+
if (type === 'docx') {
30+
const buffer = await generateDocx(content, title ?? 'Document');
31+
return new NextResponse(new Uint8Array(buffer), {
32+
headers: {
33+
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
34+
'Content-Disposition': `attachment; filename="${filename}.docx"`,
35+
},
36+
});
11637
}
117-
}
118-
119-
const pdfContent = buildSimplePdf(allLines);
12038

121-
return new NextResponse(new Uint8Array(pdfContent), {
122-
headers: {
123-
'Content-Type': 'application/pdf',
124-
'Content-Disposition': `attachment; filename="${(title || 'document').replace(/[^a-zA-Z0-9]/g, '_')}.pdf"`,
125-
},
126-
});
127-
}
128-
129-
// Build a minimal valid PDF manually (no external font files needed)
130-
function buildSimplePdf(allLines: Array<{ text: string; size: number; bold: boolean; indent: number; color: string }>): Buffer {
131-
// Build PDF structure
132-
const objects: string[] = [];
133-
let objectCount = 0;
134-
135-
const addObject = (content: string): number => {
136-
objectCount++;
137-
objects.push(`${objectCount} 0 obj\n${content}\nendobj`);
138-
return objectCount;
139-
};
140-
141-
// Object 1: Catalog
142-
const catalogObj = addObject('<< /Type /Catalog /Pages 2 0 R >>');
143-
144-
// Object 2: Pages (will reference page objects)
145-
addObject('<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
146-
147-
// Build page content stream
148-
let streamContent = '';
149-
let y = 770; // Start near top of page
150-
151-
for (const item of allLines) {
152-
if (y < 50) break; // Stop if we run off the page
153-
154-
const fontName = item.bold ? '/F2' : '/F1';
155-
const x = 50 + item.indent;
156-
157-
// Escape special PDF characters
158-
const escapedText = item.text
159-
.replace(/\\/g, '\\\\')
160-
.replace(/\(/g, '\\(')
161-
.replace(/\)/g, '\\)');
162-
163-
streamContent += `BT\n${fontName} ${item.size} Tf\n${item.color} rg\n${x} ${y} Td\n(${escapedText}) Tj\nET\n`;
164-
y -= item.size + 4;
165-
}
166-
167-
// Object 3: Stream
168-
const streamObjId = addObject(`<< /Length ${streamContent.length} >>\nstream\n${streamContent}\nendstream`);
169-
170-
// Object 4: Page
171-
addObject(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents ${streamObjId} 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>`);
172-
173-
// Object 5: Font (Courier)
174-
addObject('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
175-
176-
// Object 6: Font (Courier-Bold)
177-
addObject('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
178-
179-
// Build PDF
180-
let pdf = '%PDF-1.4\n';
181-
const offsets: number[] = [];
182-
183-
for (let i = 0; i < objects.length; i++) {
184-
offsets.push(pdf.length);
185-
pdf += `${objects[i]}\n`;
186-
}
187-
188-
// Cross-reference table
189-
const xrefOffset = pdf.length;
190-
pdf += `xref\n0 ${objectCount + 1}\n`;
191-
pdf += '0000000000 65535 f \n';
192-
for (const offset of offsets) {
193-
pdf += `${offset.toString().padStart(10, '0')} 00000 n \n`;
39+
const buffer = generatePdfBuffer(content, title ?? 'Document');
40+
return new NextResponse(new Uint8Array(buffer), {
41+
headers: {
42+
'Content-Type': 'application/pdf',
43+
'Content-Disposition': `attachment; filename="${filename}.pdf"`,
44+
},
45+
});
46+
} catch (error) {
47+
console.error('Export error:', error);
48+
return NextResponse.json({ error: 'Failed to generate export' }, { status: 500 });
19449
}
195-
196-
pdf += `trailer\n<< /Size ${objectCount + 1} /Root ${catalogObj} 0 R >>\n`;
197-
pdf += `startxref\n${xrefOffset}\n%%EOF`;
198-
199-
return Buffer.from(pdf, 'binary');
20050
}

src/lib/export/docx.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
2+
3+
/** Parses markdown-ish line prefixes into docx paragraph styling. */
4+
function buildParagraph(line: string): Paragraph {
5+
const trimmed = line.trim();
6+
7+
if (trimmed.startsWith('### ')) {
8+
return new Paragraph({
9+
children: [new TextRun({ text: trimmed.replace('### ', ''), bold: true, size: 22 })],
10+
heading: HeadingLevel.HEADING_3,
11+
});
12+
}
13+
if (trimmed.startsWith('## ')) {
14+
return new Paragraph({
15+
children: [new TextRun({ text: trimmed.replace('## ', ''), bold: true, size: 26 })],
16+
heading: HeadingLevel.HEADING_2,
17+
});
18+
}
19+
if (trimmed.startsWith('# ')) {
20+
return new Paragraph({
21+
children: [new TextRun({ text: trimmed.replace('# ', ''), bold: true, size: 28 })],
22+
heading: HeadingLevel.HEADING_1,
23+
});
24+
}
25+
if (trimmed.startsWith('- ') || trimmed.startsWith('• ')) {
26+
return new Paragraph({
27+
children: [new TextRun({ text: `• ${trimmed.replace(/^[-]\s*/, '')}`, size: 22 })],
28+
indent: { left: 360 },
29+
});
30+
}
31+
return new Paragraph({
32+
children: [new TextRun({ text: trimmed, size: 22 })],
33+
});
34+
}
35+
36+
export async function generateDocx(content: string, title: string): Promise<Buffer> {
37+
const lines = content.split('\n');
38+
const children: Paragraph[] = [];
39+
40+
children.push(
41+
new Paragraph({
42+
children: [new TextRun({ text: title || 'Document', bold: true, size: 36 })],
43+
heading: HeadingLevel.HEADING_1,
44+
alignment: AlignmentType.CENTER,
45+
}),
46+
);
47+
children.push(new Paragraph({ text: '' }));
48+
49+
for (const line of lines) {
50+
if (!line.trim()) {
51+
children.push(new Paragraph({ text: '' }));
52+
continue;
53+
}
54+
children.push(buildParagraph(line));
55+
}
56+
57+
const doc = new Document({ sections: [{ properties: {}, children }] });
58+
return Packer.toBuffer(doc);
59+
}

0 commit comments

Comments
 (0)