@ryangarber/markitdown-ts 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,17 +1,28 @@
1
+ #!/usr/bin/env node
1
2
  import { readFile } from "node:fs/promises";
2
- import { Command } from "@commander-js/extra-typings";
3
- import turndownPluginGfm from "@joplin/turndown-plugin-gfm";
4
- import TurndownService from "turndown";
3
+ import { basename } from "node:path";
4
+ import { argument, commandLine, flag, message, object, option, optional, string, withDefault } from "@optique/core";
5
+ import { url } from "@optique/core/message";
6
+ import { defineProgram } from "@optique/core/program";
7
+ import { path, runSync } from "@optique/run";
5
8
  import { parseHTML } from "linkedom";
9
+ import TurndownPluginGfm from "@truto/turndown-plugin-gfm";
10
+ import TurndownService from "turndown";
11
+ import { fileTypeFromBuffer } from "file-type";
12
+ import { Mime } from "mime";
13
+ import otherTypes from "mime/types/other.js";
14
+ import standardTypes from "mime/types/standard.js";
6
15
  import mammoth from "mammoth";
7
16
  import { extractText, getDocumentProxy, getMeta } from "unpdf";
8
- import ExcelJS from "exceljs";
9
- import { DOMParser } from "@xmldom/xmldom";
17
+ import { pptxToHtml } from "@jvmr/pptx-to-html";
18
+ import { DOMParser, Document, Element, Node } from "@xmldom/xmldom";
19
+ import readExcelFile from "read-excel-file/universal";
10
20
  import { unzipSync } from "fflate";
11
- //#region package.json
12
- var version = "0.1.4";
13
- //#endregion
14
- //#region src/bytes.ts
21
+ // package.json
22
+ var version = "0.1.5";
23
+ var bugs = { "url": "https://github.com/ryangarber/markitdown-ts/issues" };
24
+ var author = "Ryan Garber";
25
+ // src/utils/bytes.ts
15
26
  async function toUint8Array(source) {
16
27
  if (source instanceof Uint8Array) return source;
17
28
  if (isBlob(source)) return new Uint8Array(await source.arrayBuffer());
@@ -27,12 +38,11 @@ function decodeText(source) {
27
38
  function isBlob(value) {
28
39
  return typeof Blob !== "undefined" && value instanceof Blob;
29
40
  }
30
- //#endregion
31
- //#region src/custom-turndown.ts
32
- var CustomTurnDown = class {
41
+ // src/utils/turndown.ts
42
+ var Turndown = class {
33
43
  convertSoup(document) {
34
44
  const service = new TurndownService({ headingStyle: "atx" });
35
- service.use(turndownPluginGfm.gfm);
45
+ service.use(TurndownPluginGfm);
36
46
  service.addRule("anchor tags", {
37
47
  filter: ["a"],
38
48
  replacement(content, node) {
@@ -67,48 +77,166 @@ var CustomTurnDown = class {
67
77
  return `![${alt}](${source}${title ? ` "${title}"` : ""})`;
68
78
  }
69
79
  });
70
- return service.turndown(document);
80
+ return service.turndown(serializeNode(document));
71
81
  }
72
82
  };
73
- //#endregion
74
- //#region src/html-document.ts
75
- function parseHtmlDocument(html) {
76
- return parseHTML(html).document;
83
+ /**
84
+ * Turndown and its GFM plugin rely on DOM APIs (e.g. `HTMLTableElement.rows`, `Element.closest`)
85
+ * that linkedom does not implement, which silently drops content such as tables. Serializing
86
+ * lets Turndown re-parse the markup with its own spec-compliant DOM.
87
+ */
88
+ function serializeNode(node) {
89
+ if (typeof node === "string") return node;
90
+ if (node.nodeType === node.DOCUMENT_NODE) return node.documentElement?.outerHTML ?? "";
91
+ if (node.nodeType === node.ELEMENT_NODE) return node.outerHTML;
92
+ if (node.nodeType === node.TEXT_NODE) return escapeHtml$1(node.textContent ?? "");
93
+ return [...node.childNodes].map(serializeNode).join("");
94
+ }
95
+ function escapeHtml$1(value) {
96
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
97
+ }
98
+ // src/utils/html.ts
99
+ function parseHtmlDocument(html, transform) {
100
+ const document = parseHTML(wrapFragment(html)).document;
101
+ return Object.fromEntries(Object.entries(transform(document)).map(([key, value]) => {
102
+ if (value.type === "node") return [key, (Array.isArray(value.value) ? value.value : [value.value]).map((node) => new Turndown().convertSoup(node).split(/\n+/).map((line) => line.trim()).filter(Boolean).join("\n\n")).join("\n\n")];
103
+ return [key, value.value];
104
+ }));
105
+ }
106
+ /**
107
+ * linkedom only parses complete documents: given a fragment (e.g. mammoth output or an RSS
108
+ * description), it promotes the first element to `documentElement` and drops its siblings
109
+ * from the tree, so wrap fragments in a full document first.
110
+ */
111
+ function wrapFragment(html) {
112
+ if (/<html[\s>]/i.test(html)) return html;
113
+ if (/<(head|body)[\s>]/i.test(html)) return `<html>${html}</html>`;
114
+ return `<html><head></head><body>${html}</body></html>`;
115
+ }
116
+ // src/utils/mime.ts
117
+ const mime = new Mime(standardTypes, otherTypes);
118
+ function extensionFromMime(contentType) {
119
+ if (!contentType) return null;
120
+ return extensionNormalized(mime.getExtension(contentType));
121
+ }
122
+ async function extensionFromBuffer(buffer) {
123
+ const type = await fileTypeFromBuffer(buffer);
124
+ if (!type) return null;
125
+ return extensionNormalized(type.ext);
126
+ }
127
+ /**
128
+ * Sniffs text formats that have no magic number. Returns null for binary content, and `txt`
129
+ * for text that is not recognized as anything more specific.
130
+ */
131
+ function extensionFromText(buffer) {
132
+ let text;
133
+ try {
134
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
135
+ } catch {
136
+ return null;
137
+ }
138
+ if (hasBinaryControlCharacters(text)) return null;
139
+ const start = text.trimStart().slice(0, 1024).toLowerCase();
140
+ if (start.startsWith("<")) {
141
+ if (/<rss[\s>]/.test(start)) return "rss";
142
+ if (/<feed[\s>]/.test(start)) return "atom";
143
+ if (/^<!doctype html|<(html|head|body)[\s>]/.test(start)) return "html";
144
+ if (start.startsWith("<?xml")) return "xml";
145
+ }
146
+ if (start.startsWith("{") && /"nbformat"\s*:/.test(text) && /"cells"\s*:/.test(text)) return "ipynb";
147
+ return "txt";
148
+ }
149
+ function hasBinaryControlCharacters(text) {
150
+ for (let index = 0; index < text.length; index++) {
151
+ const code = text.charCodeAt(index);
152
+ if (code < 32 && (code < 9 || code > 13) && code !== 27) return true;
153
+ }
154
+ return false;
77
155
  }
78
- //#endregion
79
- //#region src/converters/bingserp.ts
156
+ function extensionFromPathname(pathname) {
157
+ const filename = pathname.split("/").pop() ?? "";
158
+ const dot = filename.lastIndexOf(".");
159
+ return dot > 0 ? extensionNormalized(filename.slice(dot)) : null;
160
+ }
161
+ /** Reads the filename from a Content-Disposition header, preferring the RFC 5987 form. */
162
+ function filenameFromDisposition(disposition) {
163
+ if (!disposition) return null;
164
+ const extended = disposition.match(/filename\*\s*=\s*[\w-]*'[^']*'([^;]+)/i)?.[1]?.trim();
165
+ if (extended) try {
166
+ return decodeURIComponent(extended);
167
+ } catch {
168
+ return extended;
169
+ }
170
+ const plain = disposition.match(/filename\s*=\s*(?:"([^"]*)"|([^;]+))/i);
171
+ return (plain?.[1] ?? plain?.[2])?.trim() || null;
172
+ }
173
+ /**
174
+ * Lists candidate extensions for the content, most reliable first: explicit extensions, then
175
+ * names, then declared MIME types, then the content itself.
176
+ */
177
+ async function detectExtensions(buffer, { extension, filename, urls = [], contentType } = {}) {
178
+ const candidates = [
179
+ extensionNormalized(extension),
180
+ filename ? extensionFromPathname(filename) : null,
181
+ ...urls.map((url) => url ? extensionFromUrl(url) : null),
182
+ extensionFromMime(contentType),
183
+ await extensionFromBuffer(buffer) ?? extensionFromText(buffer)
184
+ ];
185
+ return [...new Set(candidates.filter((candidate) => !!candidate))];
186
+ }
187
+ function extensionFromUrl(url) {
188
+ try {
189
+ return extensionFromPathname(decodeURIComponent(new URL(url).pathname));
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ function extensionNormalized(extension, withDot = false) {
195
+ if (!extension) return "";
196
+ const normalized = extension.trim().toLowerCase().replace(/^[.-]+/, "");
197
+ return withDot ? `.${normalized}` : normalized;
198
+ }
199
+ function isTextExtension(extension) {
200
+ const type = mime.getType(extension);
201
+ if (!type) return false;
202
+ return type.startsWith("text/");
203
+ }
204
+ // src/converters/bingserp.ts
80
205
  const BING_SEARCH_URL = /^https:\/\/www\.bing\.com\/search\?q=/i;
81
206
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
82
207
  var BingSerpConverter = class {
83
208
  async convert(source, options = {}) {
84
- const extension = options.file_extension?.toLowerCase() ?? "";
85
- const url = options.url ?? "";
86
- if (extension !== ".html" && extension !== ".htm" || !BING_SEARCH_URL.test(url)) return null;
87
- const document = parseHtmlDocument(decodeText(source));
88
- const query = new URL(url).searchParams.get("q") ?? "";
89
- document.querySelectorAll(".tptt").forEach((element) => {
90
- if (element.textContent) element.textContent += " ";
91
- });
92
- document.querySelectorAll(".algoSlug_icon").forEach((element) => {
93
- element.remove();
94
- });
95
- const turndown = new CustomTurnDown();
96
- const markdown = `## A Bing search for '${query}' found the following results:\n\n${[...document.querySelectorAll(".b_algo")].map((result) => {
97
- result.querySelectorAll("a[href]").forEach((anchor) => {
98
- const href = anchor.getAttribute("href");
99
- if (!href) return;
100
- try {
101
- const encoded = new URL(href, url).searchParams.get("u");
102
- if (encoded) anchor.setAttribute("href", decodeBingUrl(encoded));
103
- } catch {}
209
+ if (options.extension !== "html" && options.extension !== "htm" || !BING_SEARCH_URL.test(options.url ?? "")) return null;
210
+ let query = new URL(options.url ?? "").searchParams.get("q") || null;
211
+ if (query) query = `Search results for "${query}"`;
212
+ return parseHtmlDocument(decodeText(source), (document) => {
213
+ document.querySelectorAll(".tptt").forEach((element) => {
214
+ if (element.textContent) element.textContent += " ";
104
215
  });
105
- return turndown.convertSoup(result).split(/\n+/).map((line) => line.trim()).filter(Boolean).join("\n");
106
- }).join("\n\n")}`;
107
- return {
108
- title: document.title || null,
109
- markdown,
110
- text_content: markdown
111
- };
216
+ document.querySelectorAll(".algoSlug_icon").forEach((element) => {
217
+ element.remove();
218
+ });
219
+ return {
220
+ title: {
221
+ type: "metadata",
222
+ value: query || document.title || null
223
+ },
224
+ markdown: {
225
+ type: "node",
226
+ value: [...document.querySelectorAll(".b_algo")].map((result) => {
227
+ result.querySelectorAll("a[href]").forEach((anchor) => {
228
+ const href = anchor.getAttribute("href");
229
+ if (!href) return;
230
+ try {
231
+ const encoded = new URL(href, options.url ?? "").searchParams.get("u");
232
+ if (encoded) anchor.setAttribute("href", decodeBingUrl(encoded));
233
+ } catch {}
234
+ });
235
+ return result;
236
+ })
237
+ }
238
+ };
239
+ });
112
240
  }
113
241
  };
114
242
  function decodeBingUrl(encoded) {
@@ -128,52 +256,46 @@ function decodeBingUrl(encoded) {
128
256
  }
129
257
  return new TextDecoder().decode(new Uint8Array(bytes));
130
258
  }
131
- //#endregion
132
- //#region src/converters/html.ts
259
+ // src/converters/html.ts
133
260
  var HtmlConverter = class {
134
261
  async convert(source, options) {
135
- const extension = options.file_extension?.toLowerCase() ?? "";
136
- if (extension !== ".html" && extension !== ".htm") return null;
262
+ if (options.extension !== "html" && options.extension !== "htm") return null;
137
263
  return this.convertHtml(decodeText(source));
138
264
  }
139
265
  async convertHtml(html) {
140
- const document = parseHtmlDocument(html);
141
- const title = document.title;
142
- document.querySelectorAll("script, style, title").forEach((element) => {
143
- element.remove();
266
+ return parseHtmlDocument(html, (document) => {
267
+ const title = document.title || null;
268
+ document.querySelectorAll("script, style, title").forEach((element) => {
269
+ element.remove();
270
+ });
271
+ return {
272
+ title: {
273
+ type: "metadata",
274
+ value: title
275
+ },
276
+ markdown: {
277
+ type: "node",
278
+ value: document
279
+ }
280
+ };
144
281
  });
145
- const markdown = new CustomTurnDown().convertSoup(document);
146
- return {
147
- title: title || null,
148
- markdown,
149
- text_content: markdown
150
- };
151
282
  }
152
283
  };
153
- //#endregion
154
- //#region src/converters/docx.ts
284
+ // src/converters/docx.ts
155
285
  var DocxConverter = class extends HtmlConverter {
156
286
  async convert(source, options) {
157
- if (options.file_extension?.toLowerCase() !== ".docx") return null;
287
+ if (options.extension !== "docx") return null;
158
288
  let mammothInput = { arrayBuffer: toArrayBuffer(source) };
159
289
  if (typeof process !== "undefined" && process.versions !== null && process.versions.node !== null) mammothInput = { buffer: Buffer.from(source) };
160
- const mammothOptions = {
161
- styleMap: options.styleMap,
162
- includeEmbeddedStyleMap: options.includeEmbeddedStyleMap,
163
- includeDefaultStyleMap: options.includeDefaultStyleMap,
164
- ignoreEmptyParagraphs: options.ignoreEmptyParagraphs,
165
- idPrefix: options.idPrefix
166
- };
167
- if (options.transformDocument) mammothOptions.transformDocument = options.transformDocument;
290
+ const mammothOptions = Object.fromEntries(Object.entries(options.mammoth ?? {}).flatMap(([key, value]) => value !== void 0 ? [[key, value]] : []));
168
291
  const html = await mammoth.convertToHtml(mammothInput, mammothOptions);
169
292
  return this.convertHtml(html.value);
170
293
  }
171
294
  };
172
- //#endregion
173
- //#region src/converters/ipynb.ts
295
+ // src/converters/ipynb.ts
174
296
  var IpynbConverter = class {
175
297
  async convert(source, options = {}) {
176
- if (options.file_extension?.toLowerCase() !== ".ipynb") return null;
298
+ if (options.extension !== "ipynb") return null;
177
299
  const notebook = JSON.parse(decodeText(source));
178
300
  const output = [];
179
301
  let title = notebook.metadata?.title ?? null;
@@ -188,267 +310,290 @@ var IpynbConverter = class {
188
310
  const markdown = output.join("\n\n");
189
311
  return {
190
312
  title,
191
- markdown,
192
- text_content: markdown
313
+ markdown
193
314
  };
194
315
  }
195
316
  };
196
- //#endregion
197
- //#region src/converters/pdf.ts
317
+ // src/converters/pdf.ts
198
318
  var PdfConverter = class {
199
319
  async convert(source, options) {
200
- if (options.file_extension?.toLowerCase() !== ".pdf") return null;
320
+ if (options.extension !== "pdf") return null;
201
321
  const pdf = await getDocumentProxy(source.slice());
202
322
  try {
203
323
  const [{ text }, { info }] = await Promise.all([extractText(pdf, { mergePages: true }), getMeta(pdf)]);
204
324
  return {
205
325
  title: typeof info.Title === "string" && info.Title.trim() ? info.Title : null,
206
- markdown: text,
207
- text_content: text
326
+ markdown: text
208
327
  };
209
328
  } finally {
210
329
  await pdf.loadingTask.destroy();
211
330
  }
212
331
  }
213
332
  };
214
- //#endregion
215
- //#region src/mime.ts
216
- const MIME_EXTENSIONS = {
217
- "application/atom+xml": ".atom",
218
- "application/json": ".json",
219
- "application/pdf": ".pdf",
220
- "application/zip": ".zip",
221
- "application/rss+xml": ".rss",
222
- "application/x-pdf": ".pdf",
223
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
224
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
225
- "application/xml": ".xml",
226
- "text/csv": ".csv",
227
- "text/html": ".html",
228
- "text/markdown": ".md",
229
- "text/plain": ".txt",
230
- "text/xml": ".xml"
231
- };
232
- const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
233
- ".csv",
234
- ".json",
235
- ".log",
236
- ".markdown",
237
- ".md",
238
- ".text",
239
- ".txt"
240
- ]);
241
- function extensionFromMime(contentType) {
242
- if (!contentType) return null;
243
- const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
244
- return MIME_EXTENSIONS[mime] ?? (mime.startsWith("text/") ? ".txt" : null);
245
- }
246
- function isTextExtension(extension) {
247
- return TEXT_EXTENSIONS.has(extension.toLowerCase());
248
- }
249
- function extensionFromPathname(pathname) {
250
- const filename = pathname.split("/").pop() ?? "";
251
- const dot = filename.lastIndexOf(".");
252
- return dot > 0 ? filename.slice(dot).toLowerCase() : null;
253
- }
254
- //#endregion
255
- //#region src/converters/plain-text.ts
333
+ // src/converters/plain-text.ts
256
334
  var PlainTextConverter = class {
257
335
  async convert(source, options = {}) {
258
- if (!isTextExtension(options.file_extension ?? "")) return null;
259
- const content = decodeText(source);
336
+ if (!isTextExtension(options.extension ?? "")) return null;
337
+ return {
338
+ title: null,
339
+ markdown: decodeText(source)
340
+ };
341
+ }
342
+ };
343
+ // src/converters/pptx.ts
344
+ var PptxConverter = class extends HtmlConverter {
345
+ async convert(source, options) {
346
+ if (options.extension !== "pptx") return null;
347
+ const slides = await pptxToHtml(toArrayBuffer(source), { domParserFactory: createPptxDomParser });
348
+ const sections = [];
349
+ for (const [index, html] of slides.entries()) {
350
+ const result = await this.convertHtml(`<html><body>${html}</body></html>`);
351
+ sections.push(`<!-- Slide number: ${index + 1} -->\n${result?.markdown.trim() ?? ""}`);
352
+ }
260
353
  return {
261
354
  title: null,
262
- markdown: content,
263
- text_content: content
355
+ markdown: sections.join("\n\n")
264
356
  };
265
357
  }
266
358
  };
267
- //#endregion
268
- //#region src/converters/wikipedia.ts
359
+ function createPptxDomParser() {
360
+ if (typeof globalThis.DOMParser === "function") return new globalThis.DOMParser();
361
+ patchXmlDom();
362
+ return new DOMParser();
363
+ }
364
+ let xmlDomPatched = false;
365
+ function patchXmlDom() {
366
+ if (xmlDomPatched) return;
367
+ xmlDomPatched = true;
368
+ defineMethod(Element.prototype, "querySelector", querySelector);
369
+ defineMethod(Element.prototype, "querySelectorAll", querySelectorAll);
370
+ defineMethod(Document.prototype, "querySelector", querySelector);
371
+ defineMethod(Document.prototype, "querySelectorAll", querySelectorAll);
372
+ defineGetter(Element.prototype, "children", function() {
373
+ return [...this.childNodes].filter((node) => node.nodeType === 1);
374
+ });
375
+ defineGetter(Node.prototype, "parentElement", function() {
376
+ const parent = this.parentNode;
377
+ return parent?.nodeType === 1 ? parent : null;
378
+ });
379
+ }
380
+ function querySelector(selector) {
381
+ for (const group of selector.split(",")) {
382
+ const match = queryByPath(this, group.trim());
383
+ if (match) return match;
384
+ }
385
+ return null;
386
+ }
387
+ function querySelectorAll() {
388
+ return [];
389
+ }
390
+ function queryByPath(root, selector) {
391
+ const tokens = selector.split(/\s+/).filter(Boolean);
392
+ let current = [root];
393
+ for (const token of tokens) {
394
+ const localName = selectorLocalName(token);
395
+ const next = [];
396
+ for (const node of current) next.push(...Array.from(node.getElementsByTagNameNS("*", localName)));
397
+ current = next;
398
+ }
399
+ return current[0] ?? null;
400
+ }
401
+ function selectorLocalName(token) {
402
+ if (token.includes("|")) return token.slice(token.lastIndexOf("|") + 1);
403
+ return token.replace(/\\:/g, ":").split(":").pop() ?? token;
404
+ }
405
+ function defineMethod(target, name, value) {
406
+ Object.defineProperty(target, name, {
407
+ configurable: true,
408
+ writable: true,
409
+ value
410
+ });
411
+ }
412
+ function defineGetter(target, name, get) {
413
+ if (Object.getOwnPropertyDescriptor(target, name)) return;
414
+ Object.defineProperty(target, name, {
415
+ configurable: true,
416
+ get
417
+ });
418
+ }
419
+ // src/converters/wikipedia.ts
269
420
  const WIKIPEDIA_URL = /^https?:\/\/[a-z]{2,3}\.wikipedia\.org\//i;
270
421
  var WikipediaConverter = class {
271
422
  async convert(source, options = {}) {
272
- const extension = options.file_extension?.toLowerCase() ?? "";
273
- if (extension !== ".html" && extension !== ".htm" || !WIKIPEDIA_URL.test(options.url ?? "")) return null;
274
- const document = parseHtmlDocument(decodeText(source));
275
- document.querySelectorAll("script, style").forEach((element) => {
276
- element.remove();
423
+ if (options.extension !== "html" && options.extension !== "htm" || !WIKIPEDIA_URL.test(options.url ?? "")) return null;
424
+ return parseHtmlDocument(decodeText(source), (document) => {
425
+ document.querySelectorAll("script, style").forEach((element) => {
426
+ element.remove();
427
+ });
428
+ const body = document.querySelector("div#mw-content-text");
429
+ return {
430
+ title: {
431
+ type: "metadata",
432
+ value: document.querySelector("span.mw-page-title-main")?.textContent?.trim() || document.title || null
433
+ },
434
+ markdown: {
435
+ type: "node",
436
+ value: body || document
437
+ }
438
+ };
277
439
  });
278
- const body = document.querySelector("div#mw-content-text");
279
- const title = document.querySelector("span.mw-page-title-main")?.textContent?.trim() || document.title || null;
280
- const markdown = body ? `# ${title ?? "Wikipedia"}\n\n${new CustomTurnDown().convertSoup(body)}` : new CustomTurnDown().convertSoup(document);
281
- return {
282
- title,
283
- markdown,
284
- text_content: markdown
285
- };
286
440
  }
287
441
  };
288
- //#endregion
289
- //#region src/converters/xlsx.ts
290
- const { Workbook } = ExcelJS;
442
+ // src/converters/xlsx.ts
291
443
  var XlsxConverter = class extends HtmlConverter {
292
444
  async convert(source, options) {
293
- if (options.file_extension?.toLowerCase() !== ".xlsx") return null;
294
- const workbook = new Workbook();
295
- await workbook.xlsx.load(toArrayBuffer(source));
445
+ if (options.extension !== "xlsx") return null;
446
+ const sheets = await readExcelFile(toArrayBuffer(source));
296
447
  const sections = [];
297
- for (const worksheet of workbook.worksheets) {
298
- const html = worksheetToHtml(worksheet);
448
+ for (const { sheet, data } of sheets) {
449
+ const html = sheetToHtml(data);
299
450
  if (!html) continue;
300
451
  const result = await this.convertHtml(html);
301
- sections.push(`## ${worksheet.name}\n${result?.markdown.trim() ?? ""}`);
452
+ sections.push(`## ${sheet}\n${result?.markdown.trim() ?? ""}`);
302
453
  }
303
- const markdown = sections.join("\n\n");
304
454
  return {
305
- title: workbook.title || "Untitled",
306
- markdown,
307
- text_content: markdown
455
+ title: null,
456
+ markdown: sections.join("\n\n")
308
457
  };
309
458
  }
310
459
  };
311
- function worksheetToHtml(worksheet) {
312
- const columnCount = worksheet.columnCount;
313
- if (!columnCount || !worksheet.actualRowCount) return "";
314
- const rows = [];
315
- worksheet.eachRow({ includeEmpty: true }, (row) => {
460
+ function sheetToHtml(rows) {
461
+ const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
462
+ if (!columnCount || rows.length === 0) return "";
463
+ return `<html><body><table>${rows.map((row) => {
316
464
  const cells = [];
317
- for (let column = 1; column <= columnCount; column++) cells.push(`<td>${escapeHtml(cellText(row.getCell(column)))}</td>`);
318
- rows.push(`<tr>${cells.join("")}</tr>`);
319
- });
320
- return `<html><body><table>${rows.join("")}</table></body></html>`;
465
+ for (let column = 0; column < columnCount; column++) cells.push(`<td>${escapeHtml(cellText(row[column]))}</td>`);
466
+ return `<tr>${cells.join("")}</tr>`;
467
+ }).join("")}</table></body></html>`;
321
468
  }
322
- function cellText(cell) {
323
- if (cell.isMerged && cell.master !== cell) return "";
324
- return cell.text;
469
+ function cellText(value) {
470
+ if (value == null) return "";
471
+ if (value instanceof Date) return value.toISOString();
472
+ return String(value);
325
473
  }
326
474
  function escapeHtml(value) {
327
475
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
328
476
  }
329
- //#endregion
330
- //#region src/converters/xml-rss-atom.ts
477
+ // src/converters/xml-rss-atom.ts
331
478
  var RSSConverter = class {
332
479
  async convert(source, options = {}) {
333
- const extension = options.file_extension?.toLowerCase() ?? "";
334
- if (![
335
- ".xml",
336
- ".rss",
337
- ".atom"
338
- ].includes(extension)) return null;
480
+ if (options.extension !== "xml" && options.extension !== "rss" && options.extension !== "atom") return null;
339
481
  const document = new DOMParser().parseFromString(decodeText(source), "text/xml");
340
- if (document.getElementsByTagName("rss").length > 0) return this.parseRss(document);
341
- if (document.getElementsByTagName("feed")[0]?.getElementsByTagName("entry").length) return this.parseAtom(document);
482
+ if (document.getElementsByTagName("rss").length > 0) return parseRss(document);
483
+ if (document.getElementsByTagName("feed")[0]?.getElementsByTagName("entry").length) return parseAtom(document);
342
484
  return null;
343
485
  }
344
- parseAtom(document) {
345
- const root = document.getElementsByTagName("feed")[0];
346
- if (!root) return null;
347
- const title = getText(root, "title");
348
- const subtitle = getText(root, "subtitle");
349
- const sections = [`# ${title ?? "Feed"}`];
350
- if (subtitle) sections.push(subtitle);
351
- for (const entry of [...root.getElementsByTagName("entry")]) {
352
- const entryTitle = getText(entry, "title");
353
- const updated = getText(entry, "updated");
354
- const summary = getText(entry, "summary");
355
- const content = getText(entry, "content");
356
- const lines = [];
357
- if (entryTitle) lines.push(`## ${entryTitle}`);
358
- if (updated) lines.push(`Updated on: ${updated}`);
359
- if (summary) lines.push(parseContent(summary));
360
- if (content) lines.push(parseContent(content));
361
- sections.push(lines.join("\n"));
362
- }
363
- const markdown = sections.join("\n\n");
364
- return {
365
- title,
366
- markdown,
367
- text_content: markdown
368
- };
486
+ };
487
+ function parseAtom(document) {
488
+ const root = document.getElementsByTagName("feed")[0];
489
+ if (!root) return null;
490
+ const title = getText(root, "title");
491
+ const subtitle = getText(root, "subtitle");
492
+ const sections = [`# ${title ?? "Feed"}`];
493
+ if (subtitle) sections.push(subtitle);
494
+ for (const entry of [...root.getElementsByTagName("entry")]) {
495
+ const entryTitle = getText(entry, "title");
496
+ const updated = getText(entry, "updated");
497
+ const summary = getText(entry, "summary");
498
+ const content = getText(entry, "content");
499
+ const lines = [];
500
+ if (entryTitle) lines.push(`## ${entryTitle}`);
501
+ if (updated) lines.push(`Updated on: ${updated}`);
502
+ const node = summary || content;
503
+ if (node) lines.push(parseHtmlDocument(node, (document) => ({ markdown: {
504
+ type: "node",
505
+ value: document.documentElement
506
+ } })).markdown);
507
+ sections.push(lines.join("\n\n"));
369
508
  }
370
- parseRss(document) {
371
- const channel = document.getElementsByTagName("rss")[0]?.getElementsByTagName("channel")[0];
372
- if (!channel) return null;
373
- const title = getText(channel, "title");
374
- const description = getText(channel, "description");
375
- const sections = title ? [`# ${title}`] : [];
376
- if (description) sections.push(description);
377
- for (const item of [...channel.getElementsByTagName("item")]) {
378
- const itemTitle = getText(item, "title");
379
- const published = getText(item, "pubDate");
380
- const itemDescription = getText(item, "description");
381
- const content = getText(item, "content:encoded");
382
- const lines = [];
383
- if (itemTitle) lines.push(`## ${itemTitle}`);
384
- if (published) lines.push(`Published on: ${published}`);
385
- if (itemDescription) lines.push(parseContent(itemDescription));
386
- if (content) lines.push(parseContent(content));
387
- sections.push(lines.join("\n"));
388
- }
389
- const markdown = sections.join("\n\n");
390
- return {
391
- title,
392
- markdown,
393
- text_content: markdown
394
- };
509
+ return {
510
+ title,
511
+ markdown: sections.join("\n\n")
512
+ };
513
+ }
514
+ function parseRss(document) {
515
+ const channel = document.getElementsByTagName("rss")[0]?.getElementsByTagName("channel")[0];
516
+ if (!channel) return null;
517
+ const title = getText(channel, "title");
518
+ const description = getText(channel, "description");
519
+ const sections = title ? [`# ${title}`] : [];
520
+ if (description) sections.push(description);
521
+ for (const item of [...channel.getElementsByTagName("item")]) {
522
+ const itemTitle = getText(item, "title");
523
+ const published = getText(item, "pubDate");
524
+ const itemDescription = getText(item, "description");
525
+ const content = getText(item, "content:encoded");
526
+ const lines = [];
527
+ if (itemTitle) lines.push(`## ${itemTitle}`);
528
+ if (published) lines.push(`Published on: ${published}`);
529
+ const node = itemDescription || content;
530
+ if (node) lines.push(parseHtmlDocument(node, (document) => ({ markdown: {
531
+ type: "node",
532
+ value: document.documentElement
533
+ } })).markdown);
534
+ sections.push(lines.join("\n\n"));
395
535
  }
396
- };
397
- function parseContent(content) {
398
- return new CustomTurnDown().convertSoup(parseHtmlDocument(content).documentElement);
536
+ return {
537
+ title,
538
+ markdown: sections.join("\n\n")
539
+ };
399
540
  }
400
541
  function getText(element, tagName) {
401
542
  return element.getElementsByTagName(tagName)[0]?.textContent?.trim() || null;
402
543
  }
403
- //#endregion
404
- //#region src/converters/youtube.ts
544
+ // src/converters/youtube.ts
545
+ const YOUTUBE_URL = /^https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|shorts\/)?([a-zA-Z0-9_-]+)/i;
405
546
  var YouTubeConverter = class {
406
547
  async convert(source, options = {}) {
407
- const extension = options.file_extension?.toLowerCase() ?? "";
408
- if (extension !== ".html" && extension !== ".htm" || !options.url?.startsWith("https://www.youtube.com/watch?")) return null;
409
- const document = parseHtmlDocument(decodeText(source));
410
- const metadata = { title: document.title };
411
- document.querySelectorAll("meta").forEach((meta) => {
412
- const content = meta.getAttribute("content");
413
- const key = meta.getAttribute("itemprop") ?? meta.getAttribute("property") ?? meta.getAttribute("name");
414
- if (key && content) metadata[key] = content;
415
- });
416
- try {
417
- for (const script of document.querySelectorAll("script")) {
418
- const content = script.textContent ?? "";
419
- if (!content.includes("ytInitialData")) continue;
420
- const start = content.indexOf("{");
421
- const end = content.lastIndexOf("}");
422
- if (start >= 0 && end > start) {
423
- const description = findKey(JSON.parse(content.slice(start, end + 1)), "attributedDescriptionBodyText");
424
- if (isRecord(description) && typeof description.content === "string") metadata.description = description.content;
548
+ if (options.extension !== "html" && options.extension !== "htm" || !YOUTUBE_URL.test(options.url ?? "")) return null;
549
+ return parseHtmlDocument(decodeText(source), (document) => {
550
+ const metadata = {};
551
+ document.querySelectorAll("meta").forEach((meta) => {
552
+ const content = meta.getAttribute("content");
553
+ const key = meta.getAttribute("itemprop") ?? meta.getAttribute("property") ?? meta.getAttribute("name");
554
+ if (key && content) metadata[key] = content;
555
+ });
556
+ try {
557
+ for (const script of document.querySelectorAll("script")) {
558
+ const content = script.textContent ?? "";
559
+ if (!content.includes("ytInitialData")) continue;
560
+ const start = content.indexOf("{");
561
+ const end = content.lastIndexOf("}");
562
+ if (start >= 0 && end > start) {
563
+ const description = findKey(JSON.parse(content.slice(start, end + 1)), "attributedDescriptionBodyText");
564
+ if (isRecord(description) && typeof description.content === "string") metadata.description = description.content;
565
+ }
566
+ break;
425
567
  }
426
- break;
427
- }
428
- } catch {}
429
- const title = first(metadata, [
430
- "title",
431
- "og:title",
432
- "name"
433
- ]) || document.title || null;
434
- const lines = ["# YouTube"];
435
- if (title) lines.push(`## ${title}`);
436
- const stats = [];
437
- const views = first(metadata, ["interactionCount"]);
438
- const keywords = first(metadata, ["keywords"]);
439
- const runtime = first(metadata, ["duration"]);
440
- if (views) stats.push(`- **Views:** ${views}`);
441
- if (keywords) stats.push(`- **Keywords:** ${keywords}`);
442
- if (runtime) stats.push(`- **Runtime:** ${runtime}`);
443
- if (stats.length > 0) lines.push(`### Video Metadata\n${stats.join("\n")}`);
444
- const description = first(metadata, ["description", "og:description"]);
445
- if (description) lines.push(`### Description\n${description}`);
446
- const markdown = lines.join("\n\n");
447
- return {
448
- title,
449
- markdown,
450
- text_content: markdown
451
- };
568
+ } catch {}
569
+ const title = first(metadata, [
570
+ "title",
571
+ "og:title",
572
+ "name"
573
+ ]) || document.title || null;
574
+ const lines = ["# YouTube"];
575
+ if (title) lines.push(`## ${title}`);
576
+ const stats = [];
577
+ const views = first(metadata, ["interactionCount"]);
578
+ const keywords = first(metadata, ["keywords"]);
579
+ const runtime = first(metadata, ["duration"]);
580
+ if (views) stats.push(`- **Views:** ${views}`);
581
+ if (keywords) stats.push(`- **Keywords:** ${keywords}`);
582
+ if (runtime) stats.push(`- **Runtime:** ${runtime}`);
583
+ if (stats.length > 0) lines.push(`### Video Metadata\n\n${stats.join("\n")}`);
584
+ const description = first(metadata, ["description", "og:description"]);
585
+ if (description) lines.push(`### Description\n\n${description}`);
586
+ return {
587
+ title: {
588
+ type: "metadata",
589
+ value: title
590
+ },
591
+ markdown: {
592
+ type: "metadata",
593
+ value: lines.join("\n\n")
594
+ }
595
+ };
596
+ });
452
597
  }
453
598
  };
454
599
  function first(metadata, keys) {
@@ -471,23 +616,20 @@ function findKey(value, key) {
471
616
  function isRecord(value) {
472
617
  return typeof value === "object" && value !== null;
473
618
  }
474
- //#endregion
475
- //#region src/converters/zip.ts
619
+ // src/converters/zip.ts
476
620
  var ZipConverter = class {
477
621
  async convert(source, options = {}) {
478
- if (options.file_extension?.toLowerCase() !== ".zip") return null;
622
+ if (options.extension !== "zip") return null;
479
623
  const files = unzipSync(source, { filter: (file) => !file.name.endsWith("/") });
480
- const converters = options._parent_converters ?? [];
624
+ const converters = options._converters ?? [];
481
625
  const sections = [`Content from the zip file \`${options.url ?? "archive.zip"}\`:`];
482
626
  for (const [name, bytes] of Object.entries(files)) {
483
627
  const result = await convertEntry(bytes, name, converters, options);
484
628
  if (result) sections.push(`## File: ${name}\n\n${result.markdown}`);
485
629
  }
486
- const markdown = sections.join("\n\n");
487
630
  return {
488
631
  title: null,
489
- markdown,
490
- text_content: markdown
632
+ markdown: sections.join("\n\n")
491
633
  };
492
634
  }
493
635
  };
@@ -496,9 +638,9 @@ async function convertEntry(source, name, converters, options) {
496
638
  if (!extension) return null;
497
639
  const entryOptions = {
498
640
  ...options,
499
- file_extension: extension,
641
+ extension,
500
642
  url: void 0,
501
- _parent_converters: converters
643
+ _converters: converters
502
644
  };
503
645
  for (const converter of converters) try {
504
646
  const result = await converter.convert(source, entryOptions);
@@ -506,41 +648,38 @@ async function convertEntry(source, name, converters, options) {
506
648
  } catch {}
507
649
  return null;
508
650
  }
509
- //#endregion
510
- //#region src/markitdown.ts
651
+ // src/markitdown.ts
511
652
  var MarkItDown = class {
653
+ converters = [];
512
654
  constructor() {
513
- this.converters = [];
514
- this.registerConverter(new PlainTextConverter());
515
- this.registerConverter(new HtmlConverter());
516
- this.registerConverter(new ZipConverter());
517
- this.registerConverter(new RSSConverter());
518
- this.registerConverter(new WikipediaConverter());
519
- this.registerConverter(new YouTubeConverter());
520
- this.registerConverter(new BingSerpConverter());
521
- this.registerConverter(new DocxConverter());
522
- this.registerConverter(new XlsxConverter());
523
- this.registerConverter(new PdfConverter());
524
- this.registerConverter(new IpynbConverter());
655
+ const ordered = [
656
+ PlainTextConverter,
657
+ HtmlConverter,
658
+ ZipConverter,
659
+ RSSConverter,
660
+ WikipediaConverter,
661
+ YouTubeConverter,
662
+ BingSerpConverter,
663
+ DocxConverter,
664
+ XlsxConverter,
665
+ PptxConverter,
666
+ PdfConverter,
667
+ IpynbConverter
668
+ ];
669
+ for (const converter of ordered) this.registerConverter(new converter());
525
670
  }
526
671
  /** Convert a URL, response, blob, array buffer, data view, or typed array. */
527
672
  async convert(source, options = {}) {
528
673
  if (typeof source === "string" || source instanceof URL) return this.convertUrl(source.toString(), options);
529
674
  if (isResponse(source)) return this.convertResponse(source, options);
530
- const extensions = /* @__PURE__ */ new Set();
531
- this.addExtension(extensions, options.file_extension?.replace(/^\./, ""));
532
- if (isBlob(source)) {
533
- this.addExtension(extensions, extensionFromMime(source.type));
534
- if ("name" in source && typeof source.name === "string") this.addExtension(extensions, extensionFromPathname(source.name));
535
- }
536
- if (extensions.size === 0) throw new Error("Could not determine the file type. Provide `file_extension` when converting bytes.");
537
- return this.convertBytes(await toUint8Array(source), extensions, options);
538
- }
539
- /**
540
- * @deprecated Pass the byte source directly to `convert`.
541
- */
542
- async convertBuffer(source, options) {
543
- return this.convert(source, options);
675
+ const bytes = await toUint8Array(source);
676
+ const extensions = await detectExtensions(bytes, {
677
+ extension: options.extension,
678
+ filename: isBlob(source) && "name" in source ? String(source.name) : null,
679
+ urls: [options.url],
680
+ contentType: isBlob(source) ? source.type : null
681
+ });
682
+ return this.convertBytes(bytes, extensions, options);
544
683
  }
545
684
  async convertUrl(source, { fetch: fetchImplementation = globalThis.fetch, ...options }) {
546
685
  const url = new URL(source);
@@ -554,44 +693,39 @@ var MarkItDown = class {
554
693
  });
555
694
  }
556
695
  async convertResponse(response, options) {
557
- const extensions = /* @__PURE__ */ new Set();
558
- this.addExtension(extensions, options.file_extension);
559
- this.addExtension(extensions, extensionFromMime(response.headers.get("content-type")));
560
- const filename = (response.headers.get("content-disposition") ?? "").match(/filename\*?=(?:UTF-8''|["']?)([^;"']+)/i)?.[1];
561
- if (filename) this.addExtension(extensions, extensionFromPathname(decodeURIComponent(filename.trim())));
562
- const responseUrl = options.url || response.url;
563
- if (responseUrl) this.addExtension(extensions, extensionFromPathname(new URL(responseUrl).pathname));
564
- if (extensions.size === 0) throw new Error("Could not determine the file type. Provide `file_extension` or a Content-Type header.");
565
- return this.convertBytes(new Uint8Array(await response.arrayBuffer()), extensions, {
696
+ const bytes = new Uint8Array(await response.arrayBuffer());
697
+ const extensions = await detectExtensions(bytes, {
698
+ extension: options.extension,
699
+ filename: filenameFromDisposition(response.headers.get("content-disposition")),
700
+ urls: [options.url, response.url],
701
+ contentType: response.headers.get("content-type")
702
+ });
703
+ return this.convertBytes(bytes, extensions, {
566
704
  ...options,
567
- url: responseUrl
705
+ url: options.url || response.url || void 0
568
706
  });
569
707
  }
570
708
  async convertBytes(source, extensions, options) {
709
+ if (extensions.length === 0) throw new Error("Could not determine the file type. Provide `extension` for better results.");
571
710
  let lastError;
572
711
  for (const extension of extensions) for (const converter of this.converters) try {
573
712
  const result = await converter.convert(source, {
574
713
  ...options,
575
- file_extension: extension,
576
- _parent_converters: this.converters
714
+ extension,
715
+ _converters: this.converters
577
716
  });
578
717
  if (result) {
579
718
  result.markdown = result.markdown.replace(/\r\n|\r|\n/g, "\n").trim().replace(/\n{3,}/g, "\n\n");
580
- result.text_content = result.markdown;
581
719
  return result;
582
720
  }
583
721
  } catch (error) {
584
722
  if (options.debug) console.error("Converter threw error:", error);
585
723
  lastError = error;
586
724
  }
587
- const formats = [...extensions].join(", ");
725
+ const formats = extensions.join(", ");
588
726
  if (lastError) throw new Error(`Could not convert ${formats} to Markdown: ${String(lastError)}`);
589
727
  throw new Error(`Unsupported file type: ${formats}.`);
590
728
  }
591
- addExtension(extensions, extension) {
592
- if (!extension) return;
593
- extensions.add(extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`);
594
- }
595
729
  registerConverter(converter) {
596
730
  this.converters.unshift(converter);
597
731
  }
@@ -599,13 +733,53 @@ var MarkItDown = class {
599
733
  function isResponse(value) {
600
734
  return typeof Response !== "undefined" && value instanceof Response;
601
735
  }
602
- //#endregion
603
- //#region src/cli.ts
604
- new Command().name("markitdown").version(version).argument("<file>", "The file to convert to Markdown").action(async (file) => {
605
- const extension = file.split(".").pop()?.toLowerCase();
606
- const bytes = await readFile(file);
607
- const result = await new MarkItDown().convert(new Uint8Array(bytes), { file_extension: extension });
608
- console.log(result);
609
- }).parse();
610
- //#endregion
611
- export {};
736
+ // src/cli.ts
737
+ const parser = object({
738
+ file: argument(path({
739
+ mustExist: true,
740
+ type: "file",
741
+ metavar: "FILE"
742
+ }), { description: message`File to convert.` }),
743
+ extension: optional(option("-x", "--extension", string({ metavar: "EXT" }), { description: message`File type to convert from, when it can't be detected from the file.` })),
744
+ json: withDefault(flag("--json", { description: message`Print the title and Markdown as JSON instead of only the Markdown.` }), false)
745
+ });
746
+ const program = defineProgram({
747
+ parser,
748
+ metadata: {
749
+ name: "markitdown",
750
+ version,
751
+ brief: message`Convert files to Markdown`,
752
+ description: message`Converts PDF, Word, Excel, PowerPoint, HTML, notebooks, feeds, zip archives and text files to Markdown. The file type is detected from the file's name and contents.`,
753
+ examples: message`Convert a document:
754
+
755
+ ${commandLine("markitdown report.docx")}
756
+
757
+ Convert a file without an extension as HTML:
758
+
759
+ ${commandLine("markitdown --extension html page")}`,
760
+ author: message`${author}`,
761
+ bugs: message`Report bugs at ${url(bugs.url)}.`
762
+ }
763
+ });
764
+ if (import.meta.main) {
765
+ const argv = process.argv.slice(2);
766
+ const parsed = runSync(program, {
767
+ args: argv.length ? argv : ["--help"],
768
+ aboveError: "none",
769
+ help: "both",
770
+ version: {
771
+ value: version,
772
+ option: true
773
+ },
774
+ completion: "both"
775
+ });
776
+ try {
777
+ const bytes = await readFile(parsed.file);
778
+ const result = await new MarkItDown().convert(new File([bytes], basename(parsed.file)), parsed.extension ? { extension: parsed.extension } : {});
779
+ console.log(parsed.json ? JSON.stringify(result, null, 2) : result?.markdown);
780
+ } catch (error) {
781
+ console.error(`Error: ${error.message}`);
782
+ process.exitCode = 1;
783
+ }
784
+ }
785
+ export { program };