codesummary
Version:
Cross-platform CLI tool that generates professional PDF documentation and RAG-optimized JSON outputs from project source code. Perfect for code reviews, audits, documentation, and AI/ML applications with semantic chunking and precision offsets.
476 lines (412 loc) • 14.9 kB
JavaScript
import PDFDocument from 'pdfkit';
import fs from 'fs-extra';
import path from 'path';
import chalk from 'chalk';
import ErrorHandler from './errorHandler.js';
/**
* PDF Generator for CodeSummary
* Creates styled PDF documents with project code content
*/
export class PDFGenerator {
constructor(config) {
this.config = config;
this.doc = null;
this.pageHeight = 842; // A4 height (595 x 842 points)
this.pageWidth = 595; // A4 width
}
/**
* Calculate available content width
* @returns {number} Available width for content
*/
getContentWidth() {
return this.pageWidth - (this.config.styles.layout.marginLeft + this.config.styles.layout.marginRight);
}
/**
* Generate PDF from scanned files
* @param {object} filesByExtension - Files grouped by extension
* @param {Array} selectedExtensions - Extensions selected by user
* @param {string} outputPath - Output file path
* @param {string} projectName - Name of the project
* @returns {Promise<object>} Object with outputPath and pageCount
*/
async generatePDF(filesByExtension, selectedExtensions, outputPath, projectName) {
console.log(chalk.gray('Generating PDF...'));
// Initialize PDF document with A4 size and optimized margins
this.doc = new PDFDocument({
size: 'A4',
margins: {
top: this.config.styles.layout.marginTop,
bottom: this.config.styles.layout.marginTop,
left: this.config.styles.layout.marginLeft,
right: this.config.styles.layout.marginRight
}
});
// Register cleanup for PDF document
ErrorHandler.registerCleanup(() => {
if (this.doc && !this.doc.closed) {
this.doc.end();
}
}, 'PDF document cleanup');
// Setup output stream with error handling for files in use
let finalOutputPath = outputPath;
let outputStream;
try {
outputStream = fs.createWriteStream(outputPath);
this.doc.pipe(outputStream);
// Register cleanup for output stream
ErrorHandler.registerCleanup(() => {
if (outputStream && !outputStream.destroyed) {
outputStream.destroy();
}
}, 'PDF output stream cleanup');
} catch (error) {
if (error.code === 'EBUSY' || error.code === 'EACCES') {
// Generate timestamped filename
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '')
.replace('T', '_')
.substring(0, 15);
const dir = path.dirname(outputPath);
const baseName = path.basename(outputPath, '.pdf');
finalOutputPath = path.join(dir, `${baseName}_${timestamp}.pdf`);
console.log(chalk.yellow(`WARNING: Original file is in use. Creating: ${path.basename(finalOutputPath)}`));
outputStream = fs.createWriteStream(finalOutputPath);
this.doc.pipe(outputStream);
} else {
throw error;
}
}
// PDFKit handles positioning automatically
try {
// Generate the three main sections
await this.generateTitleSection(selectedExtensions, projectName);
await this.generateFileStructureSection(filesByExtension, selectedExtensions);
await this.generateFileContentSection(filesByExtension, selectedExtensions);
// No page numbers - content only
// Finalize document
this.doc.end();
// Wait for file write to complete
await new Promise((resolve, reject) => {
outputStream.on('finish', resolve);
outputStream.on('error', reject);
});
console.log(chalk.green('SUCCESS: PDF generation completed'));
return { outputPath: finalOutputPath, pageCount: 'N/A' };
} catch (error) {
ErrorHandler.handlePDFError(error, finalOutputPath || outputPath);
}
}
/**
* Generate the title/overview section
* @param {Array} selectedExtensions - Selected file extensions
* @param {string} projectName - Project name
*/
async generateTitleSection(selectedExtensions, projectName) {
// Title
this.doc
.fontSize(20)
.fillColor(this.config.styles.colors.title)
.font('Helvetica-Bold')
.text(this.config.settings.documentTitle, {
align: 'center',
width: this.getContentWidth()
})
.moveDown(1);
// Subtitle
this.doc
.fontSize(14)
.fillColor(this.config.styles.colors.section)
.font('Helvetica')
.text(`Project: ${projectName}`, {
align: 'center',
width: this.getContentWidth()
})
.moveDown(1);
// Generation timestamp
const timestamp = new Date().toLocaleString();
this.doc
.fontSize(10)
.fillColor(this.config.styles.colors.footer)
.font('Helvetica')
.text(`Generated on: ${timestamp}`, {
width: this.getContentWidth()
})
.moveDown(2);
// Included file types section
this.doc
.fontSize(16)
.fillColor(this.config.styles.colors.section)
.font('Helvetica-Bold')
.text('Included File Types', {
width: this.getContentWidth()
})
.moveDown(0.5);
selectedExtensions.forEach(ext => {
const description = this.getExtensionDescription(ext);
this.doc
.fontSize(11)
.fillColor(this.config.styles.colors.text)
.font('Helvetica')
.text(`- ${ext} -> ${description}`, {
width: this.getContentWidth()
});
});
this.doc.moveDown(2);
}
/**
* Generate the file structure section
* @param {object} filesByExtension - Files grouped by extension
* @param {Array} selectedExtensions - Selected extensions
*/
async generateFileStructureSection(filesByExtension, selectedExtensions) {
// Section header
this.doc
.fontSize(16)
.fillColor(this.config.styles.colors.section)
.font('Helvetica-Bold')
.text('Project File Structure', {
width: this.getContentWidth()
})
.moveDown(1);
// Collect all files from selected extensions
const allFiles = [];
selectedExtensions.forEach(ext => {
if (filesByExtension[ext]) {
allFiles.push(...filesByExtension[ext]);
}
});
// Sort files by path
allFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
// Add file list as continuous text
const fileList = allFiles.map(file => file.relativePath).join('\n');
this.doc
.fontSize(10)
.fillColor(this.config.styles.colors.text)
.font('Courier')
.text(fileList, {
width: this.getContentWidth()
})
.moveDown(2);
}
/**
* Generate the file content section
* @param {object} filesByExtension - Files grouped by extension
* @param {Array} selectedExtensions - Selected extensions
*/
async generateFileContentSection(filesByExtension, selectedExtensions) {
// Section header
this.doc
.fontSize(16)
.fillColor(this.config.styles.colors.section)
.font('Helvetica-Bold')
.text('Project File Content', {
width: this.getContentWidth()
})
.moveDown(2);
for (const extension of selectedExtensions) {
if (!filesByExtension[extension]) continue;
const files = filesByExtension[extension];
for (const file of files) {
await this.addFileContent(file);
}
}
}
/**
* Add content of a single file to the PDF
* @param {object} file - File object with path and metadata
*/
async addFileContent(file) {
// Add file header
this.addFileHeader(file.relativePath);
try {
// Read and validate file content
const contentBuffer = await fs.readFile(file.absolutePath);
// Validate content before processing
if (!ErrorHandler.validateFileContent(file.relativePath, contentBuffer)) {
this.addErrorContent('File appears to contain binary data');
return;
}
const content = contentBuffer.toString('utf8');
// Memory and size limits for large files
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const MAX_LINES = 10000; // Maximum lines per file
const MAX_LINE_LENGTH = 2000; // Maximum characters per line
if (contentBuffer.length > MAX_FILE_SIZE) {
this.addErrorContent(`File too large (${Math.round(contentBuffer.length / 1024 / 1024)}MB). Limit: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
return;
}
const lines = content.split('\n');
let processedContent = content;
let wasModified = false;
// Limit number of lines
if (lines.length > MAX_LINES) {
const truncatedLines = lines.slice(0, MAX_LINES);
truncatedLines.push(`\n... [TRUNCATED: ${lines.length - MAX_LINES} more lines] ...`);
processedContent = truncatedLines.join('\n');
wasModified = true;
console.log(chalk.yellow(`WARNING: File truncated from ${lines.length} to ${MAX_LINES} lines: ${file.relativePath}`));
}
// Limit line length
const limitedLines = processedContent.split('\n').map(line => {
if (line.length > MAX_LINE_LENGTH) {
wasModified = true;
return line.substring(0, MAX_LINE_LENGTH) + '... [TRUNCATED]';
}
return line;
});
if (wasModified) {
processedContent = limitedLines.join('\n');
}
// Log processing info for large files
if (lines.length > 1000 || contentBuffer.length > 1024 * 1024) {
console.log(chalk.gray(`Processing large file: ${file.relativePath} (${lines.length} lines, ${Math.round(contentBuffer.length / 1024)}KB)`));
}
await this.addCodeContent(processedContent);
} catch (error) {
// Handle unreadable files with appropriate error messages
let errorMessage = 'Could not read file';
if (error.code === 'EACCES') {
errorMessage = 'Permission denied';
} else if (error.code === 'ENOENT') {
errorMessage = 'File not found';
} else if (error.code === 'EISDIR') {
errorMessage = 'Path is a directory';
} else if (error.message) {
errorMessage = error.message;
}
this.addErrorContent(errorMessage);
}
}
/**
* Add file header
* @param {string} filePath - Relative file path
*/
addFileHeader(filePath) {
this.doc
.moveDown(1)
.fontSize(12)
.fillColor(this.config.styles.colors.section)
.font('Helvetica-Bold')
.text(`File: ${filePath}`, {
width: this.getContentWidth()
})
.moveDown(0.5);
}
/**
* Add code content with proper formatting
* @param {string} content - File content
*/
async addCodeContent(content) {
// Clean and prepare content
const cleanContent = this.prepareContent(content);
this.doc
.fontSize(9)
.fillColor(this.config.styles.colors.text)
.font('Courier')
.text(cleanContent, {
width: this.getContentWidth(),
align: 'left'
});
// Add some spacing after code block
this.doc.moveDown(1);
}
/**
* Add error content for unreadable files
* @param {string} errorMessage - Error message to display
*/
addErrorContent(errorMessage) {
this.doc
.fontSize(10)
.fillColor(this.config.styles.colors.error)
.font('Helvetica-Oblique')
.text(`ERROR: ${errorMessage}`, {
width: this.getContentWidth()
})
.moveDown(0.5);
}
/**
* Prepare content for PDF rendering
* @param {string} content - Raw file content
* @returns {string} Cleaned content
*/
prepareContent(content) {
return content
.replace(/\r\n/g, '\n') // Normalize line endings
.replace(/\r/g, '\n') // Handle old Mac line endings
.replace(/\t/g, ' ') // Replace tabs with 4 spaces for better alignment
.replace(/[^\x20-\x7E\n\u00A0-\uFFFF]/g, ''); // Remove control chars, keep Unicode text
}
// All text methods removed - using direct PDFKit calls for simplicity
// Page numbering removed to prevent duplicate pages
/**
* Get description for file extension
* @param {string} extension - File extension
* @returns {string} Description
*/
getExtensionDescription(extension) {
const descriptions = {
'.js': 'JavaScript',
'.ts': 'TypeScript',
'.jsx': 'React JSX',
'.tsx': 'TypeScript JSX',
'.json': 'JSON',
'.xml': 'XML',
'.html': 'HTML',
'.css': 'CSS',
'.scss': 'SCSS',
'.md': 'Markdown',
'.txt': 'Text',
'.py': 'Python',
'.java': 'Java',
'.cs': 'C#',
'.cpp': 'C++',
'.c': 'C',
'.h': 'Header',
'.yaml': 'YAML',
'.yml': 'YAML',
'.sh': 'Shell Script',
'.bat': 'Batch File'
};
return descriptions[extension] || 'Unknown';
}
/**
* Generate output file path with timestamp fallback if file is in use
* @param {string} projectName - Project name
* @param {string} outputDir - Output directory
* @returns {string} Complete output file path
*/
static generateOutputPath(projectName, outputDir) {
const baseFileName = `${projectName.toUpperCase()}_code.pdf`;
const basePath = path.join(outputDir, baseFileName);
// Check if file exists and is potentially in use
if (fs.existsSync(basePath)) {
try {
// Try to open the file to check if it's in use
const fd = fs.openSync(basePath, 'r+');
fs.closeSync(fd);
// File is not in use, can overwrite
return basePath;
} catch (error) {
// File is in use, generate timestamped name
if (error.code === 'EBUSY' || error.code === 'EACCES') {
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '')
.replace('T', '_')
.substring(0, 15); // YYYYMMDD_HHMMSS
const timestampedFileName = `${projectName.toUpperCase()}_code_${timestamp}.pdf`;
console.log(chalk.yellow(`WARNING: Original file is in use. Creating: ${timestampedFileName}`));
return path.join(outputDir, timestampedFileName);
}
}
}
return basePath;
}
/**
* Ensure output directory exists
* @param {string} outputDir - Output directory path
*/
static async ensureOutputDirectory(outputDir) {
await fs.ensureDir(outputDir);
}
}
export default PDFGenerator;