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.
- package/README.md +152 -18
- package/dist/OfficeGenerator.d.ts +1 -1
- package/dist/OfficeGenerator.js +16 -7
- package/dist/OfficeParser.js +6 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +12 -3
- package/dist/defaults.js +27 -1
- package/dist/generators/BaseGenerator.d.ts +3 -3
- package/dist/generators/ChunkingGenerator.js +31 -4
- package/dist/generators/CsvGenerator.d.ts +1 -1
- package/dist/generators/HtmlGenerator.d.ts +2 -1
- package/dist/generators/HtmlGenerator.js +462 -40
- package/dist/generators/MarkdownGenerator.d.ts +1 -1
- package/dist/generators/MarkdownGenerator.js +3 -1
- package/dist/generators/PdfGenerator.d.ts +1 -1
- package/dist/generators/PdfGenerator.js +51 -10
- package/dist/generators/RtfGenerator.d.ts +2 -1
- package/dist/generators/RtfGenerator.js +43 -6
- package/dist/generators/TextGenerator.d.ts +1 -1
- package/dist/officeparser.browser.d.ts +377 -53
- package/dist/officeparser.browser.iife.js +380 -93
- package/dist/officeparser.browser.mjs +380 -93
- package/dist/parsers/CsvParser.js +6 -1
- package/dist/parsers/ExcelParser.js +69 -21
- package/dist/parsers/HtmlParser.js +15 -1
- package/dist/parsers/MarkdownParser.js +18 -10
- package/dist/parsers/OpenOfficeParser.js +61 -34
- package/dist/parsers/PdfParser.js +26 -1
- package/dist/parsers/PowerPointParser.js +168 -40
- package/dist/parsers/RtfParser.js +30 -24
- package/dist/parsers/WordParser.js +158 -11
- package/dist/sbom.cdx.json +100 -100
- package/dist/types.d.ts +383 -53
- package/dist/types.js +4 -0
- package/dist/utils/astUtils.d.ts +2 -2
- package/dist/utils/astUtils.js +2 -1
- package/dist/utils/configUtils.d.ts +5 -0
- package/dist/utils/configUtils.js +69 -2
- package/dist/utils/errorUtils.d.ts +20 -0
- package/dist/utils/errorUtils.js +39 -3
- package/dist/utils/moduleLoader.js +3 -3
- package/dist/utils/ocrUtils.js +271 -66
- package/dist/utils/xmlUtils.d.ts +17 -0
- package/dist/utils/xmlUtils.js +85 -1
- package/package.json +3 -2
package/dist/utils/astUtils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/utils/errorUtils.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
29
|
+
return import(specifier);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
/**
|
package/dist/utils/ocrUtils.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
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(
|
|
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.
|
|
178
|
+
if (this.isProcessing)
|
|
89
179
|
return;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
job.resolve(text);
|
|
325
|
+
await managed.worker.terminate();
|
|
152
326
|
}
|
|
153
|
-
catch (
|
|
154
|
-
|
|
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
|
-
|
|
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.
|