officeparser 7.0.2 → 7.1.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.
@@ -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.
@@ -144,3 +144,11 @@ export declare const parseOfficeMetadata: (xmlContent: string) => OfficeMetadata
144
144
  * ```
145
145
  */
146
146
  export declare const parseOOXMLCustomProperties: (xmlContent: string) => Record<string, string | number | boolean | Date>;
147
+ /**
148
+ * Decodes XML entities (standard named entities, decimal, and hexadecimal entities) in a string.
149
+ * Useful when parsing content with regular expressions instead of a full DOM parser.
150
+ *
151
+ * @param text - The XML-encoded string
152
+ * @returns The decoded string
153
+ */
154
+ export declare const decodeXmlEntities: (text: string) => string;
@@ -11,7 +11,7 @@
11
11
  * @module xmlUtils
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.parseOOXMLCustomProperties = exports.parseOfficeMetadata = exports.getDirectChildren = exports.getAttribute = exports.getFirstElementByTagName = exports.getRawContent = exports.getSourceSubstring = exports.serializeXml = exports.getElementsByTagName = exports.parseXmlString = exports.isElement = void 0;
14
+ exports.decodeXmlEntities = exports.parseOOXMLCustomProperties = exports.parseOfficeMetadata = exports.getDirectChildren = exports.getAttribute = exports.getFirstElementByTagName = exports.getRawContent = exports.getSourceSubstring = exports.serializeXml = exports.getElementsByTagName = exports.parseXmlString = exports.isElement = void 0;
15
15
  const xmldom_1 = require("@xmldom/xmldom");
16
16
  const dateUtils_js_1 = require("./dateUtils.js");
17
17
  /**
@@ -375,3 +375,35 @@ const parseOOXMLCustomProperties = (xmlContent) => {
375
375
  return result;
376
376
  };
377
377
  exports.parseOOXMLCustomProperties = parseOOXMLCustomProperties;
378
+ /**
379
+ * Decodes XML entities (standard named entities, decimal, and hexadecimal entities) in a string.
380
+ * Useful when parsing content with regular expressions instead of a full DOM parser.
381
+ *
382
+ * @param text - The XML-encoded string
383
+ * @returns The decoded string
384
+ */
385
+ const decodeXmlEntities = (text) => {
386
+ return text.replace(/&([^;]+);/g, (match, entity) => {
387
+ if (entity.startsWith('#')) {
388
+ if (entity[1] === 'x' || entity[1] === 'X') {
389
+ const hex = entity.slice(2);
390
+ const code = parseInt(hex, 16);
391
+ return !isNaN(code) ? String.fromCodePoint(code) : match;
392
+ }
393
+ else {
394
+ const dec = entity.slice(1);
395
+ const code = parseInt(dec, 10);
396
+ return !isNaN(code) ? String.fromCodePoint(code) : match;
397
+ }
398
+ }
399
+ switch (entity) {
400
+ case 'amp': return '&';
401
+ case 'lt': return '<';
402
+ case 'gt': return '>';
403
+ case 'quot': return '"';
404
+ case 'apos': return "'";
405
+ default: return match;
406
+ }
407
+ });
408
+ };
409
+ exports.decodeXmlEntities = decodeXmlEntities;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "officeparser",
3
- "version": "7.0.2",
3
+ "version": "7.1.0",
4
4
  "description": "A robust, strictly-typed Node.js and Browser library for parsing office files (.docx, .pptx, .xlsx, .odt, .odp, .ods, .pdf, .rtf, .csv, .md, .html) and generating high-fidelity outputs in Markdown, HTML, CSV, RTF, and RAG-focused chunks.",
5
5
  "funding": "https://github.com/sponsors/harshankur",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "build:browser:types": "dts-bundle-generator --no-check -o dist/officeparser.browser.d.ts src/index.ts",
30
30
  "build:browser": "node build_browser.js && npm run sync:docs",
31
31
  "sync:versions": "node scripts/sync-pdfjs-versions.js",
32
- "sync:docs": "mkdir -p docs/dist && cp dist/officeparser.browser.iife.js docs/dist/ && cp dist/officeparser.browser.mjs docs/dist/",
32
+ "sync:docs": "mkdir -p docs/dist && cp dist/officeparser.browser.iife.js docs/dist/ && cp dist/officeparser.browser.mjs docs/dist/ && mkdir -p docs/test/files && cp test/files/* docs/test/files/",
33
33
  "lint": "eslint src",
34
34
  "test": "npm run lint && npm run test:clean && npm run build && npm run test:license && npm run test:artifacts && npm run test:parser && npm run test:generator",
35
35
  "test:baseline": "npm run test:parser:baseline && npm run test:generator:baseline",
@@ -38,6 +38,7 @@
38
38
  "test:generator": "npx tsx test/generator/testOfficeGenerator.ts",
39
39
  "test:generator:baseline": "npx tsx test/generator/testOfficeGenerator.ts baseline",
40
40
  "test:artifacts": "npx tsx test/testShippingArtifacts.ts",
41
+ "test:visualizer": "node test/testVisualizer.js",
41
42
  "test:license": "npm run sbom && node scripts/validate-licenses.js",
42
43
  "test:clean": "rm -rf test/results test/generator/results test/generator/output test/parser/results test/parser/output",
43
44
  "clean": "rm -rf dist && npm run test:clean",