UNPKG

@aidalinfo/office-to-markdown

Version:

Modern TypeScript library for converting Office documents (DOCX) to Markdown format, optimized for Bun runtime with enhanced table support and math equation conversion.

1,212 lines (1,181 loc) 39.6 kB
// src/converters/docx-converter.ts import mammoth from "mammoth"; // src/converters/base-converter.ts class DocumentConverter { async sourceToBuffer(source) { if (typeof source === "string") { const file = Bun.file(source); return Buffer.from(await file.arrayBuffer()); } if (Buffer.isBuffer(source)) { return source; } if (source instanceof ArrayBuffer) { return Buffer.from(source); } if (source instanceof Uint8Array) { return Buffer.from(source); } if (this.isFileSource(source)) { return Buffer.from(await source.arrayBuffer()); } const chunks = []; const reader = source.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } } finally { reader.releaseLock(); } const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return Buffer.from(result); } isFileSource(source) { return source && typeof source.arrayBuffer === "function"; } inferStreamInfo(source, providedInfo) { const streamInfo = { ...providedInfo }; if (typeof source === "string") { const path = source; streamInfo.localPath = path; streamInfo.filename = path.split("/").pop() || path; const ext = path.lastIndexOf(".") > -1 ? path.substring(path.lastIndexOf(".")) : undefined; if (ext && !streamInfo.extension) { streamInfo.extension = ext; } } if (this.isFileSource(source)) { if (source.name && !streamInfo.filename) { streamInfo.filename = source.name; } if (source.type && !streamInfo.mimetype) { streamInfo.mimetype = source.type; } } return streamInfo; } guessMimeTypeFromExtension(extension) { const mimeMap = { ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".doc": "application/msword", ".pdf": "application/pdf", ".html": "text/html", ".htm": "text/html", ".txt": "text/plain", ".md": "text/markdown", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation" }; return mimeMap[extension.toLowerCase()]; } } // src/preprocessing/docx-preprocessor.ts import JSZip from "jszip"; // src/math/omml-processor.ts var UNICODE_TO_LATEX = { "α": "\\alpha", "β": "\\beta", "γ": "\\gamma", "δ": "\\delta", "ε": "\\epsilon", "ζ": "\\zeta", "η": "\\eta", "θ": "\\theta", "ι": "\\iota", "κ": "\\kappa", "λ": "\\lambda", "μ": "\\mu", "ν": "\\nu", "ξ": "\\xi", "π": "\\pi", "ρ": "\\rho", "σ": "\\sigma", "τ": "\\tau", "υ": "\\upsilon", "φ": "\\phi", "χ": "\\chi", "ψ": "\\psi", "ω": "\\omega", "∞": "\\infty", "±": "\\pm", "∓": "\\mp", "≤": "\\leq", "≥": "\\geq", "≠": "\\neq", "≈": "\\approx", "∈": "\\in", "∉": "\\notin", "∪": "\\cup", "∩": "\\cap", "∑": "\\sum", "∏": "\\prod", "∫": "\\int", "√": "\\sqrt", "→": "\\rightarrow", "←": "\\leftarrow", "↔": "\\leftrightarrow" }; function replaceUnicodeSymbols(text) { let result = text; for (const [unicode, latex] of Object.entries(UNICODE_TO_LATEX)) { result = result.replace(new RegExp(unicode, "g"), latex + " "); } return result; } var OMML_PATTERNS = [ { pattern: /<f>[\s\S]*?<num>(.*?)<\/num>[\s\S]*?<den>(.*?)<\/den>[\s\S]*?<\/f>/g, replacement: "\\frac{$1}{$2}" }, { pattern: /<sSup>[\s\S]*?<e>(.*?)<\/e>[\s\S]*?<sup>(.*?)<\/sup>[\s\S]*?<\/sSup>/g, replacement: "$1^{$2}" }, { pattern: /<sSub>[\s\S]*?<e>(.*?)<\/e>[\s\S]*?<sub>(.*?)<\/sub>[\s\S]*?<\/sSub>/g, replacement: "$1_{$2}" }, { pattern: /<rad>[\s\S]*?<e>(.*?)<\/e>[\s\S]*?<\/rad>/g, replacement: "\\sqrt{$1}" }, { pattern: /<rad>[\s\S]*?<deg>(.*?)<\/deg>[\s\S]*?<e>(.*?)<\/e>[\s\S]*?<\/rad>/g, replacement: "\\sqrt[$1]{$2}" }, { pattern: /<r>[\s\S]*?<t>(.*?)<\/t>[\s\S]*?<\/r>/g, replacement: "$1" } ]; function convertOmmlStringToLatex(ommlXml) { try { let latex = ommlXml; latex = latex.replace(/[a-zA-Z]+:/g, ""); for (const { pattern, replacement } of OMML_PATTERNS) { latex = latex.replace(pattern, replacement); } latex = latex.replace(/<[^>]*>/g, " "); latex = replaceUnicodeSymbols(latex); return latex.replace(/\s+/g, " ").trim(); } catch (error) { console.warn("Failed to convert OMML to LaTeX:", error); return ommlXml.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); } } // src/preprocessing/docx-preprocessor.ts var MATH_ROOT_TEMPLATE = [ "<w:document ", 'xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" ', 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ', 'xmlns:o="urn:schemas-microsoft-com:office:office" ', 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ', 'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" ', 'xmlns:v="urn:schemas-microsoft-com:vml" ', 'xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" ', 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ', 'xmlns:w10="urn:schemas-microsoft-com:office:word" ', 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ', 'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ', 'xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" ', 'xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" ', 'xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" ', 'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 wp14">', "{0}</w:document>" ].join(""); function convertOmmlElementToLatex(ommlXml) { try { const latex = convertOmmlStringToLatex(ommlXml); return latex || ""; } catch (error) { console.warn("Failed to convert OMML to LaTeX:", error); return ommlXml.replace(/<[^>]*>/g, "").trim(); } } function createLatexTextRun(latex, isBlock = false) { const delimiter = isBlock ? "$$" : "$"; return `<w:r><w:t>${delimiter}${latex}${delimiter}</w:t></w:r>`; } function preprocessMath(xmlContent) { try { let content = xmlContent.toString("utf-8"); content = content.replace(/<(m:)?oMathPara[^>]*>([\s\S]*?)<\/(m:)?oMathPara>/gi, (match, _, mathContent) => { const omathMatches = mathContent.match(/<(m:)?oMath[^>]*>([\s\S]*?)<\/(m:)?oMath>/gi); if (omathMatches) { const latexElements = omathMatches.map((omathMatch) => { const latex = convertOmmlElementToLatex(omathMatch); return createLatexTextRun(latex, true); }); return `<w:p>${latexElements.join("")}</w:p>`; } return match; }); content = content.replace(/<(m:)?oMath[^>]*>([\s\S]*?)<\/(m:)?oMath>/gi, (match, _, mathContent) => { const latex = convertOmmlElementToLatex(match); return createLatexTextRun(latex, false); }); return Buffer.from(content, "utf-8"); } catch (error) { console.warn("Failed to preprocess math content:", error); return xmlContent; } } async function preprocessDocx(inputDocx) { try { const zip = await JSZip.loadAsync(inputDocx); const mathProcessFiles = [ "word/document.xml", "word/footnotes.xml", "word/endnotes.xml" ]; for (const filename of mathProcessFiles) { const file = zip.file(filename); if (file) { try { const content = await file.async("nodebuffer"); const processedContent = preprocessMath(content); zip.file(filename, processedContent); } catch (error) { console.warn(`Failed to process ${filename}:`, error); } } } return Buffer.from(await zip.generateAsync({ type: "nodebuffer" })); } catch (error) { console.warn("Failed to preprocess DOCX file:", error); return inputDocx; } } // src/utils/html-to-markdown.ts import TurndownService from "turndown"; class CustomHtmlToMarkdown { turndown; constructor(options = {}) { this.turndown = new TurndownService({ headingStyle: options.headingStyle === "setext" ? "setext" : "atx", hr: "---", bulletListMarker: "-", codeBlockStyle: "fenced", fence: "```", emDelimiter: "*", strongDelimiter: "**", linkStyle: "inlined", linkReferenceStyle: "full", preformattedCode: false }); if (options.preserveTables !== false) { this.addTableRules(); } this.addCustomRules(); } convert(html) { html = this.preprocessHtml(html); let markdown = this.turndown.turndown(html); markdown = this.postprocessMarkdown(markdown); return markdown; } addTableRules() { this.turndown.addRule("table", { filter: "table", replacement: (content) => { const rows = content.trim().split(` `).filter((row) => row.trim()); if (rows.length === 0) return ""; let result = ` `; let headerAdded = false; for (let i = 0;i < rows.length; i++) { const row = rows[i]; result += row + ` `; if (!headerAdded && i === 0 && this.looksLikeHeaderRow(row || "")) { const cellCount = ((row || "").match(/\|/g) || []).length - 1; const separator = "|" + " --- |".repeat(cellCount); result += separator + ` `; headerAdded = true; } } return result + ` `; } }); this.turndown.addRule("tableRow", { filter: "tr", replacement: (content) => { const cleanContent = content.trim(); if (!cleanContent) return ""; return `|${cleanContent} `; } }); this.turndown.addRule("tableCell", { filter: ["td", "th"], replacement: (content) => { content = content.trim().replace(/\|/g, "\\|").replace(/\n/g, " "); return ` ${content} |`; } }); } addCustomRules() { this.turndown.remove("heading"); this.turndown.addRule("customHeading", { filter: ["h1", "h2", "h3", "h4", "h5", "h6"], replacement: (content, node) => { const level = parseInt(node.nodeName.charAt(1)); const prefix = "#".repeat(level); const cleanContent = content.trim().replace(/\n/g, " "); return ` ${prefix} ${cleanContent} `; } }); this.turndown.addRule("mathInline", { filter: (node) => { const text = node.textContent || ""; return text.match(/^\$[^$]+\$$/); }, replacement: (content) => { return content.trim(); } }); this.turndown.addRule("mathBlock", { filter: (node) => { const text = node.textContent || ""; return text.match(/^\$\$[\s\S]+\$\$$/); }, replacement: (content) => { return ` ${content.trim()} `; } }); this.turndown.addRule("customParagraph", { filter: "p", replacement: (content) => { const cleanContent = content.trim(); if (!cleanContent) return ""; if (cleanContent.match(/^\$\$[\s\S]+\$\$$/)) { return ` ${cleanContent} `; } return `${cleanContent} `; } }); this.turndown.addRule("smartLineBreak", { filter: "br", replacement: (_content, node) => { const parent = node.parentNode; if (parent && (parent.nodeName === "P" || parent.nodeName === "LI")) { return ` `; } return ` `; } }); this.turndown.addRule("smartImage", { filter: "img", replacement: (_content, node) => { const alt = node.getAttribute("alt") || ""; const src = node.getAttribute("src") || ""; const title = node.getAttribute("title"); if (src.startsWith("data:")) { if (src.length > 100) { const mimeType = src.split(";")[0].replace("data:", ""); return alt ? `![${alt}](data:${mimeType}...)` : `![Image](data:${mimeType}...)`; } } if (src.length > 500) { return alt ? `[${alt}]` : "[Image]"; } const titlePart = title ? ` "${title.replace(/"/g, "\\\"")}"` : ""; return `![${alt}](${src}${titlePart})`; } }); this.turndown.addRule("safeLink", { filter: "a", replacement: (content, node) => { const href = node.getAttribute("href"); const title = node.getAttribute("title"); if (!href || href.startsWith("javascript:") || href.startsWith("vbscript:")) { return content; } let cleanHref = href; try { if (cleanHref.includes(" ")) { cleanHref = encodeURI(cleanHref); } } catch (error) { return content; } const titlePart = title ? ` "${title.replace(/"/g, "\\\"")}"` : ""; if (content.trim() === cleanHref && !title) { return `<${cleanHref}>`; } return `[${content}](${cleanHref}${titlePart})`; } }); this.turndown.addRule("codeBlock", { filter: "pre", replacement: (content, node) => { const codeElement = node.querySelector("code"); if (codeElement) { const language = codeElement.className.replace(/language-/, "") || ""; return ` \`\`\`${language} ${content} \`\`\` `; } return ` \`\`\` ${content} \`\`\` `; } }); this.turndown.addRule("listItem", { filter: "li", replacement: (content, _node, options) => { const cleanContent = content.trim(); if (!cleanContent) return ""; const prefix = options.bulletListMarker || "-"; const indentedContent = cleanContent.split(` `).map((line, index) => index === 0 ? line : ` ${line}`).join(` `); return `${prefix} ${indentedContent} `; } }); } preprocessHtml(html) { html = html.replace(/<(script|style)[^>]*>[\s\S]*?<\/(script|style)>/gi, ""); html = html.replace(/\n\s*\n\s*\n/g, ` `); html = html.replace(/<w:([^>]*)>/g, ""); html = html.replace(/<\/w:([^>]*)>/g, ""); return html; } postprocessMarkdown(markdown) { markdown = markdown.replace(/\r\n/g, ` `); markdown = markdown.replace(/\n{4,}/g, ` `); markdown = markdown.replace(/\n(#{1,6} .+)\n{1}/g, ` $1 `); markdown = markdown.replace(/\n([*\-+] .+)\n([*\-+] .+)/g, ` $1 $2`); markdown = markdown.replace(/\|\s*\|\s*\|/g, "| |"); markdown = markdown.split(` `).map((line) => { if (line.endsWith(" ")) { return line; } return line.trimEnd(); }).join(` `); markdown = markdown.trimEnd() + ` `; return markdown; } looksLikeHeaderRow(row) { return /\*\*.*\*\*/.test(row) || /\b(name|title|date|description|id|type|status)\b/i.test(row); } } // src/types/result.ts function createDocumentConverterResult(markdown, title) { return { markdown, title }; } // src/types/converter.ts class FileConversionException extends Error { originalError; constructor(message, originalError) { super(message); this.originalError = originalError; this.name = "FileConversionException"; } } class MissingDependencyException extends Error { constructor(message) { super(message); this.name = "MissingDependencyException"; } } class UnsupportedFormatException extends Error { constructor(message) { super(message); this.name = "UnsupportedFormatException"; } } // src/utils/error-handler.ts class OfficeToMarkdownError extends Error { code; context; originalError; constructor(message, code = "UNKNOWN_ERROR" /* UNKNOWN_ERROR */, context, originalError) { super(message); this.name = "OfficeToMarkdownError"; this.code = code; this.context = context; this.originalError = originalError; if (Error.captureStackTrace) { Error.captureStackTrace(this, OfficeToMarkdownError); } } toJSON() { return { name: this.name, message: this.message, code: this.code, context: this.context, stack: this.stack, originalError: this.originalError ? { name: this.originalError.name, message: this.originalError.message, stack: this.originalError.stack } : undefined }; } } class ErrorHandler { static async wrapAsync(fn, errorContext) { try { return await fn(); } catch (error) { throw ErrorHandler.enhanceError(error, errorContext); } } static wrap(fn, errorContext) { try { return fn(); } catch (error) { throw ErrorHandler.enhanceError(error, errorContext); } } static enhanceError(error, context) { if (error instanceof OfficeToMarkdownError) { return error; } if (error instanceof FileConversionException) { return new OfficeToMarkdownError(error.message, "CONVERSION_FAILED" /* CONVERSION_FAILED */, context, error.originalError); } if (error instanceof MissingDependencyException) { return new OfficeToMarkdownError(error.message, "MISSING_DEPENDENCY" /* MISSING_DEPENDENCY */, context, error instanceof Error ? error : undefined); } if (error instanceof UnsupportedFormatException) { return new OfficeToMarkdownError(error.message, "UNSUPPORTED_FORMAT" /* UNSUPPORTED_FORMAT */, context, error instanceof Error ? error : undefined); } if (error instanceof Error) { const code = ErrorHandler.getErrorCodeFromMessage(error.message); return new OfficeToMarkdownError(error.message, code, context, error); } const message = typeof error === "string" ? error : "Unknown error occurred"; return new OfficeToMarkdownError(message, "UNKNOWN_ERROR" /* UNKNOWN_ERROR */, { ...context, originalError: error }, undefined); } static getErrorCodeFromMessage(message) { const lowerMessage = message.toLowerCase(); if (lowerMessage.includes("no such file") || lowerMessage.includes("file not found")) { return "FILE_NOT_FOUND" /* FILE_NOT_FOUND */; } if (lowerMessage.includes("permission denied") || lowerMessage.includes("access denied")) { return "FILE_ACCESS_DENIED" /* FILE_ACCESS_DENIED */; } if (lowerMessage.includes("corrupted") || lowerMessage.includes("invalid zip")) { return "FILE_CORRUPTED" /* FILE_CORRUPTED */; } if (lowerMessage.includes("too large") || lowerMessage.includes("file size")) { return "FILE_TOO_LARGE" /* FILE_TOO_LARGE */; } if (lowerMessage.includes("timeout")) { return "TIMEOUT_ERROR" /* TIMEOUT_ERROR */; } if (lowerMessage.includes("network") || lowerMessage.includes("fetch")) { return "NETWORK_ERROR" /* NETWORK_ERROR */; } if (lowerMessage.includes("html") || lowerMessage.includes("parse")) { return "HTML_PARSING_FAILED" /* HTML_PARSING_FAILED */; } if (lowerMessage.includes("markdown")) { return "MARKDOWN_GENERATION_FAILED" /* MARKDOWN_GENERATION_FAILED */; } return "UNKNOWN_ERROR" /* UNKNOWN_ERROR */; } static createFileError(filePath, originalError, operation = "process") { let code = "UNKNOWN_ERROR" /* UNKNOWN_ERROR */; let message = `Failed to ${operation} file: ${filePath}`; if (originalError.message.includes("ENOENT")) { code = "FILE_NOT_FOUND" /* FILE_NOT_FOUND */; message = `File not found: ${filePath}`; } else if (originalError.message.includes("EACCES")) { code = "FILE_ACCESS_DENIED" /* FILE_ACCESS_DENIED */; message = `Access denied to file: ${filePath}`; } else if (originalError.message.includes("EMFILE") || originalError.message.includes("ENFILE")) { code = "FILE_ACCESS_DENIED" /* FILE_ACCESS_DENIED */; message = `Too many open files. Cannot access: ${filePath}`; } return new OfficeToMarkdownError(message, code, { filePath, operation }, originalError); } static createConversionError(phase, originalError, context) { let code = "CONVERSION_FAILED" /* CONVERSION_FAILED */; let message = `Conversion failed during ${phase}`; switch (phase) { case "preprocessing": code = "PREPROCESSING_FAILED" /* PREPROCESSING_FAILED */; message = "Failed to preprocess document (math equations, etc.)"; break; case "docx-to-html": code = "CONVERSION_FAILED" /* CONVERSION_FAILED */; message = "Failed to convert DOCX to HTML"; break; case "html-to-markdown": code = "MARKDOWN_GENERATION_FAILED" /* MARKDOWN_GENERATION_FAILED */; message = "Failed to convert HTML to Markdown"; break; } return new OfficeToMarkdownError(`${message}: ${originalError.message}`, code, { phase, ...context }, originalError); } static logError(error, logger) { const logFn = logger || console; if (error instanceof OfficeToMarkdownError) { if (typeof logFn.error === "function") { logFn.error("Conversion error:", error.toJSON()); } else { console.error("Conversion error:", error.message, error.context); } } else { if (typeof logFn.error === "function") { logFn.error("Unexpected error:", error); } else { console.error("Unexpected error:", error); } } } static isRecoverable(error) { if (error instanceof OfficeToMarkdownError) { const recoverableCodes = [ "NETWORK_ERROR" /* NETWORK_ERROR */, "TIMEOUT_ERROR" /* TIMEOUT_ERROR */, "FILE_ACCESS_DENIED" /* FILE_ACCESS_DENIED */ ]; return recoverableCodes.includes(error.code); } return false; } static getUserFriendlyMessage(error) { if (!(error instanceof OfficeToMarkdownError)) { return "An unexpected error occurred during conversion."; } switch (error.code) { case "FILE_NOT_FOUND" /* FILE_NOT_FOUND */: return "The specified file could not be found. Please check the file path and try again."; case "FILE_ACCESS_DENIED" /* FILE_ACCESS_DENIED */: return "Permission denied. Please check file permissions and ensure the file is not open in another application."; case "FILE_CORRUPTED" /* FILE_CORRUPTED */: return "The file appears to be corrupted or invalid. Please try with a different file."; case "FILE_TOO_LARGE" /* FILE_TOO_LARGE */: return "The file is too large to process. Please try with a smaller file."; case "UNSUPPORTED_FORMAT" /* UNSUPPORTED_FORMAT */: return "This file format is not supported. Currently supported formats: DOCX."; case "MISSING_DEPENDENCY" /* MISSING_DEPENDENCY */: return "A required component is missing. Please reinstall the library."; case "CONVERSION_FAILED" /* CONVERSION_FAILED */: return "The document could not be converted. The file may be corrupted or contain unsupported elements."; case "NETWORK_ERROR" /* NETWORK_ERROR */: return "Network error occurred. Please check your internet connection and try again."; case "TIMEOUT_ERROR" /* TIMEOUT_ERROR */: return "The operation timed out. Please try again with a smaller file."; default: return error.message || "An error occurred during conversion."; } } } // src/converters/docx-converter.ts var ACCEPTED_MIME_TYPE_PREFIXES = [ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ]; var ACCEPTED_FILE_EXTENSIONS = [".docx"]; class DocxConverter extends DocumentConverter { htmlConverter; constructor(options = {}) { super(); this.htmlConverter = new CustomHtmlToMarkdown(options); } accepts(buffer, streamInfo) { const mimetype = (streamInfo.mimetype || "").toLowerCase(); const extension = (streamInfo.extension || "").toLowerCase(); if (ACCEPTED_FILE_EXTENSIONS.includes(extension)) { return true; } for (const prefix of ACCEPTED_MIME_TYPE_PREFIXES) { if (mimetype.startsWith(prefix)) { return true; } } if (buffer.length >= 4) { const signature = buffer.subarray(0, 4); if (signature[0] === 80 && signature[1] === 75) { const bufferString = buffer.toString("binary", 0, Math.min(1024, buffer.length)); if (bufferString.includes("word/") && bufferString.includes("document.xml")) { return true; } } } return false; } async convert(buffer, streamInfo, options = {}) { return ErrorHandler.wrapAsync(async () => { if (!mammoth) { throw new OfficeToMarkdownError("mammoth library is required for DOCX conversion. Please install it with: bun add mammoth", "MISSING_DEPENDENCY" /* MISSING_DEPENDENCY */, { library: "mammoth" }); } let processedBuffer = buffer; if (options.convertMath !== false) { try { processedBuffer = await preprocessDocx(buffer); } catch (error) { ErrorHandler.logError(ErrorHandler.createConversionError("preprocessing", error instanceof Error ? error : new Error("Unknown preprocessing error"), { filename: streamInfo.filename })); processedBuffer = buffer; } } const mammothOptions = {}; if (options.styleMap) { mammothOptions.styleMap = options.styleMap; } let result; try { result = await mammoth.convertToHtml({ buffer: processedBuffer }, mammothOptions); } catch (error) { throw ErrorHandler.createConversionError("docx-to-html", error instanceof Error ? error : new Error("Mammoth conversion failed"), { filename: streamInfo.filename, bufferSize: processedBuffer.length, hasStyleMap: !!options.styleMap }); } if (result.messages && result.messages.length > 0) { const errors = result.messages.filter((m) => m.type === "error"); const warnings = result.messages.filter((m) => m.type === "warning"); if (errors.length > 0) { console.warn(`Mammoth conversion errors (${errors.length}):`, errors); } if (warnings.length > 5) { console.warn(`Mammoth conversion warnings (${warnings.length} total)`); } } let title; try { const titleMatch = result.value.match(/<h1[^>]*>(.*?)<\/h1>/i); if (titleMatch) { title = titleMatch[1].replace(/<[^>]*>/g, "").trim(); } else { if (streamInfo.filename) { title = streamInfo.filename.replace(/\.(docx|doc)$/i, ""); } } } catch (error) { console.warn("Failed to extract title:", error); } let markdown; try { markdown = this.htmlConverter.convert(result.value); } catch (error) { throw ErrorHandler.createConversionError("html-to-markdown", error instanceof Error ? error : new Error("HTML to Markdown conversion failed"), { filename: streamInfo.filename, htmlLength: result.value?.length || 0 }); } return createDocumentConverterResult(markdown, title); }, { operation: "convert", filename: streamInfo.filename, filesize: buffer.length, mimetype: streamInfo.mimetype, extension: streamInfo.extension }); } async convertFile(filePath, options = {}) { try { const buffer = await this.sourceToBuffer(filePath); const streamInfo = this.inferStreamInfo(filePath); return await this.convert(buffer, streamInfo, options); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; throw new FileConversionException(`Failed to convert DOCX file at ${filePath}: ${message}`, error instanceof Error ? error : undefined); } } async convertMultiple(sources, options = {}) { const results = []; const errors = []; for (let i = 0;i < sources.length; i++) { const source = sources[i]; if (!source) continue; const { buffer, streamInfo } = source; try { const result = await this.convert(buffer, streamInfo, options); results.push(result); } catch (error) { errors.push({ index: i, error: error instanceof Error ? error : new Error("Unknown error") }); results.push(createDocumentConverterResult("", "Failed to convert")); } } if (errors.length > 0) { console.warn(`Failed to convert ${errors.length} out of ${sources.length} documents:`, errors); } return results; } getConversionInfo() { return { supportedExtensions: [...ACCEPTED_FILE_EXTENSIONS], supportedMimeTypes: [...ACCEPTED_MIME_TYPE_PREFIXES], features: [ "Tables", "Math equations (OMML to LaTeX)", "Headings and formatting", "Images with alt text", "Lists and structural elements", "Custom style mapping", "Batch processing" ] }; } } // src/types/stream-info.ts function createStreamInfo(options = {}) { return { mimetype: options.mimetype, extension: options.extension, charset: options.charset, filename: options.filename, localPath: options.localPath, url: options.url }; } // src/utils/file-detector.ts var EXTENSION_TO_MIME = { ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".doc": "application/msword", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xls": "application/vnd.ms-excel", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".ppt": "application/vnd.ms-powerpoint", ".pdf": "application/pdf", ".rtf": "application/rtf", ".odt": "application/vnd.oasis.opendocument.text", ".ods": "application/vnd.oasis.opendocument.spreadsheet", ".odp": "application/vnd.oasis.opendocument.presentation", ".html": "text/html", ".htm": "text/html", ".xml": "text/xml", ".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp", ".webp": "image/webp", ".svg": "image/svg+xml", ".zip": "application/zip", ".rar": "application/x-rar-compressed", ".tar": "application/x-tar", ".gz": "application/gzip" }; var FILE_SIGNATURES = [ { signature: [80, 75, 3, 4], mimetype: "application/zip", extension: ".zip" }, { signature: [80, 75, 7, 8], mimetype: "application/zip", extension: ".zip" }, { signature: [37, 80, 68, 70], mimetype: "application/pdf", extension: ".pdf" }, { signature: [208, 207, 17, 224, 161, 177, 26, 225], mimetype: "application/msword", extension: ".doc" }, { signature: [123, 92, 114, 116, 102], mimetype: "application/rtf", extension: ".rtf" }, { signature: [255, 216, 255], mimetype: "image/jpeg", extension: ".jpg" }, { signature: [137, 80, 78, 71, 13, 10, 26, 10], mimetype: "image/png", extension: ".png" }, { signature: [71, 73, 70, 56], mimetype: "image/gif", extension: ".gif" }, { signature: [66, 77], mimetype: "image/bmp", extension: ".bmp" } ]; function detectFileType(buffer) { for (const { signature, mimetype, extension, offset = 0 } of FILE_SIGNATURES) { if (buffer.length >= signature.length + offset) { const matches = signature.every((byte, index) => buffer[offset + index] === byte); if (matches) { if (mimetype === "application/zip") { return detectOfficeFormat(buffer) || { mimetype, extension }; } return { mimetype, extension }; } } } return null; } function detectOfficeFormat(buffer) { const bufferString = buffer.toString("binary", 0, Math.min(1024, buffer.length)); if (bufferString.includes("word/") && bufferString.includes("document.xml")) { return { mimetype: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", extension: ".docx" }; } if (bufferString.includes("xl/") && bufferString.includes("workbook.xml")) { return { mimetype: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", extension: ".xlsx" }; } if (bufferString.includes("ppt/") && bufferString.includes("presentation.xml")) { return { mimetype: "application/vnd.openxmlformats-officedocument.presentationml.presentation", extension: ".pptx" }; } return null; } function guessMimeTypeFromExtension(extension) { return EXTENSION_TO_MIME[extension.toLowerCase()]; } function guessExtensionFromMimeType(mimetype) { const entries = Object.entries(EXTENSION_TO_MIME); const match = entries.find(([, mime]) => mime === mimetype); return match ? match[0] : undefined; } function enhanceStreamInfo(buffer, baseInfo = {}) { const streamInfo = createStreamInfo(baseInfo); const detected = detectFileType(buffer); if (detected) { if (!streamInfo.mimetype) { streamInfo.mimetype = detected.mimetype; } if (!streamInfo.extension) { streamInfo.extension = detected.extension; } } if (streamInfo.extension && !streamInfo.mimetype) { const guessedMime = guessMimeTypeFromExtension(streamInfo.extension); if (guessedMime) { streamInfo.mimetype = guessedMime; } } if (streamInfo.mimetype && !streamInfo.extension) { const guessedExt = guessExtensionFromMimeType(streamInfo.mimetype); if (guessedExt) { streamInfo.extension = guessedExt; } } if (streamInfo.localPath && !streamInfo.filename) { streamInfo.filename = streamInfo.localPath.split("/").pop() || streamInfo.localPath; } return streamInfo; } function isSupportedFileType(mimetype, extension) { const supportedMimes = [ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/msword", "text/html", "text/plain", "text/markdown" ]; const supportedExtensions = [ ".docx", ".doc", ".html", ".htm", ".txt", ".md" ]; if (mimetype && supportedMimes.includes(mimetype)) { return true; } if (extension && supportedExtensions.includes(extension.toLowerCase())) { return true; } return false; } function getFileInfo(buffer, streamInfo) { const detected = detectFileType(buffer); const enhanced = enhanceStreamInfo(buffer, streamInfo); const supported = isSupportedFileType(enhanced.mimetype, enhanced.extension); return { size: buffer.length, detected, enhanced, supported }; } // src/index.ts class OfficeToMarkdown { docxConverter; constructor(options = {}) { this.docxConverter = new DocxConverter(options); } async convert(source, options = {}) { const buffer = await this.sourceToBuffer(source); const streamInfo = this.inferStreamInfo(source, options); const enhancedInfo = enhanceStreamInfo(buffer, streamInfo); if (!isSupportedFileType(enhancedInfo.mimetype, enhancedInfo.extension)) { throw new UnsupportedFormatException(`Unsupported file type: ${enhancedInfo.mimetype || "unknown"} (${enhancedInfo.extension || "unknown extension"})`); } if (this.docxConverter.accepts(buffer, enhancedInfo)) { return await this.docxConverter.convert(buffer, enhancedInfo, options); } throw new UnsupportedFormatException("No suitable converter found for this file type"); } async convertDocx(source, options = {}) { const buffer = await this.sourceToBuffer(source); const streamInfo = this.inferStreamInfo(source, options); const enhancedInfo = enhanceStreamInfo(buffer, streamInfo); return await this.docxConverter.convert(buffer, enhancedInfo, options); } async convertMultiple(sources, options = {}) { const results = []; for (const source of sources) { try { const result = await this.convert(source, options); results.push(result); } catch (error) { console.warn("Failed to convert document:", error); results.push({ markdown: "", title: "Conversion failed" }); } } return results; } async getFileInfo(source) { const buffer = await this.sourceToBuffer(source); const streamInfo = this.inferStreamInfo(source); return getFileInfo(buffer, streamInfo); } async isSupported(source) { try { const info = await this.getFileInfo(source); return info.supported; } catch { return false; } } getSupportedTypes() { return { extensions: [".docx"], mimeTypes: [ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ], converters: [ { name: "DocxConverter", info: this.docxConverter.getConversionInfo() } ] }; } async sourceToBuffer(source) { if (typeof source === "string") { const file = Bun.file(source); return Buffer.from(await file.arrayBuffer()); } if (Buffer.isBuffer(source)) { return source; } if (source instanceof ArrayBuffer) { return Buffer.from(source); } if (source instanceof Uint8Array) { return Buffer.from(source); } if (this.isFileSource(source)) { return Buffer.from(await source.arrayBuffer()); } const chunks = []; const reader = source.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } } finally { reader.releaseLock(); } const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return Buffer.from(result); } inferStreamInfo(source, _options = {}) { const streamInfo = createStreamInfo(); if (typeof source === "string") { streamInfo.localPath = source; streamInfo.filename = source.split("/").pop() || source; const ext = source.lastIndexOf(".") > -1 ? source.substring(source.lastIndexOf(".")) : undefined; if (ext) { streamInfo.extension = ext; } } if (this.isFileSource(source)) { if (source.name) { streamInfo.filename = source.name; } if (source.type) { streamInfo.mimetype = source.type; } } return streamInfo; } isFileSource(source) { return source && typeof source.arrayBuffer === "function"; } } async function docxToMarkdown(filePath, options = {}) { const converter = new OfficeToMarkdown(options); const result = await converter.convertDocx(filePath, options); return result.markdown; } async function officeToMarkdown(source, options = {}) { const converter = new OfficeToMarkdown(options); const result = await converter.convert(source, options); return result.markdown; } var defaultConverter = new OfficeToMarkdown; export { officeToMarkdown, isSupportedFileType, getFileInfo, enhanceStreamInfo, docxToMarkdown, defaultConverter, createStreamInfo, UnsupportedFormatException, OfficeToMarkdown, FileConversionException, DocxConverter };