@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
JavaScript
// 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 ? `` : ``;
}
}
if (src.length > 500) {
return alt ? `[${alt}]` : "[Image]";
}
const titlePart = title ? ` "${title.replace(/"/g, "\\\"")}"` : "";
return ``;
}
});
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
};