From 05ff96b8b3833c2eb6ddf73a679bc8f52ab40616 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Thu, 30 Jul 2026 09:12:43 +0200 Subject: [PATCH] feat: add best-effort PDF source converters (txt, html, docx) Co-Authored-By: Claude Sonnet 5 --- src/converters/document.js | 42 ++++++++++++++++ test/converters/documentFromPdf.test.js | 64 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 test/converters/documentFromPdf.test.js diff --git a/src/converters/document.js b/src/converters/document.js index 36795c7..87497a9 100644 --- a/src/converters/document.js +++ b/src/converters/document.js @@ -1,6 +1,8 @@ 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, Packer } from 'docx'; import { register } from './registry.js'; async function renderHtmlToPdf(html, outputPath) { @@ -42,9 +44,49 @@ async function convertDocxToPdf(inputPath, outputPath) { await renderHtmlToPdf(result.value, outputPath); } +async function extractPdfPageTexts(inputPath) { + const data = new Uint8Array(await fs.readFile(inputPath)); + const doc = await pdfjsLib.getDocument({ data }).promise; + + const pageTexts = []; + for (let pageNum = 1; pageNum <= doc.numPages; pageNum += 1) { + const page = await doc.getPage(pageNum); + const content = await page.getTextContent(); + pageTexts.push(content.items.map((item) => item.str).join(' ')); + } + return pageTexts; +} + +async function convertPdfToTxt(inputPath, outputPath) { + const pageTexts = await extractPdfPageTexts(inputPath); + await fs.writeFile(outputPath, pageTexts.join('\n\n')); +} + +async function convertPdfToHtml(inputPath, outputPath) { + const pageTexts = await extractPdfPageTexts(inputPath); + const body = pageTexts.map((text) => `

${escapeHtml(text)}

`).join('\n'); + await fs.writeFile(outputPath, `${body}`); +} + +async function convertPdfToDocx(inputPath, outputPath) { + const pageTexts = await extractPdfPageTexts(inputPath); + const doc = new Document({ + sections: [ + { + children: pageTexts.map((text) => new Paragraph({ children: [new TextRun(text)] })), + }, + ], + }); + const buffer = await Packer.toBuffer(doc); + await fs.writeFile(outputPath, buffer); +} + export function registerDocumentConverters() { register({ family: 'document', sourceFormat: 'docx', targetFormat: 'html', convert: convertDocxToHtml }); register({ family: 'document', sourceFormat: 'txt', targetFormat: 'pdf', convert: convertTxtToPdf }); register({ family: 'document', sourceFormat: 'html', targetFormat: 'pdf', convert: convertHtmlToPdf }); register({ family: 'document', sourceFormat: 'docx', targetFormat: 'pdf', convert: convertDocxToPdf }); + 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 }); } diff --git a/test/converters/documentFromPdf.test.js b/test/converters/documentFromPdf.test.js new file mode 100644 index 0000000..e028938 --- /dev/null +++ b/test/converters/documentFromPdf.test.js @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { PDFDocument, StandardFonts } from 'pdf-lib'; +import { registerDocumentConverters } from '../../src/converters/document.js'; +import { resolve } from '../../src/converters/registry.js'; + +let tmpDir; +let pdfFixturePath; + +beforeAll(async () => { + registerDocumentConverters(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-document-from-pdf-')); + + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([600, 400]); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + page.drawText('Extractable fixture text', { x: 50, y: 350, size: 24, font }); + const bytes = await pdfDoc.save(); + pdfFixturePath = path.join(tmpDir, 'fixture.pdf'); + await fs.writeFile(pdfFixturePath, bytes); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('PDF source document converters', () => { + it('extracts text from PDF to TXT', async () => { + const outputPath = path.join(tmpDir, 'output.txt'); + const entry = resolve('pdf', 'txt'); + + await entry.convert(pdfFixturePath, outputPath); + + const text = await fs.readFile(outputPath, 'utf8'); + expect(text).toContain('Extractable fixture text'); + }); + + it('extracts text from PDF to HTML', async () => { + const outputPath = path.join(tmpDir, 'output.html'); + const entry = resolve('pdf', 'html'); + + await entry.convert(pdfFixturePath, outputPath); + + const html = await fs.readFile(outputPath, 'utf8'); + expect(html).toContain('Extractable fixture text'); + expect(html).toContain('

'); + }); + + it('reconstructs PDF text into a DOCX (best-effort)', async () => { + const outputPath = path.join(tmpDir, 'output.docx'); + const entry = resolve('pdf', 'docx'); + + await entry.convert(pdfFixturePath, outputPath); + + const stat = await fs.stat(outputPath); + expect(stat.size).toBeGreaterThan(0); + + const mammoth = await import('mammoth'); + const result = await mammoth.default.convertToHtml({ path: outputPath }); + expect(result.value).toContain('Extractable fixture text'); + }); +});