officeparser 7.0.3 → 7.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.
Files changed (45) hide show
  1. package/README.md +152 -18
  2. package/dist/OfficeGenerator.d.ts +1 -1
  3. package/dist/OfficeGenerator.js +16 -7
  4. package/dist/OfficeParser.js +6 -0
  5. package/dist/cli.d.ts +4 -0
  6. package/dist/cli.js +12 -3
  7. package/dist/defaults.js +27 -1
  8. package/dist/generators/BaseGenerator.d.ts +3 -3
  9. package/dist/generators/ChunkingGenerator.js +31 -4
  10. package/dist/generators/CsvGenerator.d.ts +1 -1
  11. package/dist/generators/HtmlGenerator.d.ts +2 -1
  12. package/dist/generators/HtmlGenerator.js +462 -40
  13. package/dist/generators/MarkdownGenerator.d.ts +1 -1
  14. package/dist/generators/MarkdownGenerator.js +3 -1
  15. package/dist/generators/PdfGenerator.d.ts +1 -1
  16. package/dist/generators/PdfGenerator.js +51 -10
  17. package/dist/generators/RtfGenerator.d.ts +2 -1
  18. package/dist/generators/RtfGenerator.js +43 -6
  19. package/dist/generators/TextGenerator.d.ts +1 -1
  20. package/dist/officeparser.browser.d.ts +377 -53
  21. package/dist/officeparser.browser.iife.js +380 -93
  22. package/dist/officeparser.browser.mjs +380 -93
  23. package/dist/parsers/CsvParser.js +6 -1
  24. package/dist/parsers/ExcelParser.js +69 -21
  25. package/dist/parsers/HtmlParser.js +15 -1
  26. package/dist/parsers/MarkdownParser.js +18 -10
  27. package/dist/parsers/OpenOfficeParser.js +61 -34
  28. package/dist/parsers/PdfParser.js +26 -1
  29. package/dist/parsers/PowerPointParser.js +168 -40
  30. package/dist/parsers/RtfParser.js +30 -24
  31. package/dist/parsers/WordParser.js +158 -11
  32. package/dist/sbom.cdx.json +100 -100
  33. package/dist/types.d.ts +383 -53
  34. package/dist/types.js +4 -0
  35. package/dist/utils/astUtils.d.ts +2 -2
  36. package/dist/utils/astUtils.js +2 -1
  37. package/dist/utils/configUtils.d.ts +5 -0
  38. package/dist/utils/configUtils.js +69 -2
  39. package/dist/utils/errorUtils.d.ts +20 -0
  40. package/dist/utils/errorUtils.js +39 -3
  41. package/dist/utils/moduleLoader.js +3 -3
  42. package/dist/utils/ocrUtils.js +271 -66
  43. package/dist/utils/xmlUtils.d.ts +17 -0
  44. package/dist/utils/xmlUtils.js +85 -1
  45. package/package.json +3 -2
@@ -16,13 +16,14 @@ const OfficeGenerator_js_1 = require("../OfficeGenerator.js");
16
16
  * @param toTextSync - Synchronous text extraction logic (for backward compatibility)
17
17
  * @returns An object conforming to OfficeParserAST
18
18
  */
19
- function createAST(type, metadata, content, attachments, config, toTextSync) {
19
+ function createAST(type, metadata, content, attachments, config, auxiliary, toTextSync) {
20
20
  return {
21
21
  config,
22
22
  type,
23
23
  metadata,
24
24
  content,
25
25
  attachments,
26
+ auxiliary,
26
27
  warnings: [],
27
28
  toText: toTextSync,
28
29
  async to(destination, genConfig) {
@@ -24,3 +24,8 @@ export declare function resolveParserConfig(userConfig?: OfficeParserConfig | Fu
24
24
  * @returns A fully populated configuration object
25
25
  */
26
26
  export declare function resolveGeneratorConfig<D extends string>(destination: D, astConfig?: OfficeParserConfig, userConfig?: GeneratorConfig<D> | FullGeneratorConfig): FullGeneratorConfig;
27
+ /**
28
+ * Validates the containerWidth option for HTML generation.
29
+ * Can be 'auto', a positive number, or a positive CSS length/percentage string.
30
+ */
31
+ export declare function isValidContainerWidth(width: any): boolean;
@@ -4,7 +4,10 @@ exports.isFullGeneratorConfig = isFullGeneratorConfig;
4
4
  exports.isFullParserConfig = isFullParserConfig;
5
5
  exports.resolveParserConfig = resolveParserConfig;
6
6
  exports.resolveGeneratorConfig = resolveGeneratorConfig;
7
+ exports.isValidContainerWidth = isValidContainerWidth;
7
8
  const defaults_js_1 = require("../defaults.js");
9
+ const types_js_1 = require("../types.js");
10
+ const errorUtils_js_1 = require("./errorUtils.js");
8
11
  /**
9
12
  * Deep clones an object, specifically handling arrays and plain objects.
10
13
  */
@@ -66,12 +69,25 @@ function resolveParserConfig(userConfig) {
66
69
  const { ocrConfig, ...rest } = userConfig;
67
70
  Object.assign(config, rest);
68
71
  if (ocrConfig) {
69
- config.ocrConfig = { ...config.ocrConfig, ...ocrConfig };
72
+ const { timeout, ...ocrRest } = ocrConfig;
73
+ config.ocrConfig = {
74
+ ...config.ocrConfig,
75
+ ...ocrRest,
76
+ timeout: {
77
+ autoTerminate: timeout?.autoTerminate !== undefined ? timeout.autoTerminate : config.ocrConfig.timeout.autoTerminate,
78
+ workerLoad: timeout?.workerLoad !== undefined ? timeout.workerLoad : config.ocrConfig.timeout.workerLoad,
79
+ recognition: timeout?.recognition !== undefined ? timeout.recognition : config.ocrConfig.timeout.recognition,
80
+ }
81
+ };
70
82
  }
71
83
  // 3. Handle legacy ocrLanguage mapping if not explicitly set in ocrConfig
72
84
  if (userConfig.ocrLanguage && !userConfig.ocrConfig?.language) {
73
85
  config.ocrConfig.language = userConfig.ocrLanguage;
74
86
  }
87
+ // 4. Propagate the top-level abortSignal to ocrConfig so the OCR subsystem is aware of it
88
+ if (config.abortSignal) {
89
+ config.ocrConfig.abortSignal = config.abortSignal;
90
+ }
75
91
  return config;
76
92
  }
77
93
  /**
@@ -87,6 +103,7 @@ function resolveGeneratorConfig(destination, astConfig, userConfig) {
87
103
  // If it's already a full config and we don't need to merge AST config, return it as is.
88
104
  // We assume FullGeneratorConfig is already "safe" (references resolved).
89
105
  if (isFullGeneratorConfig(userConfig) && !astConfig) {
106
+ validateHtmlConfigWidth(userConfig.htmlConfig, userConfig);
90
107
  return userConfig;
91
108
  }
92
109
  // 1. Start with full defaults (deep cloned to avoid reference sharing)
@@ -102,7 +119,22 @@ function resolveGeneratorConfig(destination, astConfig, userConfig) {
102
119
  return;
103
120
  for (const key in source) {
104
121
  if (source[key] !== undefined) {
105
- target[key] = source[key];
122
+ // Deep merge plain objects (like injections or margin)
123
+ if (typeof source[key] === 'object' &&
124
+ source[key] !== null &&
125
+ !Array.isArray(source[key]) &&
126
+ !(source[key] instanceof Function) &&
127
+ !(source[key] instanceof Date) &&
128
+ !(source[key] instanceof RegExp) &&
129
+ !(source[key] instanceof Buffer)) {
130
+ if (!target[key] || typeof target[key] !== 'object') {
131
+ target[key] = {};
132
+ }
133
+ mergeSubConfig(target[key], source[key]);
134
+ }
135
+ else {
136
+ target[key] = source[key];
137
+ }
106
138
  }
107
139
  }
108
140
  };
@@ -136,5 +168,40 @@ function resolveGeneratorConfig(destination, astConfig, userConfig) {
136
168
  // Since FullGeneratorConfig doesn't have an 'mdConfig', we rely on the generator implementation.
137
169
  }
138
170
  }
171
+ validateHtmlConfigWidth(config.htmlConfig, config);
139
172
  return config;
140
173
  }
174
+ /**
175
+ * Validates the containerWidth option for HTML generation.
176
+ * Can be 'auto', a positive number, or a positive CSS length/percentage string.
177
+ */
178
+ function isValidContainerWidth(width) {
179
+ if (width === 'auto')
180
+ return true;
181
+ if (typeof width === 'number') {
182
+ return Number.isFinite(width) && width > 0;
183
+ }
184
+ if (typeof width === 'string') {
185
+ const val = width.trim().toLowerCase();
186
+ if (val === 'auto')
187
+ return true;
188
+ const match = val.match(/^((?:\d*\.)?\d+)(px|%|em|rem|vw|vh|vmin|vmax|ch|in|cm|mm|pt|pc)?$/);
189
+ if (!match)
190
+ return false;
191
+ const numericValue = parseFloat(match[1]);
192
+ return numericValue > 0;
193
+ }
194
+ return false;
195
+ }
196
+ /**
197
+ * Emits a warning and falls back to 'auto' if the HTML containerWidth is invalid.
198
+ */
199
+ function validateHtmlConfigWidth(htmlConfig, config) {
200
+ if (htmlConfig?.containerWidth !== undefined) {
201
+ const width = htmlConfig.containerWidth;
202
+ if (!isValidContainerWidth(width)) {
203
+ (0, errorUtils_js_1.logWarning)(types_js_1.OfficeWarningType.INVALID_CONTAINER_WIDTH, config, width);
204
+ htmlConfig.containerWidth = 'auto';
205
+ }
206
+ }
207
+ }
@@ -27,6 +27,11 @@ export declare const getOfficeError: (type: OfficeErrorType, config?: OfficePars
27
27
  * Wraps an existing error with OfficeParser context and performs corruption detection.
28
28
  * Optionally logs the error to console.
29
29
  *
30
+ * **Important**: Do NOT pass AbortErrors to this function. AbortErrors (err.name === 'AbortError')
31
+ * represent deliberate user cancellation and must be re-thrown as-is from the catch block so that
32
+ * callers can reliably detect them via `err.name === 'AbortError'` or `err instanceof DOMException`.
33
+ * This function always returns a plain `new Error(...)`, which would strip the AbortError identity.
34
+ *
30
35
  * @param error - The original error object
31
36
  * @param config - Parser configuration
32
37
  * @param filePath - Optional file path for context
@@ -44,3 +49,18 @@ export declare const getWrappedError: (error: any, config: OfficeParserConfig, f
44
49
  * @param error - Optional original error object
45
50
  */
46
51
  export declare const logWarning: (type: OfficeWarningType, config?: OfficeParserConfig, info?: any, error?: any) => void;
52
+ /**
53
+ * Creates and returns a standard AbortError (DOMException if available).
54
+ * Used when the user signals cancellation of the parser operation.
55
+ *
56
+ * @returns Error object representing the abort action
57
+ */
58
+ export declare const getAbortError: () => Error;
59
+ /**
60
+ * Checks the provided AbortSignal and throws an AbortError if it was aborted.
61
+ * Helps cleanly interrupt loops and asynchronous phases of parsing.
62
+ *
63
+ * @param signal - Optional AbortSignal to inspect
64
+ * @throws {DOMException} If the signal has been aborted
65
+ */
66
+ export declare const checkAbortSignal: (signal?: AbortSignal | null) => void;
@@ -7,7 +7,7 @@
7
7
  * consistent error reporting across all parsers and the main entry point.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.logWarning = exports.getWrappedError = exports.getOfficeError = exports.getWarningMessage = void 0;
10
+ exports.checkAbortSignal = exports.getAbortError = exports.logWarning = exports.getWrappedError = exports.getOfficeError = exports.getWarningMessage = void 0;
11
11
  const types_js_1 = require("../types.js");
12
12
  /** Error header prefix for all error messages */
13
13
  const ERRORHEADER = "[OfficeParser]: ";
@@ -28,7 +28,8 @@ const ERROR_MESSAGES = {
28
28
  [types_js_1.OfficeErrorType.INVALID_STYLE_MAPPING]: (mapping) => `Invalid style mapping string: ${mapping}`,
29
29
  [types_js_1.OfficeErrorType.INVALID_SELECTOR]: (selector) => `Invalid selector: ${selector}`,
30
30
  [types_js_1.OfficeErrorType.INVALID_OUTPUT_MAPPING]: (output) => `Invalid output mapping: ${output}`,
31
- [types_js_1.OfficeErrorType.MISSING_EMBEDDING_FUNCTION]: `Semantic chunking requires an "embeddingFunction" to be provided in chunksConfig. This function must accept a string and return a Promise resolving to a number array (vector).`
31
+ [types_js_1.OfficeErrorType.MISSING_EMBEDDING_FUNCTION]: `Semantic chunking requires an "embeddingFunction" to be provided in chunksConfig. This function must accept a string and return a Promise resolving to a number array (vector).`,
32
+ [types_js_1.OfficeErrorType.OPERATION_ABORTED]: `The operation was aborted.`
32
33
  };
33
34
  /**
34
35
  * Lookup table for warning messages.
@@ -49,7 +50,8 @@ const WARNING_MESSAGES = {
49
50
  [types_js_1.OfficeWarningType.BUFFER_TYPE_MISMATCH]: (info) => `File content type mismatch: Detected '${info.detected}' but expected/provided '${info.expected}'. Parsing will proceed with '${info.expected}' as requested.`,
50
51
  [types_js_1.OfficeWarningType.FILE_TYPE_DETECTION_FAILED]: `Auto-detection of file type failed. This can happen on older Node.js versions with modern file-type versions. Please provide the 'fileType' hint in the configuration if parsing fails.`,
51
52
  [types_js_1.OfficeWarningType.EMPTY_CHUNK_GENERATED]: (strategy) => `No chunks generated for document. Check if the document content is compatible with the '${strategy}' strategy.`,
52
- [types_js_1.OfficeWarningType.WHITESPACE_NODE_SKIPPED]: (nodeType) => `Skipped whitespace-only node of type: ${nodeType}`
53
+ [types_js_1.OfficeWarningType.WHITESPACE_NODE_SKIPPED]: (nodeType) => `Skipped whitespace-only node of type: ${nodeType}`,
54
+ [types_js_1.OfficeWarningType.INVALID_CONTAINER_WIDTH]: (val) => `Invalid HTML containerWidth: ${JSON.stringify(val)}. Falling back to "auto". Width must be a positive number, a valid CSS length string (e.g., "900px", "100%", "50vw"), or "auto".`
53
55
  };
54
56
  /**
55
57
  * Creates a formatted warning message for a specific warning type.
@@ -118,6 +120,11 @@ exports.getOfficeError = getOfficeError;
118
120
  * Wraps an existing error with OfficeParser context and performs corruption detection.
119
121
  * Optionally logs the error to console.
120
122
  *
123
+ * **Important**: Do NOT pass AbortErrors to this function. AbortErrors (err.name === 'AbortError')
124
+ * represent deliberate user cancellation and must be re-thrown as-is from the catch block so that
125
+ * callers can reliably detect them via `err.name === 'AbortError'` or `err instanceof DOMException`.
126
+ * This function always returns a plain `new Error(...)`, which would strip the AbortError identity.
127
+ *
121
128
  * @param error - The original error object
122
129
  * @param config - Parser configuration
123
130
  * @param filePath - Optional file path for context
@@ -176,3 +183,32 @@ const logWarning = (type, config, info, error) => {
176
183
  reportIssue(issue, config);
177
184
  };
178
185
  exports.logWarning = logWarning;
186
+ /**
187
+ * Creates and returns a standard AbortError (DOMException if available).
188
+ * Used when the user signals cancellation of the parser operation.
189
+ *
190
+ * @returns Error object representing the abort action
191
+ */
192
+ const getAbortError = () => {
193
+ const message = ERROR_MESSAGES[types_js_1.OfficeErrorType.OPERATION_ABORTED];
194
+ if (typeof DOMException !== 'undefined') {
195
+ return new DOMException(message, 'AbortError');
196
+ }
197
+ const err = new Error(message);
198
+ err.name = 'AbortError';
199
+ return err;
200
+ };
201
+ exports.getAbortError = getAbortError;
202
+ /**
203
+ * Checks the provided AbortSignal and throws an AbortError if it was aborted.
204
+ * Helps cleanly interrupt loops and asynchronous phases of parsing.
205
+ *
206
+ * @param signal - Optional AbortSignal to inspect
207
+ * @throws {DOMException} If the signal has been aborted
208
+ */
209
+ const checkAbortSignal = (signal) => {
210
+ if (signal?.aborted) {
211
+ throw (0, exports.getAbortError)();
212
+ }
213
+ };
214
+ exports.checkAbortSignal = checkAbortSignal;
@@ -14,7 +14,7 @@ exports.loadPdfJs = loadPdfJs;
14
14
  const envUtils_js_1 = require("./envUtils.js");
15
15
  async function loadNodeEsmModule(specifier) {
16
16
  // In Node.js, we resolve the specifier to an absolute file URL.
17
- // This ensures that the dynamic import() call (executed via new Function)
17
+ // This ensures that the dynamic import() call
18
18
  // always finds the correct module regardless of the caller's context.
19
19
  // This is especially important in Node 18 for sub-paths of packages.
20
20
  try {
@@ -22,11 +22,11 @@ async function loadNodeEsmModule(specifier) {
22
22
  // @ts-ignore - require.resolve is available in Node.js
23
23
  const absolutePath = require.resolve(specifier);
24
24
  const fileUrl = pathToFileURL(absolutePath).href;
25
- return new Function('s', 'return import(s)')(fileUrl);
25
+ return import(fileUrl);
26
26
  }
27
27
  catch (e) {
28
28
  // Fallback for cases where require.resolve might fail (e.g. non-file specifiers)
29
- return new Function('s', 'return import(s)')(specifier);
29
+ return import(specifier);
30
30
  }
31
31
  }
32
32
  /**
@@ -13,6 +13,24 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.terminateOcr = exports.performOcr = void 0;
15
15
  const envUtils_js_1 = require("./envUtils.js");
16
+ const errorUtils_js_1 = require("./errorUtils.js");
17
+ /**
18
+ * Wraps a promise in a timeout.
19
+ *
20
+ * @param promise - The promise to wrap
21
+ * @param ms - Timeout duration in milliseconds
22
+ * @param errMsg - Error message to throw if timeout occurs
23
+ * @returns The wrapped promise
24
+ */
25
+ function withTimeout(promise, ms, errMsg) {
26
+ let id;
27
+ const timeout = new Promise((_, reject) => {
28
+ id = setTimeout(() => {
29
+ reject(new Error(errMsg));
30
+ }, ms);
31
+ });
32
+ return Promise.race([promise, timeout]).then((res) => { clearTimeout(id); return res; }, (err) => { clearTimeout(id); throw err; });
33
+ }
16
34
  /**
17
35
  * Manages a pool of Tesseract workers with "Smart Affinity".
18
36
  *
@@ -31,6 +49,7 @@ class OcrSchedulerManager {
31
49
  MAX_WORKERS = 4;
32
50
  idleTimeout = 10000; // 10s default
33
51
  timeoutId = null;
52
+ isProcessing = false;
34
53
  constructor() { }
35
54
  /**
36
55
  * Returns the singleton instance of the manager.
@@ -65,103 +84,289 @@ class OcrSchedulerManager {
65
84
  * Performs OCR on an image using the smart worker pool.
66
85
  *
67
86
  * @param image - Image data (Buffer, string path, or Blob)
68
- * @param config - OCR configuration (language, custom paths)
87
+ * @param config - OCR configuration (language, custom paths, timeouts, signal)
69
88
  * @returns Recognized text
70
89
  */
71
90
  async recognize(image, config) {
91
+ const signal = config?.abortSignal;
92
+ if (signal?.aborted) {
93
+ return Promise.reject((0, errorUtils_js_1.getAbortError)());
94
+ }
72
95
  return new Promise((resolve, reject) => {
73
- // Update idle timeout if provided
74
- if (config?.autoTerminateTimeout !== undefined) {
75
- this.idleTimeout = config.autoTerminateTimeout;
96
+ // Update idle timeout if provided.
97
+ // Priority: timeout.autoTerminate (new) > autoTerminateTimeout (deprecated) > built-in default.
98
+ const effectiveAutoTerminate = config?.timeout?.autoTerminate ?? config?.autoTerminateTimeout;
99
+ if (effectiveAutoTerminate !== undefined) {
100
+ this.idleTimeout = effectiveAutoTerminate;
76
101
  }
77
102
  // Reset the inactivity timer every time a new job is requested
78
103
  this.resetIdleTimer();
104
+ let abortListener = null;
105
+ let finished = false;
106
+ let job;
107
+ const cleanResolve = (val) => {
108
+ if (finished)
109
+ return;
110
+ finished = true;
111
+ if (job)
112
+ job.isFinished = true;
113
+ if (abortListener && signal) {
114
+ signal.removeEventListener('abort', abortListener);
115
+ }
116
+ resolve(val);
117
+ };
118
+ const cleanReject = (err) => {
119
+ if (finished)
120
+ return;
121
+ finished = true;
122
+ if (job)
123
+ job.isFinished = true;
124
+ if (abortListener && signal) {
125
+ signal.removeEventListener('abort', abortListener);
126
+ }
127
+ reject(err);
128
+ };
129
+ // Priority: timeout.recognition (new) > 30 s default.
130
+ const recogTimeout = config?.timeout?.recognition ?? 30000;
131
+ // Create job
132
+ job = {
133
+ image,
134
+ config: config || {},
135
+ resolve: cleanResolve,
136
+ reject: cleanReject,
137
+ startTime: Date.now(),
138
+ timeoutMs: recogTimeout
139
+ };
140
+ if (signal) {
141
+ abortListener = () => {
142
+ if (finished)
143
+ return;
144
+ const err = (0, errorUtils_js_1.getAbortError)();
145
+ cleanReject(err);
146
+ // 1. Remove job from queue if it hasn't run yet
147
+ const idx = this.queue.indexOf(job);
148
+ if (idx !== -1) {
149
+ this.queue.splice(idx, 1);
150
+ }
151
+ // 2. Find if any worker is currently running this job and terminate/remove it
152
+ const workerIndex = this.pool.findIndex(mw => mw.activeJob === job);
153
+ if (workerIndex !== -1) {
154
+ const managedWorker = this.pool[workerIndex];
155
+ // Remove from pool immediately to prevent reuse
156
+ this.pool.splice(workerIndex, 1);
157
+ // Terminate the worker process
158
+ try {
159
+ managedWorker.worker.terminate();
160
+ }
161
+ catch (e) { }
162
+ // Trigger queue processing for subsequent tasks
163
+ this.processQueue();
164
+ }
165
+ };
166
+ signal.addEventListener('abort', abortListener);
167
+ }
79
168
  // Add job to queue and trigger processing
80
- this.queue.push({ image, config: config || {}, resolve, reject });
169
+ this.queue.push(job);
81
170
  this.processQueue();
82
171
  });
83
172
  }
84
173
  /**
85
174
  * Attempts to process the next job in the queue using an available worker.
175
+ * Designed to be race-free and support concurrent/parallel job execution.
86
176
  */
87
177
  async processQueue() {
88
- if (this.queue.length === 0)
178
+ if (this.isProcessing)
89
179
  return;
90
- const nextJob = this.queue[0];
91
- const requestedLanguage = nextJob.config.language || 'eng';
92
- // 1. Find an idle worker with the EXACT language affinity
93
- let managed = this.pool.find(mw => !mw.isBusy && mw.language === requestedLanguage);
94
- // 2. If not found and we have room, create a new worker
95
- if (!managed && this.pool.length < this.MAX_WORKERS) {
96
- try {
97
- const { createWorker } = await import('tesseract.js');
98
- const options = { logger: () => { } };
99
- if (nextJob.config.workerPath)
100
- options.workerPath = nextJob.config.workerPath;
101
- if (nextJob.config.corePath)
102
- options.corePath = nextJob.config.corePath;
103
- if (nextJob.config.langPath)
104
- options.langPath = nextJob.config.langPath;
105
- const worker = await createWorker(requestedLanguage, 1, options);
106
- managed = {
107
- worker,
108
- language: requestedLanguage,
109
- lastUsed: Date.now(),
110
- isBusy: false
111
- };
112
- this.pool.push(managed);
180
+ this.isProcessing = true;
181
+ try {
182
+ while (this.queue.length > 0) {
183
+ const nextJob = this.queue[0];
184
+ if (nextJob.isFinished) {
185
+ this.queue.shift();
186
+ continue;
187
+ }
188
+ const requestedLanguage = nextJob.config.language || 'eng';
189
+ // 1. Find an idle worker with the EXACT language affinity
190
+ let managed = this.pool.find(mw => !mw.isBusy && mw.language === requestedLanguage);
191
+ // 2. If not found and we have room, create a new worker
192
+ if (!managed && this.pool.length < this.MAX_WORKERS) {
193
+ const job = this.queue.shift();
194
+ if (!job)
195
+ continue;
196
+ this.createAndRunWorker(job, requestedLanguage);
197
+ continue;
198
+ }
199
+ // 3. If still not found and we are at capacity, find the LRU idle worker and re-initialize it
200
+ if (!managed) {
201
+ const idleWorkers = this.pool.filter(mw => !mw.isBusy);
202
+ if (idleWorkers.length > 0) {
203
+ const job = this.queue.shift();
204
+ if (!job)
205
+ continue;
206
+ managed = idleWorkers.reduce((prev, curr) => (prev.lastUsed < curr.lastUsed ? prev : curr));
207
+ this.reinitializeAndRunWorker(managed, job, requestedLanguage);
208
+ continue;
209
+ }
210
+ }
211
+ // 4. If we have a worker ready, execute the job
212
+ if (managed) {
213
+ const job = this.queue.shift();
214
+ if (!job)
215
+ continue;
216
+ this.runWorker(managed, job);
217
+ continue;
218
+ }
219
+ // No workers can be allocated right now (all busy and pool at capacity). Break work loop.
220
+ break;
113
221
  }
114
- catch (err) {
115
- const job = this.queue.shift();
116
- job?.reject(err);
117
- this.processQueue(); // Try next job
222
+ }
223
+ finally {
224
+ this.isProcessing = false;
225
+ }
226
+ }
227
+ /**
228
+ * Helper to dynamically instantiate a Tesseract worker, register it to the pool, and run the job.
229
+ */
230
+ async createAndRunWorker(job, requestedLanguage) {
231
+ // Priority: timeout.workerLoad (new) > 60 s default.
232
+ const loadTimeout = job.config.timeout?.workerLoad ?? 60000;
233
+ let managed = null;
234
+ try {
235
+ const { createWorker } = await import('tesseract.js');
236
+ const options = { logger: () => { } };
237
+ if (job.config.workerPath)
238
+ options.workerPath = job.config.workerPath;
239
+ if (job.config.corePath)
240
+ options.corePath = job.config.corePath;
241
+ if (job.config.langPath)
242
+ options.langPath = job.config.langPath;
243
+ const workerPromise = createWorker(requestedLanguage, 1, options);
244
+ // To prevent dangling worker threads on timeout or abort, we register a post-resolution hook
245
+ // that terminates the worker if the promise finishes after the timeout has fired or the job is finished.
246
+ let hasTimedOutOrAborted = false;
247
+ workerPromise.then(async (worker) => {
248
+ if (hasTimedOutOrAborted || job.isFinished) {
249
+ try {
250
+ await worker.terminate();
251
+ }
252
+ catch (e) { }
253
+ }
254
+ }, () => { });
255
+ const worker = loadTimeout > 0
256
+ ? await withTimeout(workerPromise, loadTimeout, `OCR worker initialization timed out after ${loadTimeout}ms`).catch(err => {
257
+ hasTimedOutOrAborted = true;
258
+ throw err;
259
+ })
260
+ : await workerPromise;
261
+ // If the job finished/aborted while loading, clean up the worker and skip execution.
262
+ if (job.isFinished) {
263
+ hasTimedOutOrAborted = true;
264
+ try {
265
+ await worker.terminate();
266
+ }
267
+ catch (e) { }
268
+ this.processQueue();
118
269
  return;
119
270
  }
271
+ managed = {
272
+ worker,
273
+ language: requestedLanguage,
274
+ lastUsed: Date.now(),
275
+ isBusy: false
276
+ };
277
+ this.pool.push(managed);
278
+ await this.runWorker(managed, job);
120
279
  }
121
- // 3. If still not found and we are at capacity, find the LRU idle worker and re-initialize it
122
- if (!managed) {
123
- const idleWorkers = this.pool.filter(mw => !mw.isBusy);
124
- if (idleWorkers.length > 0) {
125
- // Find Least Recently Used idle worker
126
- managed = idleWorkers.reduce((prev, curr) => (prev.lastUsed < curr.lastUsed ? prev : curr));
127
- try {
128
- // Smart Re-initialization (v5 API)
129
- await managed.worker.reinitialize(requestedLanguage);
130
- managed.language = requestedLanguage;
280
+ catch (err) {
281
+ job.reject(err);
282
+ this.processQueue();
283
+ }
284
+ }
285
+ /**
286
+ * Helper to reinitialize an existing idle worker with a different language affinity and run the job.
287
+ */
288
+ async reinitializeAndRunWorker(managed, job, requestedLanguage) {
289
+ // Priority: timeout.workerLoad (new) > 60 s default.
290
+ const loadTimeout = job.config.timeout?.workerLoad ?? 60000;
291
+ managed.isBusy = true;
292
+ managed.lastUsed = Date.now();
293
+ managed.activeJob = job;
294
+ try {
295
+ const reinitPromise = managed.worker.reinitialize(requestedLanguage);
296
+ if (loadTimeout > 0) {
297
+ await withTimeout(reinitPromise, loadTimeout, `OCR worker re-initialization timed out after ${loadTimeout}ms`);
298
+ }
299
+ else {
300
+ await reinitPromise;
301
+ }
302
+ managed.language = requestedLanguage;
303
+ // If the job finished/aborted while reinitializing, clean up and skip execution.
304
+ if (job.isFinished) {
305
+ const index = this.pool.indexOf(managed);
306
+ if (index !== -1) {
307
+ this.pool.splice(index, 1);
131
308
  }
132
- catch (err) {
133
- // If reinitialization fails, we might need to recreate it, but for simplicity
134
- // we'll just fail this job and try another worker next time.
135
- const job = this.queue.shift();
136
- job?.reject(err);
137
- this.processQueue();
138
- return;
309
+ try {
310
+ await managed.worker.terminate();
139
311
  }
312
+ catch (e) { }
313
+ this.processQueue();
314
+ return;
140
315
  }
316
+ await this.runWorker(managed, job);
141
317
  }
142
- // 4. If we have a worker ready, execute the job
143
- if (managed) {
144
- const job = this.queue.shift();
145
- if (!job)
146
- return;
147
- managed.isBusy = true;
148
- managed.lastUsed = Date.now();
318
+ catch (err) {
319
+ // Re-initialization failed/timed out, remove worker from pool and terminate
320
+ const index = this.pool.indexOf(managed);
321
+ if (index !== -1) {
322
+ this.pool.splice(index, 1);
323
+ }
149
324
  try {
150
- const { data: { text } } = await managed.worker.recognize(job.image);
151
- job.resolve(text);
325
+ await managed.worker.terminate();
152
326
  }
153
- catch (err) {
154
- job.reject(err);
327
+ catch (e) { }
328
+ job.reject(err);
329
+ this.processQueue();
330
+ }
331
+ }
332
+ /**
333
+ * Helper to execute OCR text recognition on the worker and return the results.
334
+ */
335
+ async runWorker(managed, job) {
336
+ // Priority: timeout.recognition (new) > 30 s default.
337
+ const recogTimeout = job.config.timeout?.recognition ?? 30000;
338
+ managed.isBusy = true;
339
+ managed.lastUsed = Date.now();
340
+ managed.activeJob = job;
341
+ try {
342
+ const recognizePromise = managed.worker.recognize(job.image);
343
+ const { data: { text } } = recogTimeout > 0
344
+ ? await withTimeout(recognizePromise, recogTimeout, `OCR recognition timed out after ${recogTimeout}ms`)
345
+ : await recognizePromise;
346
+ job.resolve(text);
347
+ }
348
+ catch (err) {
349
+ // If it timed out, terminate and remove worker to avoid reusing a stuck process
350
+ if (err.message?.includes('timed out')) {
351
+ const index = this.pool.indexOf(managed);
352
+ if (index !== -1) {
353
+ this.pool.splice(index, 1);
354
+ }
355
+ try {
356
+ await managed.worker.terminate();
357
+ }
358
+ catch (e) { }
155
359
  }
156
- finally {
360
+ job.reject(err);
361
+ }
362
+ finally {
363
+ if (this.pool.includes(managed)) {
157
364
  managed.isBusy = false;
365
+ managed.activeJob = undefined;
158
366
  managed.lastUsed = Date.now();
159
- // Check if there are more jobs waiting
160
- this.processQueue();
161
367
  }
368
+ this.processQueue();
162
369
  }
163
- // If no worker is available (all busy), the job stays in the queue
164
- // and will be picked up when a worker finishes.
165
370
  }
166
371
  /**
167
372
  * Terminates all workers in the pool and resets the state.