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