@statewalker/content-extractors 0.2.2 → 0.3.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.
Files changed (73) hide show
  1. package/README.md +105 -10
  2. package/dist/collect-bytes.d.ts +10 -2
  3. package/dist/collect-bytes.d.ts.map +1 -1
  4. package/dist/collect-bytes.js +6 -4
  5. package/dist/collect-bytes.js.map +1 -1
  6. package/dist/extractor-registry.d.ts +2 -1
  7. package/dist/extractor-registry.d.ts.map +1 -1
  8. package/dist/extractor-registry.js +3 -2
  9. package/dist/extractor-registry.js.map +1 -1
  10. package/dist/extractors/docx-extractor.d.ts +5 -1
  11. package/dist/extractors/docx-extractor.d.ts.map +1 -1
  12. package/dist/extractors/docx-extractor.js +20 -11
  13. package/dist/extractors/docx-extractor.js.map +1 -1
  14. package/dist/extractors/html-extractor.d.ts +3 -0
  15. package/dist/extractors/html-extractor.d.ts.map +1 -1
  16. package/dist/extractors/html-extractor.js +8 -5
  17. package/dist/extractors/html-extractor.js.map +1 -1
  18. package/dist/extractors/index.d.ts +12 -9
  19. package/dist/extractors/index.d.ts.map +1 -1
  20. package/dist/extractors/index.js +23 -18
  21. package/dist/extractors/index.js.map +1 -1
  22. package/dist/extractors/markdown-extractor.d.ts +3 -0
  23. package/dist/extractors/markdown-extractor.d.ts.map +1 -1
  24. package/dist/extractors/markdown-extractor.js +8 -4
  25. package/dist/extractors/markdown-extractor.js.map +1 -1
  26. package/dist/extractors/pdf-extractor.d.ts +3 -0
  27. package/dist/extractors/pdf-extractor.d.ts.map +1 -1
  28. package/dist/extractors/pdf-extractor.js +17 -11
  29. package/dist/extractors/pdf-extractor.js.map +1 -1
  30. package/dist/extractors/text-extractor.d.ts +3 -0
  31. package/dist/extractors/text-extractor.d.ts.map +1 -1
  32. package/dist/extractors/text-extractor.js +8 -4
  33. package/dist/extractors/text-extractor.js.map +1 -1
  34. package/dist/extractors/xlsx-extractor.d.ts +5 -1
  35. package/dist/extractors/xlsx-extractor.d.ts.map +1 -1
  36. package/dist/extractors/xlsx-extractor.js +56 -36
  37. package/dist/extractors/xlsx-extractor.js.map +1 -1
  38. package/dist/html-to-markdown.d.ts +3 -1
  39. package/dist/html-to-markdown.d.ts.map +1 -1
  40. package/dist/html-to-markdown.js +9 -1
  41. package/dist/html-to-markdown.js.map +1 -1
  42. package/dist/index.d.ts +11 -9
  43. package/dist/index.js +9 -7
  44. package/dist/limits.d.ts +20 -0
  45. package/dist/limits.d.ts.map +1 -0
  46. package/dist/limits.js +9 -0
  47. package/dist/limits.js.map +1 -0
  48. package/dist/markdown-to-html.d.ts +11 -3
  49. package/dist/markdown-to-html.d.ts.map +1 -1
  50. package/dist/markdown-to-html.js +13 -6
  51. package/dist/markdown-to-html.js.map +1 -1
  52. package/dist/mime-utils.js +3 -2
  53. package/dist/mime-utils.js.map +1 -1
  54. package/dist/zip-size.d.ts +16 -0
  55. package/dist/zip-size.d.ts.map +1 -0
  56. package/dist/zip-size.js +90 -0
  57. package/dist/zip-size.js.map +1 -0
  58. package/package.json +8 -4
  59. package/src/collect-bytes.ts +17 -2
  60. package/src/extractor-registry.ts +3 -2
  61. package/src/extractors/docx-extractor.ts +26 -11
  62. package/src/extractors/html-extractor.ts +8 -4
  63. package/src/extractors/index.ts +29 -23
  64. package/src/extractors/markdown-extractor.ts +8 -3
  65. package/src/extractors/pdf-extractor.ts +20 -11
  66. package/src/extractors/text-extractor.ts +8 -3
  67. package/src/extractors/xlsx-extractor.ts +72 -41
  68. package/src/html-to-markdown.ts +4 -1
  69. package/src/index.ts +2 -0
  70. package/src/limits.ts +20 -0
  71. package/src/markdown-to-html.ts +16 -10
  72. package/src/mime-utils.ts +3 -2
  73. package/src/zip-size.ts +113 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zip-size.js","names":[],"sources":["../src/zip-size.ts"],"sourcesContent":["const EOCD_SIG = 0x06054b50;\nconst ZIP64_LOCATOR_SIG = 0x07064b50;\nconst ZIP64_EOCD_SIG = 0x06064b50;\nconst CD_ENTRY_SIG = 0x02014b50;\nconst EOCD_SIZE = 22;\nconst MAX_COMMENT = 0xffff;\n\nfunction fail(message: string): never {\n throw new Error(`Invalid ZIP archive: ${message}`);\n}\n\n/**\n * Sums the uncompressed sizes of all entries declared in a ZIP archive's central directory,\n * without inflating anything. Supports ZIP64. Throws when the bytes are not a readable ZIP.\n *\n * The sizes are what the archive claims; a parser that inflates the data still has to check\n * them, which JSZip (used by mammoth and exceljs) does.\n */\nexport function zipUncompressedSize(bytes: Uint8Array): number {\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const len = bytes.byteLength;\n\n // The end-of-central-directory record sits at the end, followed by a comment of up to 64 KiB.\n let eocd = -1;\n const stop = Math.max(0, len - EOCD_SIZE - MAX_COMMENT);\n for (let i = len - EOCD_SIZE; i >= stop; i--) {\n if (view.getUint32(i, true) === EOCD_SIG) {\n eocd = i;\n break;\n }\n }\n if (eocd < 0) fail(\"not a ZIP file (no end of central directory record)\");\n\n // Where the central directory must end: right before the (ZIP64) end record.\n let cdEnd = eocd;\n let entries = view.getUint16(eocd + 10, true);\n let cdSize = view.getUint32(eocd + 12, true);\n let cdOffset = view.getUint32(eocd + 16, true);\n\n if (entries === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {\n const locator = eocd - 20;\n if (locator >= 0 && view.getUint32(locator, true) === ZIP64_LOCATOR_SIG) {\n const z64 = Number(view.getBigUint64(locator + 8, true));\n if (z64 + 56 > len || view.getUint32(z64, true) !== ZIP64_EOCD_SIG) {\n fail(\"bad ZIP64 end of central directory record\");\n }\n cdEnd = z64;\n entries = Number(view.getBigUint64(z64 + 32, true));\n cdSize = Number(view.getBigUint64(z64 + 40, true));\n cdOffset = Number(view.getBigUint64(z64 + 48, true));\n }\n }\n\n // An archive with bytes prepended (a self-extracting stub, for example) keeps offsets relative\n // to the original start; JSZip accepts it, so find the directory from its end instead.\n if (cdOffset + 4 > len || (entries > 0 && view.getUint32(cdOffset, true) !== CD_ENTRY_SIG)) {\n const shifted = cdEnd - cdSize;\n if (shifted >= 0 && shifted + 4 <= len && view.getUint32(shifted, true) === CD_ENTRY_SIG) {\n cdOffset = shifted;\n }\n }\n if (cdOffset + cdSize > len) fail(\"central directory lies outside the file\");\n\n let total = 0;\n let pos = cdOffset;\n for (let n = 0; n < entries; n++) {\n if (pos + 46 > len || view.getUint32(pos, true) !== CD_ENTRY_SIG) {\n fail(\"truncated or corrupt central directory\");\n }\n const size32 = view.getUint32(pos + 24, true);\n const nameLen = view.getUint16(pos + 28, true);\n const extraLen = view.getUint16(pos + 30, true);\n const commentLen = view.getUint16(pos + 32, true);\n let size = size32;\n if (size32 === 0xffffffff) {\n size = readZip64Size(view, pos + 46 + nameLen, extraLen);\n }\n total += size;\n pos += 46 + nameLen + extraLen + commentLen;\n }\n return total;\n}\n\n/** Reads the 64-bit uncompressed size from an entry's ZIP64 extended information extra field. */\nfunction readZip64Size(view: DataView, start: number, length: number): number {\n const end = start + length;\n if (end > view.byteLength) fail(\"truncated or corrupt central directory\");\n let p = start;\n while (p + 4 <= end) {\n const id = view.getUint16(p, true);\n const size = view.getUint16(p + 2, true);\n if (id === 0x0001) {\n // The uncompressed size is the first field when the 32-bit value is 0xFFFFFFFF.\n if (size < 8 || p + 12 > end) fail(\"bad ZIP64 extra field\");\n return Number(view.getBigUint64(p + 4, true));\n }\n p += 4 + size;\n }\n fail(\"missing ZIP64 extra field\");\n}\n\n/**\n * Throws when the uncompressed size declared by a ZIP archive exceeds `maxBytes`, so a small\n * \"zip bomb\" is refused before a parser inflates it.\n */\nexport function assertZipUncompressedSize(bytes: Uint8Array, maxBytes: number): void {\n const size = zipUncompressedSize(bytes);\n if (size > maxBytes) {\n throw new Error(\n `Archive uncompressed size ${size} bytes exceeds the limit of ${maxBytes} bytes`,\n );\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/content-extractors",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Content extractors: turn PDF, DOCX, XLSX, Markdown, and HTML into markdown text.",
7
- "homepage": "https://github.com/statewalker/statewalker-content",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/statewalker-search/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/statewalker-search/tree/main/packages/content-extractors#readme",
8
11
  "author": {
9
12
  "name": "Mikhail Kotelnikov",
10
13
  "email": "mikhail.kotelnikov@gmail.com"
@@ -12,7 +15,8 @@
12
15
  "license": "MIT",
13
16
  "repository": {
14
17
  "type": "git",
15
- "url": "git+ssh://git@github.com/statewalker/statewalker-content.git"
18
+ "url": "git+https://github.com/statewalker/statewalker-search.git",
19
+ "directory": "packages/content-extractors"
16
20
  },
17
21
  "exports": {
18
22
  ".": {
@@ -40,7 +44,6 @@
40
44
  ],
41
45
  "dependencies": {
42
46
  "@joplin/turndown-plugin-gfm": "^1.0.68",
43
- "@types/turndown": "^5.0.6",
44
47
  "exceljs": "^4.4.0",
45
48
  "mammoth": "^1.13.0",
46
49
  "markdown-it": "^15.0.2",
@@ -49,6 +52,7 @@
49
52
  },
50
53
  "devDependencies": {
51
54
  "@types/node": "^26.6.4",
55
+ "@types/turndown": "^5.0.6",
52
56
  "rimraf": "^6.1.3",
53
57
  "tsdown": "^0.23.0",
54
58
  "typescript": "^7.0.2",
@@ -1,3 +1,12 @@
1
+ /** Options for {@link collectBytes} and {@link collectText}. */
2
+ export interface CollectOptions {
3
+ /**
4
+ * Largest input accepted, in bytes. Reading stops and an error is thrown as soon as the total
5
+ * exceeds it, so an oversized or endless stream is never held in memory. No limit when omitted.
6
+ */
7
+ maxBytes?: number;
8
+ }
9
+
1
10
  /**
2
11
  * Bridges the streaming world (chunked iterables from fetch, file reads,
3
12
  * etc.) with libraries that require a single contiguous buffer (pdfjs-dist,
@@ -6,12 +15,17 @@
6
15
  */
7
16
  export async function collectBytes(
8
17
  content: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
18
+ options: CollectOptions = {},
9
19
  ): Promise<Uint8Array> {
20
+ const { maxBytes = Number.POSITIVE_INFINITY } = options;
10
21
  const chunks: Uint8Array[] = [];
11
22
  let totalLength = 0;
12
23
  for await (const chunk of content) {
13
- chunks.push(chunk);
14
24
  totalLength += chunk.length;
25
+ if (totalLength > maxBytes) {
26
+ throw new Error(`Input exceeds the limit of ${maxBytes} bytes`);
27
+ }
28
+ chunks.push(chunk);
15
29
  }
16
30
  const first = chunks[0];
17
31
  if (chunks.length === 1 && first) {
@@ -33,7 +47,8 @@ export async function collectBytes(
33
47
  */
34
48
  export async function collectText(
35
49
  content: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
50
+ options: CollectOptions = {},
36
51
  ): Promise<string> {
37
- const bytes = await collectBytes(content);
52
+ const bytes = await collectBytes(content, options);
38
53
  return new TextDecoder().decode(bytes);
39
54
  }
@@ -13,10 +13,11 @@ export class ExtractorRegistry {
13
13
 
14
14
  /**
15
15
  * Associates a suffix pattern with an extractor so the registry can
16
- * route files to the right handler based on their filename.
16
+ * route files to the right handler based on their filename. Patterns are
17
+ * case-insensitive, like the file names they are matched against.
17
18
  */
18
19
  registerByPattern(pattern: string, extractor: ContentExtractor): void {
19
- this.patternMap.set(pattern, extractor);
20
+ this.patternMap.set(pattern.toLowerCase(), extractor);
20
21
  }
21
22
 
22
23
  /**
@@ -1,21 +1,36 @@
1
1
  import mammoth from "mammoth";
2
2
  import { collectBytes } from "../collect-bytes.js";
3
3
  import { htmlToMarkdown } from "../html-to-markdown.js";
4
+ import {
5
+ DEFAULT_MAX_BYTES,
6
+ DEFAULT_MAX_UNCOMPRESSED_BYTES,
7
+ type ZipExtractorLimits,
8
+ } from "../limits.js";
4
9
  import type { ContentExtractor } from "../types.js";
10
+ import { assertZipUncompressedSize } from "../zip-size.js";
5
11
 
6
12
  /**
7
13
  * Unlocks Word document content for text-based pipelines via a two-stage
8
14
  * conversion (DOCX -> HTML -> markdown). Supplies both `buffer` and
9
15
  * `arrayBuffer` to mammoth so the same code runs in Node.js and browsers
10
- * without environment detection.
16
+ * without environment detection. The input size and the archive's declared
17
+ * uncompressed size are checked before mammoth inflates it.
11
18
  */
12
- export const docxExtractor: ContentExtractor = async (content) => {
13
- const bytes = await collectBytes(content);
14
- const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
15
- // mammoth uses "buffer" in Node.js and "arrayBuffer" in browsers
16
- const result = await mammoth.convertToHtml({
17
- buffer: arrayBuffer,
18
- arrayBuffer,
19
- } as Parameters<typeof mammoth.convertToHtml>[0]);
20
- return htmlToMarkdown(result.value);
21
- };
19
+ export function createDocxExtractor(limits: ZipExtractorLimits = {}): ContentExtractor {
20
+ const { maxBytes = DEFAULT_MAX_BYTES, maxUncompressedBytes = DEFAULT_MAX_UNCOMPRESSED_BYTES } =
21
+ limits;
22
+ return async (content) => {
23
+ const bytes = await collectBytes(content, { maxBytes });
24
+ assertZipUncompressedSize(bytes, maxUncompressedBytes);
25
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
26
+ // mammoth uses "buffer" in Node.js and "arrayBuffer" in browsers
27
+ const result = await mammoth.convertToHtml({
28
+ buffer: arrayBuffer,
29
+ arrayBuffer,
30
+ } as Parameters<typeof mammoth.convertToHtml>[0]);
31
+ return htmlToMarkdown(result.value);
32
+ };
33
+ }
34
+
35
+ /** DOCX/DOCM extractor with the default limits. */
36
+ export const docxExtractor: ContentExtractor = createDocxExtractor();
@@ -1,5 +1,6 @@
1
1
  import { collectText } from "../collect-bytes.js";
2
2
  import { htmlToMarkdown } from "../html-to-markdown.js";
3
+ import { DEFAULT_MAX_BYTES, type ExtractorLimits } from "../limits.js";
3
4
  import type { ContentExtractor } from "../types.js";
4
5
 
5
6
  /**
@@ -7,7 +8,10 @@ import type { ContentExtractor } from "../types.js";
7
8
  * text format. Delegates to `htmlToMarkdown` for the actual conversion,
8
9
  * keeping this extractor focused on the bytes-to-string bridging step.
9
10
  */
10
- export const htmlExtractor: ContentExtractor = async (content) => {
11
- const html = await collectText(content);
12
- return htmlToMarkdown(html);
13
- };
11
+ export function createHtmlExtractor(limits: ExtractorLimits = {}): ContentExtractor {
12
+ const { maxBytes = DEFAULT_MAX_BYTES } = limits;
13
+ return async (content) => htmlToMarkdown(await collectText(content, { maxBytes }));
14
+ }
15
+
16
+ /** HTML extractor with the default limits. */
17
+ export const htmlExtractor: ContentExtractor = createHtmlExtractor();
@@ -1,34 +1,40 @@
1
- export { docxExtractor } from "./docx-extractor.js";
2
- export { htmlExtractor } from "./html-extractor.js";
3
- export { markdownExtractor } from "./markdown-extractor.js";
4
- export { pdfExtractor } from "./pdf-extractor.js";
5
- export { textExtractor } from "./text-extractor.js";
6
- export { xlsxExtractor } from "./xlsx-extractor.js";
1
+ export { createDocxExtractor, docxExtractor } from "./docx-extractor.js";
2
+ export { createHtmlExtractor, htmlExtractor } from "./html-extractor.js";
3
+ export { createMarkdownExtractor, markdownExtractor } from "./markdown-extractor.js";
4
+ export { createPdfExtractor, pdfExtractor } from "./pdf-extractor.js";
5
+ export { createTextExtractor, textExtractor } from "./text-extractor.js";
6
+ export { createXlsxExtractor, xlsxExtractor } from "./xlsx-extractor.js";
7
7
 
8
8
  import { ExtractorRegistry } from "../extractor-registry.js";
9
- import { docxExtractor } from "./docx-extractor.js";
10
- import { htmlExtractor } from "./html-extractor.js";
11
- import { markdownExtractor } from "./markdown-extractor.js";
12
- import { pdfExtractor } from "./pdf-extractor.js";
13
- import { textExtractor } from "./text-extractor.js";
14
- import { xlsxExtractor } from "./xlsx-extractor.js";
9
+ import type { ZipExtractorLimits } from "../limits.js";
10
+ import { createDocxExtractor } from "./docx-extractor.js";
11
+ import { createHtmlExtractor } from "./html-extractor.js";
12
+ import { createMarkdownExtractor } from "./markdown-extractor.js";
13
+ import { createPdfExtractor } from "./pdf-extractor.js";
14
+ import { createTextExtractor } from "./text-extractor.js";
15
+ import { createXlsxExtractor } from "./xlsx-extractor.js";
15
16
 
16
17
  /**
17
18
  * Provides a batteries-included registry so most consumers can start
18
19
  * extracting immediately without manual format registration. Custom
19
20
  * registries are still supported for apps that need to override or
20
- * limit the built-in set.
21
+ * limit the built-in set. `limits` applies to every registered extractor
22
+ * (`maxUncompressedBytes` only to DOCX, DOCM and XLSX); omitted limits
23
+ * take the defaults.
21
24
  */
22
- export function createDefaultRegistry(): ExtractorRegistry {
25
+ export function createDefaultRegistry(limits: ZipExtractorLimits = {}): ExtractorRegistry {
26
+ const markdown = createMarkdownExtractor(limits);
27
+ const html = createHtmlExtractor(limits);
28
+ const docx = createDocxExtractor(limits);
23
29
  const registry = new ExtractorRegistry();
24
- registry.registerByPattern("*.md", markdownExtractor);
25
- registry.registerByPattern("*.markdown", markdownExtractor);
26
- registry.registerByPattern("*.txt", textExtractor);
27
- registry.registerByPattern("*.html", htmlExtractor);
28
- registry.registerByPattern("*.htm", htmlExtractor);
29
- registry.registerByPattern("*.pdf", pdfExtractor);
30
- registry.registerByPattern("*.docx", docxExtractor);
31
- registry.registerByPattern("*.docm", docxExtractor);
32
- registry.registerByPattern("*.xlsx", xlsxExtractor);
30
+ registry.registerByPattern("*.md", markdown);
31
+ registry.registerByPattern("*.markdown", markdown);
32
+ registry.registerByPattern("*.txt", createTextExtractor(limits));
33
+ registry.registerByPattern("*.html", html);
34
+ registry.registerByPattern("*.htm", html);
35
+ registry.registerByPattern("*.pdf", createPdfExtractor(limits));
36
+ registry.registerByPattern("*.docx", docx);
37
+ registry.registerByPattern("*.docm", docx);
38
+ registry.registerByPattern("*.xlsx", createXlsxExtractor(limits));
33
39
  return registry;
34
40
  }
@@ -1,4 +1,5 @@
1
1
  import { collectText } from "../collect-bytes.js";
2
+ import { DEFAULT_MAX_BYTES, type ExtractorLimits } from "../limits.js";
2
3
  import type { ContentExtractor } from "../types.js";
3
4
 
4
5
  /**
@@ -7,6 +8,10 @@ import type { ContentExtractor } from "../types.js";
7
8
  * the registry can distinguish `.md` from `.txt` -- allowing future
8
9
  * markdown-specific processing without breaking existing registrations.
9
10
  */
10
- export const markdownExtractor: ContentExtractor = async (content) => {
11
- return collectText(content);
12
- };
11
+ export function createMarkdownExtractor(limits: ExtractorLimits = {}): ContentExtractor {
12
+ const { maxBytes = DEFAULT_MAX_BYTES } = limits;
13
+ return (content) => collectText(content, { maxBytes });
14
+ }
15
+
16
+ /** Markdown extractor with the default limits. */
17
+ export const markdownExtractor: ContentExtractor = createMarkdownExtractor();
@@ -1,5 +1,6 @@
1
1
  import { extractText } from "unpdf";
2
2
  import { collectBytes } from "../collect-bytes.js";
3
+ import { DEFAULT_MAX_BYTES, type ExtractorLimits } from "../limits.js";
3
4
  import type { ContentExtractor } from "../types.js";
4
5
 
5
6
  /**
@@ -7,17 +8,25 @@ import type { ContentExtractor } from "../types.js";
7
8
  * section with a `## Page N` header. This preserves page boundaries in
8
9
  * the indexed content, making search results more precise.
9
10
  */
10
- export const pdfExtractor: ContentExtractor = async (content) => {
11
- const bytes = await collectBytes(content);
12
- const { text: pages } = await extractText(bytes);
11
+ export function createPdfExtractor(limits: ExtractorLimits = {}): ContentExtractor {
12
+ const { maxBytes = DEFAULT_MAX_BYTES } = limits;
13
+ return async (content) => {
14
+ const bytes = await collectBytes(content, { maxBytes });
15
+ // A copy, always a plain Uint8Array: pdfjs rejects a Node Buffer, and it takes over (detaches)
16
+ // the buffer it is given, which would empty the caller's array.
17
+ const { text: pages } = await extractText(new Uint8Array(bytes));
13
18
 
14
- const sections: string[] = [];
15
- for (let i = 0; i < pages.length; i++) {
16
- const pageText = pages[i]?.trim();
17
- if (pageText) {
18
- sections.push(`## Page ${i + 1}\n\n${pageText}`);
19
+ const sections: string[] = [];
20
+ for (let i = 0; i < pages.length; i++) {
21
+ const pageText = pages[i]?.trim();
22
+ if (pageText) {
23
+ sections.push(`## Page ${i + 1}\n\n${pageText}`);
24
+ }
19
25
  }
20
- }
21
26
 
22
- return sections.join("\n\n");
23
- };
27
+ return sections.join("\n\n");
28
+ };
29
+ }
30
+
31
+ /** PDF extractor with the default limits. */
32
+ export const pdfExtractor: ContentExtractor = createPdfExtractor();
@@ -1,4 +1,5 @@
1
1
  import { collectText } from "../collect-bytes.js";
2
+ import { DEFAULT_MAX_BYTES, type ExtractorLimits } from "../limits.js";
2
3
  import type { ContentExtractor } from "../types.js";
3
4
 
4
5
  /**
@@ -6,6 +7,10 @@ import type { ContentExtractor } from "../types.js";
6
7
  * decode bytes to a string. Exists as a distinct entry so `.txt` files
7
8
  * route through the same pipeline as every other format.
8
9
  */
9
- export const textExtractor: ContentExtractor = async (content) => {
10
- return collectText(content);
11
- };
10
+ export function createTextExtractor(limits: ExtractorLimits = {}): ContentExtractor {
11
+ const { maxBytes = DEFAULT_MAX_BYTES } = limits;
12
+ return (content) => collectText(content, { maxBytes });
13
+ }
14
+
15
+ /** Plain-text extractor with the default limits. */
16
+ export const textExtractor: ContentExtractor = createTextExtractor();
@@ -1,57 +1,88 @@
1
1
  import ExcelJS from "exceljs";
2
2
  import { collectBytes } from "../collect-bytes.js";
3
+ import {
4
+ DEFAULT_MAX_BYTES,
5
+ DEFAULT_MAX_UNCOMPRESSED_BYTES,
6
+ type ZipExtractorLimits,
7
+ } from "../limits.js";
3
8
  import type { ContentExtractor } from "../types.js";
9
+ import { assertZipUncompressedSize } from "../zip-size.js";
10
+
11
+ /**
12
+ * Makes a cell value safe inside a Markdown table row: `|` becomes `\|` so it does not start a
13
+ * new column, and every line break (CR, LF, CRLF) becomes one space so it does not end the row.
14
+ */
15
+ export function escapeCell(value: string): string {
16
+ return value.replace(/\|/g, "\\|").replace(/\r\n|\r|\n/g, " ");
17
+ }
18
+
19
+ /** Width of the widest row. A loop, not `Math.max(...)`, so large sheets do not overflow the stack. */
20
+ export function maxColumnCount(rows: readonly (readonly unknown[])[]): number {
21
+ let max = 0;
22
+ for (const row of rows) {
23
+ if (row.length > max) max = row.length;
24
+ }
25
+ return max;
26
+ }
4
27
 
5
28
  /**
6
29
  * Converts Excel spreadsheets into markdown tables so the content
7
30
  * is searchable and consumable by text-based pipelines. Each worksheet
8
31
  * becomes a separate section with a level-2 heading. Empty sheets are
9
- * skipped to avoid noise.
32
+ * skipped to avoid noise. The input size and the archive's declared
33
+ * uncompressed size are checked before exceljs inflates it.
10
34
  */
11
- export const xlsxExtractor: ContentExtractor = async (content) => {
12
- const bytes = await collectBytes(content);
13
- const workbook = new ExcelJS.Workbook();
14
- const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
15
- await workbook.xlsx.load(arrayBuffer as ArrayBuffer);
16
-
17
- const sections: string[] = [];
18
-
19
- workbook.eachSheet((sheet) => {
20
- const rows: string[][] = [];
21
-
22
- sheet.eachRow((row) => {
23
- const cells: string[] = [];
24
- row.eachCell({ includeEmpty: true }, (cell) => {
25
- try {
26
- const v = cell.value;
27
- cells.push(v == null ? "" : String(v));
28
- } catch {
29
- cells.push("");
30
- }
35
+ export function createXlsxExtractor(limits: ZipExtractorLimits = {}): ContentExtractor {
36
+ const { maxBytes = DEFAULT_MAX_BYTES, maxUncompressedBytes = DEFAULT_MAX_UNCOMPRESSED_BYTES } =
37
+ limits;
38
+ return async (content) => {
39
+ const bytes = await collectBytes(content, { maxBytes });
40
+ assertZipUncompressedSize(bytes, maxUncompressedBytes);
41
+ const workbook = new ExcelJS.Workbook();
42
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
43
+ await workbook.xlsx.load(arrayBuffer as ArrayBuffer);
44
+
45
+ const sections: string[] = [];
46
+
47
+ workbook.eachSheet((sheet) => {
48
+ const rows: string[][] = [];
49
+
50
+ sheet.eachRow((row) => {
51
+ const cells: string[] = [];
52
+ row.eachCell({ includeEmpty: true }, (cell) => {
53
+ try {
54
+ const v = cell.value;
55
+ cells.push(v == null ? "" : escapeCell(String(v)));
56
+ } catch {
57
+ cells.push("");
58
+ }
59
+ });
60
+ rows.push(cells);
31
61
  });
32
- rows.push(cells);
33
- });
34
62
 
35
- if (rows.length === 0) return;
63
+ if (rows.length === 0) return;
36
64
 
37
- // Normalize column count to the widest row
38
- const colCount = Math.max(...rows.map((r) => r.length));
39
- const padded = rows.map((r) => {
40
- while (r.length < colCount) r.push("");
41
- return r;
42
- });
65
+ // Normalize column count to the widest row
66
+ const colCount = maxColumnCount(rows);
67
+ for (const r of rows) {
68
+ while (r.length < colCount) r.push("");
69
+ }
43
70
 
44
- // First row as headers, rest as data
45
- const [headers, ...dataRows] = padded;
46
- if (!headers) return;
71
+ // First row as headers, rest as data
72
+ const [headers, ...dataRows] = rows;
73
+ if (!headers) return;
47
74
 
48
- const headerLine = `| ${headers.join(" | ")} |`;
49
- const separator = `| ${headers.map(() => "---").join(" | ")} |`;
50
- const bodyLines = dataRows.map((r) => `| ${r.join(" | ")} |`);
75
+ const headerLine = `| ${headers.join(" | ")} |`;
76
+ const separator = `| ${headers.map(() => "---").join(" | ")} |`;
77
+ const bodyLines = dataRows.map((r) => `| ${r.join(" | ")} |`);
78
+
79
+ const table = [headerLine, separator, ...bodyLines].join("\n");
80
+ sections.push(`## ${sheet.name}\n\n${table}`);
81
+ });
51
82
 
52
- const table = [headerLine, separator, ...bodyLines].join("\n");
53
- sections.push(`## ${sheet.name}\n\n${table}`);
54
- });
83
+ return sections.join("\n\n");
84
+ };
85
+ }
55
86
 
56
- return sections.join("\n\n");
57
- };
87
+ /** XLSX extractor with the default limits. */
88
+ export const xlsxExtractor: ContentExtractor = createXlsxExtractor();
@@ -6,7 +6,9 @@ import TurndownService from "turndown";
6
6
  * Normalizes HTML into markdown so downstream consumers get a uniform
7
7
  * text format regardless of whether the source was HTML, DOCX (via
8
8
  * mammoth's HTML output), or any other HTML-producing extractor.
9
- * Uses Turndown with GFM tables and strikethrough support.
9
+ * Uses Turndown with GFM tables and strikethrough support. The content of
10
+ * `<script>`, `<style>`, `<noscript>` and `<template>` is dropped: it is
11
+ * code or markup, not document text.
10
12
  */
11
13
  export function htmlToMarkdown(html: string): string {
12
14
  const turndownService = new TurndownService({
@@ -18,5 +20,6 @@ export function htmlToMarkdown(html: string): string {
18
20
  strongDelimiter: "**",
19
21
  });
20
22
  turndownService.use(gfm);
23
+ turndownService.remove(["script", "style", "noscript", "template"]);
21
24
  return turndownService.turndown(html);
22
25
  }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export * from "./collect-bytes.js";
2
2
  export * from "./extractor-registry.js";
3
3
  export * from "./extractors/index.js";
4
4
  export * from "./html-to-markdown.js";
5
+ export * from "./limits.js";
5
6
  export * from "./markdown-to-html.js";
6
7
  export * from "./mime-utils.js";
7
8
  export * from "./types.js";
9
+ export * from "./zip-size.js";
package/src/limits.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Default largest input an extractor reads: 100 MiB. */
2
+ export const DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
3
+
4
+ /** Default largest total uncompressed size of a DOCX/XLSX archive: 500 MiB. */
5
+ export const DEFAULT_MAX_UNCOMPRESSED_BYTES = 500 * 1024 * 1024;
6
+
7
+ /** Limits every built-in extractor accepts. Pass `Infinity` to turn a limit off. */
8
+ export interface ExtractorLimits {
9
+ /** Largest input read, in bytes. Default {@link DEFAULT_MAX_BYTES}. */
10
+ maxBytes?: number;
11
+ }
12
+
13
+ /** Limits of the ZIP-based extractors (DOCX, DOCM, XLSX). */
14
+ export interface ZipExtractorLimits extends ExtractorLimits {
15
+ /**
16
+ * Largest sum of the uncompressed entry sizes declared in the archive's central directory,
17
+ * checked before the archive is inflated. Default {@link DEFAULT_MAX_UNCOMPRESSED_BYTES}.
18
+ */
19
+ maxUncompressedBytes?: number;
20
+ }
@@ -1,21 +1,27 @@
1
1
  import MarkdownIt from "markdown-it";
2
2
 
3
- const md = new MarkdownIt({
4
- html: true,
5
- linkify: true,
6
- typographer: true,
7
- breaks: false,
8
- });
3
+ const options = { linkify: true, typographer: true, breaks: false } as const;
4
+ const escaping = new MarkdownIt({ ...options, html: false });
5
+ const passThrough = new MarkdownIt({ ...options, html: true });
6
+
7
+ export interface MarkdownToHtmlOptions {
8
+ /**
9
+ * Pass raw HTML in the Markdown through to the output. Off by default: raw HTML is escaped,
10
+ * so `<script>` or `<img onerror>` in an uploaded file cannot run when the output is shown.
11
+ * Turn it on only for trusted input, or sanitise the output before rendering it.
12
+ */
13
+ html?: boolean;
14
+ }
9
15
 
10
16
  /**
11
17
  * Provides the reverse of `htmlToMarkdown` for cases where extracted
12
18
  * markdown needs to be rendered back to HTML (e.g. previews, email
13
- * output). Configured with pass-through raw HTML and auto-linking
14
- * so embedded markup and bare URLs survive the round-trip.
19
+ * output). Bare URLs are auto-linked. Raw HTML is escaped unless
20
+ * `options.html` is set.
15
21
  */
16
- export function markdownToHtml(markdown: string): string {
22
+ export function markdownToHtml(markdown: string, options: MarkdownToHtmlOptions = {}): string {
17
23
  if (!markdown || markdown.trim() === "") {
18
24
  return "";
19
25
  }
20
- return md.render(markdown);
26
+ return (options.html ? passThrough : escaping).render(markdown);
21
27
  }
package/src/mime-utils.ts CHANGED
@@ -18,8 +18,9 @@ const MIME_MAP: Record<string, string> = {
18
18
  * Content-Type header and need a MIME type for the registry fallback path.
19
19
  */
20
20
  export function getMimeType(path: string): string | undefined {
21
- const dotIndex = path.lastIndexOf(".");
21
+ const name = path.slice(path.lastIndexOf("/") + 1);
22
+ const dotIndex = name.lastIndexOf(".");
22
23
  if (dotIndex === -1) return undefined;
23
- const ext = path.slice(dotIndex).toLowerCase();
24
+ const ext = name.slice(dotIndex).toLowerCase();
24
25
  return MIME_MAP[ext];
25
26
  }