UNPKG

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
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;