Add support for csv + xlsx

This commit is contained in:
2026-07-31 09:17:08 +02:00
parent 4c408d83e9
commit b40a7d340f
6 changed files with 1211 additions and 1 deletions
+253 -1
View File
@@ -2,9 +2,10 @@ import fs from 'node:fs/promises';
import mammoth from 'mammoth';
import puppeteer from 'puppeteer';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Document, Paragraph, TextRun, HeadingLevel, Packer } from 'docx';
import { Document, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, Packer } from 'docx';
import MarkdownIt from 'markdown-it';
import TurndownService from 'turndown';
import ExcelJS from 'exceljs';
import { register } from './registry.js';
async function renderHtmlToPdf(html, outputPath) {
@@ -217,6 +218,234 @@ async function convertPdfToDocx(inputPath, outputPath) {
await fs.writeFile(outputPath, buffer);
}
function decodeHtmlEntities(text) {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&');
}
function stripTags(html) {
return decodeHtmlEntities(html.replace(/<[^>]*>/g, '')).trim();
}
function parseHtmlTableToRows(html) {
const tableMatch = html.match(/<table[^>]*>([\s\S]*?)<\/table>/i);
if (!tableMatch) return [];
const rowMatches = [...tableMatch[1].matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)];
return rowMatches.map((rowMatch) => {
const cellMatches = [...rowMatch[1].matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi)];
return cellMatches.map((cellMatch) => stripTags(cellMatch[1]));
});
}
function rowsToHtmlDocument(rows) {
const body = rows
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(String(cell ?? ''))}</td>`).join('')}</tr>`)
.join('\n');
return `<html><body><table>${body}</table></body></html>`;
}
function escapeMarkdownCell(cell) {
return String(cell ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
}
function rowsToMarkdownTable(rows) {
if (rows.length === 0) return '';
const [header, ...body] = rows;
const headerLine = `| ${header.map(escapeMarkdownCell).join(' | ')} |`;
const separatorLine = `| ${header.map(() => '---').join(' | ')} |`;
const bodyLines = body.map((row) => `| ${row.map(escapeMarkdownCell).join(' | ')} |`);
return [headerLine, separatorLine, ...bodyLines].join('\n') + '\n';
}
function inlineTokensToPlainText(children) {
let text = '';
for (const token of children ?? []) {
if (token.type === 'text' || token.type === 'code_inline') text += token.content;
else if (token.type === 'softbreak' || token.type === 'hardbreak') text += ' ';
}
return text.trim();
}
function parseMarkdownTableToRows(markdown) {
const tokens = new MarkdownIt().parse(markdown, {});
const rows = [];
let currentRow = null;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token.type === 'tr_open') {
currentRow = [];
} else if (token.type === 'tr_close') {
if (currentRow) rows.push(currentRow);
currentRow = null;
} else if ((token.type === 'th_open' || token.type === 'td_open') && currentRow) {
currentRow.push(inlineTokensToPlainText(tokens[i + 1]?.children));
}
}
return rows;
}
function parseTxtToRows(text) {
const lines = text.split(/\r?\n/);
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines.map((line) => line.split('\t'));
}
function rowsToTxt(rows) {
return `${rows.map((row) => row.map((cell) => String(cell ?? '')).join('\t')).join('\n')}\n`;
}
async function parseDocxToRows(inputPath) {
const result = await mammoth.convertToHtml({ path: inputPath });
return parseHtmlTableToRows(result.value);
}
async function writeRowsAsDocx(rows, outputPath) {
const table = new Table({
rows: rows.map(
(row) =>
new TableRow({
children: row.map(
(cell) => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: String(cell ?? '') })] })] })
),
})
),
});
const doc = new Document({ sections: [{ children: [table] }] });
const buffer = await Packer.toBuffer(doc);
await fs.writeFile(outputPath, buffer);
}
async function parsePdfToRows(inputPath) {
const pageTexts = await extractPdfPageTexts(inputPath);
return pageTexts.map((text) => [text]);
}
async function readSpreadsheetRows(inputPath, format) {
const workbook = new ExcelJS.Workbook();
let worksheet;
if (format === 'csv') {
worksheet = await workbook.csv.readFile(inputPath);
} else {
await workbook.xlsx.readFile(inputPath);
[worksheet] = workbook.worksheets;
}
const rows = [];
worksheet.eachRow({ includeEmpty: true }, (row) => {
rows.push(row.values.slice(1).map((value) => value ?? ''));
});
return rows;
}
async function writeSpreadsheetRows(rows, outputPath, format) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Sheet1');
worksheet.addRows(rows);
if (format === 'csv') {
await workbook.csv.writeFile(outputPath);
} else {
await workbook.xlsx.writeFile(outputPath);
}
}
async function convertCsvToXlsx(inputPath, outputPath) {
await writeSpreadsheetRows(await readSpreadsheetRows(inputPath, 'csv'), outputPath, 'xlsx');
}
async function convertXlsxToCsv(inputPath, outputPath) {
await writeSpreadsheetRows(await readSpreadsheetRows(inputPath, 'xlsx'), outputPath, 'csv');
}
async function convertCsvToHtml(inputPath, outputPath) {
await fs.writeFile(outputPath, rowsToHtmlDocument(await readSpreadsheetRows(inputPath, 'csv')));
}
async function convertXlsxToHtml(inputPath, outputPath) {
await fs.writeFile(outputPath, rowsToHtmlDocument(await readSpreadsheetRows(inputPath, 'xlsx')));
}
async function convertHtmlToCsv(inputPath, outputPath) {
const html = await fs.readFile(inputPath, 'utf8');
await writeSpreadsheetRows(parseHtmlTableToRows(html), outputPath, 'csv');
}
async function convertHtmlToXlsx(inputPath, outputPath) {
const html = await fs.readFile(inputPath, 'utf8');
await writeSpreadsheetRows(parseHtmlTableToRows(html), outputPath, 'xlsx');
}
async function convertCsvToMd(inputPath, outputPath) {
await fs.writeFile(outputPath, rowsToMarkdownTable(await readSpreadsheetRows(inputPath, 'csv')));
}
async function convertXlsxToMd(inputPath, outputPath) {
await fs.writeFile(outputPath, rowsToMarkdownTable(await readSpreadsheetRows(inputPath, 'xlsx')));
}
async function convertMdToCsv(inputPath, outputPath) {
const markdown = await fs.readFile(inputPath, 'utf8');
await writeSpreadsheetRows(parseMarkdownTableToRows(markdown), outputPath, 'csv');
}
async function convertMdToXlsx(inputPath, outputPath) {
const markdown = await fs.readFile(inputPath, 'utf8');
await writeSpreadsheetRows(parseMarkdownTableToRows(markdown), outputPath, 'xlsx');
}
async function convertCsvToTxt(inputPath, outputPath) {
await fs.writeFile(outputPath, rowsToTxt(await readSpreadsheetRows(inputPath, 'csv')));
}
async function convertXlsxToTxt(inputPath, outputPath) {
await fs.writeFile(outputPath, rowsToTxt(await readSpreadsheetRows(inputPath, 'xlsx')));
}
async function convertTxtToCsv(inputPath, outputPath) {
const text = await fs.readFile(inputPath, 'utf8');
await writeSpreadsheetRows(parseTxtToRows(text), outputPath, 'csv');
}
async function convertTxtToXlsx(inputPath, outputPath) {
const text = await fs.readFile(inputPath, 'utf8');
await writeSpreadsheetRows(parseTxtToRows(text), outputPath, 'xlsx');
}
async function convertCsvToDocx(inputPath, outputPath) {
await writeRowsAsDocx(await readSpreadsheetRows(inputPath, 'csv'), outputPath);
}
async function convertXlsxToDocx(inputPath, outputPath) {
await writeRowsAsDocx(await readSpreadsheetRows(inputPath, 'xlsx'), outputPath);
}
async function convertDocxToCsv(inputPath, outputPath) {
await writeSpreadsheetRows(await parseDocxToRows(inputPath), outputPath, 'csv');
}
async function convertDocxToXlsx(inputPath, outputPath) {
await writeSpreadsheetRows(await parseDocxToRows(inputPath), outputPath, 'xlsx');
}
async function convertCsvToPdf(inputPath, outputPath) {
await renderHtmlToPdf(rowsToHtmlDocument(await readSpreadsheetRows(inputPath, 'csv')), outputPath);
}
async function convertXlsxToPdf(inputPath, outputPath) {
await renderHtmlToPdf(rowsToHtmlDocument(await readSpreadsheetRows(inputPath, 'xlsx')), outputPath);
}
async function convertPdfToCsv(inputPath, outputPath) {
await writeSpreadsheetRows(await parsePdfToRows(inputPath), outputPath, 'csv');
}
async function convertPdfToXlsx(inputPath, outputPath) {
await writeSpreadsheetRows(await parsePdfToRows(inputPath), outputPath, 'xlsx');
}
export function registerDocumentConverters() {
register({ family: 'document', sourceFormat: 'docx', targetFormat: 'html', convert: convertDocxToHtml });
register({ family: 'document', sourceFormat: 'txt', targetFormat: 'pdf', convert: convertTxtToPdf });
@@ -233,4 +462,27 @@ export function registerDocumentConverters() {
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'txt', convert: convertPdfToTxt });
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'html', convert: convertPdfToHtml });
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'docx', convert: convertPdfToDocx });
register({ family: 'document', sourceFormat: 'csv', targetFormat: 'xlsx', convert: convertCsvToXlsx });
register({ family: 'document', sourceFormat: 'xlsx', targetFormat: 'csv', convert: convertXlsxToCsv });
register({ family: 'document', sourceFormat: 'csv', targetFormat: 'html', convert: convertCsvToHtml });
register({ family: 'document', sourceFormat: 'xlsx', targetFormat: 'html', convert: convertXlsxToHtml });
register({ family: 'document', sourceFormat: 'html', targetFormat: 'csv', convert: convertHtmlToCsv });
register({ family: 'document', sourceFormat: 'html', targetFormat: 'xlsx', convert: convertHtmlToXlsx });
register({ family: 'document', sourceFormat: 'csv', targetFormat: 'md', convert: convertCsvToMd });
register({ family: 'document', sourceFormat: 'xlsx', targetFormat: 'md', convert: convertXlsxToMd });
register({ family: 'document', sourceFormat: 'md', targetFormat: 'csv', convert: convertMdToCsv });
register({ family: 'document', sourceFormat: 'md', targetFormat: 'xlsx', convert: convertMdToXlsx });
register({ family: 'document', sourceFormat: 'csv', targetFormat: 'txt', convert: convertCsvToTxt });
register({ family: 'document', sourceFormat: 'xlsx', targetFormat: 'txt', convert: convertXlsxToTxt });
register({ family: 'document', sourceFormat: 'txt', targetFormat: 'csv', convert: convertTxtToCsv });
register({ family: 'document', sourceFormat: 'txt', targetFormat: 'xlsx', convert: convertTxtToXlsx });
register({ family: 'document', sourceFormat: 'csv', targetFormat: 'docx', convert: convertCsvToDocx });
register({ family: 'document', sourceFormat: 'xlsx', targetFormat: 'docx', convert: convertXlsxToDocx });
register({ family: 'document', sourceFormat: 'docx', targetFormat: 'csv', convert: convertDocxToCsv });
register({ family: 'document', sourceFormat: 'docx', targetFormat: 'xlsx', convert: convertDocxToXlsx });
register({ family: 'document', sourceFormat: 'csv', targetFormat: 'pdf', convert: convertCsvToPdf });
register({ family: 'document', sourceFormat: 'xlsx', targetFormat: 'pdf', convert: convertXlsxToPdf });
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'csv', convert: convertPdfToCsv });
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'xlsx', convert: convertPdfToXlsx });
}
+3
View File
@@ -14,6 +14,8 @@ const OUTPUT_MIME_TYPES = {
txt: 'text/plain',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
md: 'text/markdown',
csv: 'text/csv',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
export async function detectInputMime(filePath) {
@@ -33,6 +35,7 @@ const UNDETECTABLE_TEXT_FORMATS = {
txt: 'text/plain',
html: 'text/html',
md: 'text/markdown',
csv: 'text/csv',
};
function normalizeFormat(format) {