UNPKG

n8n-nodes-pdf-accessibility

Version:

AI-powered PDF accessibility automation for N8N - comprehensive WCAG compliance analysis, intelligent remediation, and professional audit reporting with 5 integrated accessibility tools

317 lines (316 loc) 13.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TableAccessibilityTool = void 0; class TableAccessibilityTool { getName() { return 'table_accessibility'; } getDescription() { return 'Analyzes tables for proper headers, captions, and accessibility structure'; } getSupportedWCAGCriteria() { return [ '1.3.1', // Info and Relationships (Level A) '1.3.2', // Meaningful Sequence (Level A) '2.4.6', // Headings and Labels (Level AA) ]; } canProcess(context) { return context.hasTables; } async execute(context, llmProvider) { const startTime = Date.now(); const issues = []; const fixes = []; try { // Analyze table structure const tableAnalysis = await this.analyzeTableStructure(context); if (tableAnalysis.tables.length === 0) { return { toolName: this.getName(), success: true, issuesFound: [], fixesApplied: [], processing_time_ms: Date.now() - startTime, }; } // Identify accessibility issues const tableIssues = this.identifyTableIssues(tableAnalysis, context); issues.push(...tableIssues); // Generate fixes if LLM provider is available if (llmProvider && issues.length > 0) { const tableFixes = await this.generateTableFixes(tableAnalysis, context, llmProvider); fixes.push(...tableFixes); } return { toolName: this.getName(), success: true, issuesFound: issues, fixesApplied: fixes, processing_time_ms: Date.now() - startTime, }; } catch (error) { return { toolName: this.getName(), success: false, issuesFound: issues, fixesApplied: fixes, processing_time_ms: Date.now() - startTime, error: error instanceof Error ? error.message : String(error), }; } } async analyzeTableStructure(context) { const tables = this.extractTableInfo(context); const totalDataTables = tables.filter(t => t.isDataTable).length; const totalLayoutTables = tables.filter(t => t.isLayoutTable).length; const tablesWithoutHeaders = tables.filter(t => t.isDataTable && !t.hasHeaders); const tablesWithoutCaptions = tables.filter(t => t.isDataTable && !t.hasCaption); const complexTables = tables.filter(t => this.isComplexTable(t)); return { tables, totalDataTables, totalLayoutTables, tablesWithoutHeaders, tablesWithoutCaptions, complexTables, }; } extractTableInfo(context) { const tables = []; const text = context.textContent; // Look for table patterns in text // Note: patterns available for future enhancement // const tablePatterns = [ // /table\s*\d+/gi, // /\|\s*[^|]+\s*\|/g, // Pipe-separated tables // /\t[^\t]+\t/g, // Tab-separated tables // ]; let tableCount = 0; // Detect pipe-separated tables const lines = text.split('\n'); let currentTable = []; let inTable = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (this.looksLikeTableRow(line)) { if (!inTable) { inTable = true; currentTable = []; } currentTable.push(line); } else if (inTable && currentTable.length > 1) { // End of table found const tableInfo = this.parseTableFromLines(currentTable, tableCount, i); if (tableInfo) { tables.push(tableInfo); tableCount++; } inTable = false; currentTable = []; } else if (inTable) { inTable = false; currentTable = []; } } // Check for remaining table if (inTable && currentTable.length > 1) { const tableInfo = this.parseTableFromLines(currentTable, tableCount, lines.length); if (tableInfo) { tables.push(tableInfo); } } return tables.slice(0, 10); // Limit to first 10 tables } looksLikeTableRow(line) { // Heuristics for detecting table rows const pipeCount = (line.match(/\|/g) || []).length; const tabCount = (line.match(/\t/g) || []).length; return pipeCount >= 2 || tabCount >= 2 || this.hasTableKeywords(line); } hasTableKeywords(line) { const tableKeywords = ['total', 'sum', 'count', 'average', 'percentage', '%', '$']; const lowerLine = line.toLowerCase(); return tableKeywords.some(keyword => lowerLine.includes(keyword)); } parseTableFromLines(lines, index, lineNumber) { if (lines.length < 2) return null; const separator = lines[0].includes('|') ? '|' : '\t'; const rows = []; for (const line of lines) { const cells = line.split(separator).map(cell => cell.trim()).filter(cell => cell.length > 0); if (cells.length > 1) { rows.push(cells); } } if (rows.length < 2) return null; const columns = Math.max(...rows.map(row => row.length)); const firstRow = rows[0]; const hasHeaders = this.detectTableHeaders(firstRow, rows); return { index, page: Math.ceil(lineNumber / 50), // Rough page estimation rows: rows.length, columns, hasHeaders, hasCaption: false, // Would need PDF structure analysis headers: hasHeaders ? firstRow : [], sampleData: rows.slice(hasHeaders ? 1 : 0, 4), // First few data rows isDataTable: this.isDataTable(rows), isLayoutTable: !this.isDataTable(rows), contextText: this.getTableContext(lines), }; } detectTableHeaders(firstRow, allRows) { // Heuristics for detecting if first row contains headers if (firstRow.length === 0) return false; // Check if first row has header-like words const headerWords = ['name', 'type', 'date', 'value', 'description', 'id', 'category', 'status']; const hasHeaderWords = firstRow.some(cell => headerWords.some(word => cell.toLowerCase().includes(word))); // Check if first row is different from data rows (less numeric) const firstRowNumeric = firstRow.filter(cell => /^\d+(\.\d+)?$/.test(cell.trim())).length; const dataRowsNumeric = allRows.slice(1, 3).map(row => row.filter(cell => /^\d+(\.\d+)?$/.test(cell.trim())).length); const avgDataNumeric = dataRowsNumeric.length > 0 ? dataRowsNumeric.reduce((a, b) => a + b, 0) / dataRowsNumeric.length : 0; return hasHeaderWords || (firstRowNumeric < avgDataNumeric && avgDataNumeric > 0); } isDataTable(rows) { // Determine if this is a data table vs layout table if (rows.length < 2) return false; // Check for numeric data let numericCells = 0; let totalCells = 0; for (const row of rows.slice(0, 5)) { // Check first 5 rows for (const cell of row) { totalCells++; if (/^\d+(\.\d+)?%?$/.test(cell.trim()) || /^\$\d+/.test(cell.trim())) { numericCells++; } } } // If more than 30% of cells are numeric, likely a data table return totalCells > 0 && (numericCells / totalCells) > 0.3; } isComplexTable(table) { // Complex tables have many columns, rows, or nested structure return table.columns > 5 || table.rows > 10 || table.headers.some(header => header.includes('|') || header.includes('\t')); } getTableContext(lines) { return lines.slice(0, 3).join(' ').substring(0, 200); } identifyTableIssues(analysis, _context) { const issues = []; // Tables without headers for (const table of analysis.tablesWithoutHeaders) { issues.push({ type: 'table_headers', severity: 'high', description: `Table ${table.index + 1} on page ${table.page} lacks proper headers`, location: `Page ${table.page}`, wcagCriteria: ['1.3.1'], suggestion: 'Add header row to identify column contents for screen reader users', }); } // Tables without captions for (const table of analysis.tablesWithoutCaptions) { issues.push({ type: 'table_headers', severity: 'medium', description: `Table ${table.index + 1} on page ${table.page} lacks descriptive caption`, location: `Page ${table.page}`, wcagCriteria: ['1.3.1', '2.4.6'], suggestion: 'Add caption describing the table\'s purpose and content', }); } // Complex tables needing additional structure for (const table of analysis.complexTables) { issues.push({ type: 'table_headers', severity: 'medium', description: `Complex table ${table.index + 1} may need additional accessibility markup`, location: `Page ${table.page}`, wcagCriteria: ['1.3.1'], suggestion: 'Complex tables may need header associations and summary information', }); } return issues; } async generateTableFixes(analysis, context, llmProvider) { const fixes = []; // Generate headers for tables without them for (const table of analysis.tablesWithoutHeaders) { try { const generatedHeaders = await this.generateTableHeaders(table, context, llmProvider); fixes.push({ type: 'table_header_generation', description: `Generated headers for table ${table.index + 1}`, applied: false, wcagImprovement: ['1.3.1'], beforeValue: 'No headers', afterValue: generatedHeaders.join(' | '), }); } catch (error) { console.warn(`Failed to generate headers for table ${table.index + 1}:`, error); } } // Generate captions for tables without them for (const table of analysis.tablesWithoutCaptions) { try { const generatedCaption = await this.generateTableCaption(table, context, llmProvider); fixes.push({ type: 'table_caption_generation', description: `Generated caption for table ${table.index + 1}`, applied: false, wcagImprovement: ['1.3.1', '2.4.6'], beforeValue: 'No caption', afterValue: generatedCaption, }); } catch (error) { console.warn(`Failed to generate caption for table ${table.index + 1}:`, error); } } return fixes; } async generateTableHeaders(table, _context, _llmProvider) { // Mock implementation - would use LLM in real scenario const sampleData = table.sampleData[0] || []; return sampleData.map((_, index) => { if (index === 0) return 'Item'; if (sampleData.some(cell => /^\d+(\.\d+)?$/.test(cell))) return 'Value'; if (sampleData.some(cell => cell.includes('%'))) return 'Percentage'; if (sampleData.some(cell => cell.includes('$'))) return 'Amount'; return `Column ${index + 1}`; }); } async generateTableCaption(table, _context, _llmProvider) { // Mock implementation - would use LLM in real scenario const contextWords = table.contextText.toLowerCase(); if (contextWords.includes('financial') || contextWords.includes('budget')) { return `Financial data table with ${table.rows} rows and ${table.columns} columns`; } else if (contextWords.includes('result') || contextWords.includes('data')) { return `Data table showing results with ${table.rows} entries`; } else if (contextWords.includes('comparison')) { return `Comparison table with ${table.columns} categories`; } else { return `Table ${table.index + 1}: Data organized in ${table.rows} rows and ${table.columns} columns`; } } } exports.TableAccessibilityTool = TableAccessibilityTool;