@heripo/document-processor 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // ../shared/dist/index.mjs
2
- import { Output, generateText } from "ai";
2
+ import {
3
+ NoObjectGeneratedError,
4
+ Output,
5
+ generateText,
6
+ hasToolCall,
7
+ tool
8
+ } from "ai";
3
9
  var BatchProcessor = class {
4
10
  /**
5
11
  * Splits an array into batches of specified size.
@@ -73,6 +79,15 @@ var BatchProcessor = class {
73
79
  return results.flat();
74
80
  }
75
81
  };
82
+ function detectProvider(model) {
83
+ const providerId = model.provider;
84
+ if (!providerId || typeof providerId !== "string") return "unknown";
85
+ if (providerId.includes("openai")) return "openai";
86
+ if (providerId.includes("google")) return "google";
87
+ if (providerId.includes("anthropic")) return "anthropic";
88
+ if (providerId.includes("together")) return "togetherai";
89
+ return "unknown";
90
+ }
76
91
  var LLMCaller = class {
77
92
  /**
78
93
  * Extract model name from LanguageModel object
@@ -101,10 +116,95 @@ var LLMCaller = class {
101
116
  totalTokens: response.usage?.totalTokens ?? 0
102
117
  };
103
118
  }
119
+ /**
120
+ * Maximum number of retries when structured output generation fails.
121
+ * Total attempts = MAX_STRUCTURED_OUTPUT_RETRIES + 1.
122
+ *
123
+ * Applied to both:
124
+ * - `Output.object()` path: retries on NoObjectGeneratedError (schema mismatch)
125
+ * - Tool call path: retries when model does not produce a tool call
126
+ */
127
+ static MAX_STRUCTURED_OUTPUT_RETRIES = 10;
128
+ /**
129
+ * Generate structured output via forced tool call.
130
+ *
131
+ * Used for providers (Together AI, unknown) that do not reliably support
132
+ * `Output.object()`. Forces the model to call a tool whose inputSchema
133
+ * is the target Zod schema, then extracts the parsed input.
134
+ *
135
+ * Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times when the model does not
136
+ * produce a tool call, for a total of MAX_STRUCTURED_OUTPUT_RETRIES + 1 attempts.
137
+ *
138
+ * @throws NoObjectGeneratedError when all attempts fail to produce a tool call
139
+ */
140
+ static async generateViaToolCall(model, schema, promptParams) {
141
+ const submitTool = tool({
142
+ description: "Submit the structured result",
143
+ inputSchema: schema
144
+ });
145
+ let lastResult;
146
+ for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
147
+ lastResult = await generateText({
148
+ ...promptParams,
149
+ model,
150
+ tools: { submitResult: submitTool },
151
+ toolChoice: { type: "tool", toolName: "submitResult" },
152
+ stopWhen: hasToolCall("submitResult")
153
+ });
154
+ const toolCall = lastResult.toolCalls?.[0];
155
+ if (toolCall) {
156
+ return {
157
+ output: toolCall.input,
158
+ usage: lastResult.usage
159
+ };
160
+ }
161
+ }
162
+ throw new NoObjectGeneratedError({
163
+ message: "Model did not produce a tool call for structured output",
164
+ text: lastResult.text ?? "",
165
+ response: lastResult.response,
166
+ usage: lastResult.usage,
167
+ finishReason: lastResult.finishReason
168
+ });
169
+ }
170
+ /**
171
+ * Generate structured output with provider-aware strategy.
172
+ *
173
+ * Strategy per provider:
174
+ * - OpenAI / Anthropic / Google Gemini: `Output.object()` with schema retry
175
+ * - Together AI / unknown: forced tool call pattern
176
+ *
177
+ * Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times on NoObjectGeneratedError
178
+ * (schema mismatch), re-throwing the last error if all attempts fail.
179
+ */
180
+ static async generateStructuredOutput(model, schema, promptParams) {
181
+ const providerType = detectProvider(model);
182
+ if (providerType === "togetherai" || providerType === "unknown") {
183
+ return this.generateViaToolCall(model, schema, promptParams);
184
+ }
185
+ let lastError;
186
+ for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
187
+ try {
188
+ return await generateText({
189
+ model,
190
+ output: Output.object({ schema }),
191
+ ...promptParams
192
+ });
193
+ } catch (error) {
194
+ if (NoObjectGeneratedError.isInstance(error)) {
195
+ lastError = error;
196
+ continue;
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+ throw lastError;
202
+ }
104
203
  /**
105
204
  * Execute LLM call with fallback support
106
205
  *
107
206
  * Common execution logic for both text and vision calls.
207
+ * Logs additional details when NoObjectGeneratedError occurs.
108
208
  */
109
209
  static async executeWithFallback(config, generateFn) {
110
210
  const primaryModelName = this.extractModelName(config.primaryModel);
@@ -139,6 +239,8 @@ var LLMCaller = class {
139
239
  * 2. If all fail and fallbackModel provided, try fallback up to maxRetries times
140
240
  * 3. Throw error if all attempts exhausted
141
241
  *
242
+ * Provider-aware strategy is automatically applied based on the model's provider field.
243
+ *
142
244
  * @template TOutput - Output type from schema validation
143
245
  * @param config - LLM call configuration
144
246
  * @returns Result with parsed object and usage information
@@ -147,11 +249,7 @@ var LLMCaller = class {
147
249
  static async call(config) {
148
250
  return this.executeWithFallback(
149
251
  config,
150
- (model) => generateText({
151
- model,
152
- output: Output.object({
153
- schema: config.schema
154
- }),
252
+ (model) => this.generateStructuredOutput(model, config.schema, {
155
253
  system: config.systemPrompt,
156
254
  prompt: config.userPrompt,
157
255
  temperature: config.temperature,
@@ -164,6 +262,7 @@ var LLMCaller = class {
164
262
  * Call LLM for vision tasks with message format support
165
263
  *
166
264
  * Same retry and fallback logic as call(), but using message format instead of system/user prompts.
265
+ * Provider-aware strategy is automatically applied based on the model's provider field.
167
266
  *
168
267
  * @template TOutput - Output type from schema validation
169
268
  * @param config - LLM vision call configuration
@@ -173,11 +272,7 @@ var LLMCaller = class {
173
272
  static async callVision(config) {
174
273
  return this.executeWithFallback(
175
274
  config,
176
- (model) => generateText({
177
- model,
178
- output: Output.object({
179
- schema: config.schema
180
- }),
275
+ (model) => this.generateStructuredOutput(model, config.schema, {
181
276
  messages: config.messages,
182
277
  temperature: config.temperature,
183
278
  maxRetries: config.maxRetries,
@@ -1433,6 +1528,20 @@ var CONTINUATION_MARKERS = [
1433
1528
  "continued"
1434
1529
  ];
1435
1530
  var PAGE_NUMBER_PATTERN = /\.{2,}\s*\d+\s*$|…+\s*\d+\s*$|\s+\d+\s*$/;
1531
+ var RESOURCE_INDEX_PATTERNS = [
1532
+ /^\[도면\s*\d+\]/,
1533
+ /^\[사진\s*\d+\]/,
1534
+ /^\[표\s*\d+\]/,
1535
+ /^\[그림\s*\d+\]/,
1536
+ /^도면\s*\d+[.:)\s]/,
1537
+ /^사진\s*\d+[.:)\s]/,
1538
+ /^표\s*\d+[.:)\s]/,
1539
+ /^Fig\.\s*\d+/i,
1540
+ /^Photo\.?\s*\d+/i,
1541
+ /^Plate\s*\d+/i,
1542
+ /^Map\s*\d+/i,
1543
+ /^圖\s*\d+/
1544
+ ];
1436
1545
  var TocFinder = class {
1437
1546
  constructor(logger, refResolver, options) {
1438
1547
  this.logger = logger;
@@ -1493,6 +1602,10 @@ var TocFinder = class {
1493
1602
  if (result) {
1494
1603
  return this.expandToConsecutivePages(result, doc);
1495
1604
  }
1605
+ const siblingResult = this.findSiblingTocItem(doc, text.self_ref, pageNo);
1606
+ if (siblingResult) {
1607
+ return this.expandToConsecutivePages(siblingResult, doc);
1608
+ }
1496
1609
  }
1497
1610
  return null;
1498
1611
  }
@@ -1524,7 +1637,10 @@ var TocFinder = class {
1524
1637
  continue;
1525
1638
  }
1526
1639
  if (this.isTableTocLike(table)) {
1527
- const score = this.calculateTableScore(table, pageNo);
1640
+ let score = this.calculateTableScore(table, pageNo);
1641
+ if (this.isResourceIndexTable(table)) {
1642
+ score -= 100;
1643
+ }
1528
1644
  candidates.push({
1529
1645
  result: {
1530
1646
  itemRefs: [table.self_ref],
@@ -1568,6 +1684,37 @@ var TocFinder = class {
1568
1684
  }
1569
1685
  return null;
1570
1686
  }
1687
+ /**
1688
+ * Find adjacent TOC item among body siblings following the keyword text.
1689
+ * Used when findTocContainer returns null (e.g., parent is #/body).
1690
+ */
1691
+ findSiblingTocItem(doc, textRef, pageNo) {
1692
+ const idx = doc.body.children.findIndex((c) => c.$ref === textRef);
1693
+ if (idx < 0) return null;
1694
+ for (let i = idx + 1; i < Math.min(idx + 4, doc.body.children.length); i++) {
1695
+ const ref = doc.body.children[i].$ref;
1696
+ const table = this.refResolver.resolveTable(ref);
1697
+ if (table && table.prov[0]?.page_no === pageNo && this.isTableTocLike(table)) {
1698
+ return {
1699
+ itemRefs: [table.self_ref],
1700
+ startPage: pageNo,
1701
+ endPage: pageNo
1702
+ };
1703
+ }
1704
+ const group = this.refResolver.resolveGroup(ref);
1705
+ if (group) {
1706
+ const groupPage = this.getGroupFirstPage(group);
1707
+ if (groupPage === pageNo && this.isGroupTocLike(group, doc)) {
1708
+ return {
1709
+ itemRefs: [group.self_ref],
1710
+ startPage: pageNo,
1711
+ endPage: pageNo
1712
+ };
1713
+ }
1714
+ }
1715
+ }
1716
+ return null;
1717
+ }
1571
1718
  /**
1572
1719
  * Check if a group contains TOC-like structure
1573
1720
  */
@@ -1609,6 +1756,23 @@ var TocFinder = class {
1609
1756
  }
1610
1757
  return numberCount > 0 && numberCount / (num_rows - 1) > 0.5;
1611
1758
  }
1759
+ /**
1760
+ * Check if a table is a resource index (drawing/photo/figure list)
1761
+ * rather than a main TOC table
1762
+ */
1763
+ isResourceIndexTable(table) {
1764
+ const firstColCells = table.data.table_cells.filter(
1765
+ (c) => c.start_col_offset_idx === 0
1766
+ );
1767
+ if (firstColCells.length === 0) return false;
1768
+ let resourceCount = 0;
1769
+ for (const cell of firstColCells) {
1770
+ if (RESOURCE_INDEX_PATTERNS.some((p) => p.test(cell.text.trim()))) {
1771
+ resourceCount++;
1772
+ }
1773
+ }
1774
+ return resourceCount / firstColCells.length > 0.5;
1775
+ }
1612
1776
  /**
1613
1777
  * Expand TOC area to consecutive pages (both backward and forward)
1614
1778
  */
@@ -1681,7 +1845,7 @@ var TocFinder = class {
1681
1845
  if (table.prov[0]?.page_no !== pageNo) {
1682
1846
  continue;
1683
1847
  }
1684
- if (this.isTableTocLike(table) && !refs.includes(table.self_ref)) {
1848
+ if (this.isTableTocLike(table) && !this.isResourceIndexTable(table) && !refs.includes(table.self_ref)) {
1685
1849
  refs.push(table.self_ref);
1686
1850
  }
1687
1851
  }
@@ -1873,13 +2037,22 @@ var VALIDATION_CODE_DESCRIPTIONS = {
1873
2037
  V006: "Duplicate entry detected (same title and page number).",
1874
2038
  V007: "First TOC entry starts too late in the document. Earlier entries may be missing."
1875
2039
  };
1876
- var TocEntrySchema = z.lazy(
1877
- () => z.object({
2040
+ function buildTocEntrySchema(maxDepth) {
2041
+ const base = {
1878
2042
  title: z.string().describe("Chapter or section title"),
1879
2043
  level: z.number().int().min(1).describe("Hierarchy depth (1 = top level)"),
1880
- pageNo: z.number().int().min(1).describe("Starting page number"),
1881
- children: z.array(TocEntrySchema).describe("Child sections (use empty array [] if none)")
1882
- })
2044
+ pageNo: z.number().int().min(1).describe("Starting page number")
2045
+ };
2046
+ if (maxDepth <= 0) {
2047
+ return z.object(base);
2048
+ }
2049
+ return z.object({
2050
+ ...base,
2051
+ children: z.array(buildTocEntrySchema(maxDepth - 1)).describe("Child sections (use empty array [] if none)")
2052
+ });
2053
+ }
2054
+ var TocEntrySchema = buildTocEntrySchema(
2055
+ 4
1883
2056
  );
1884
2057
  var TocResponseSchema = z.object({
1885
2058
  entries: z.array(TocEntrySchema).describe("Extracted TOC entries")
@@ -2066,9 +2239,9 @@ Re-extract the TOC structure from the original markdown above. Fix all validatio
2066
2239
 
2067
2240
  5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following:
2068
2241
  - **Front matter with Roman numeral pages**: Entries whose page numbers are Roman numerals (i, ii, xxi, etc.) such as \uC77C\uB7EC\uB450\uAE30, \uBC1C\uAC04\uC0AC, \uC11C\uBB38, \uBC94\uB840, Preface, Foreword, Editorial Notes. These use a separate page numbering system and are not part of the main content.
2069
- - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, Photo Index, List of Photos, List of Figures)
2070
- - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, Drawing Index, List of Drawings)
2071
- - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
2242
+ - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, \u5BEB\u771E \u76EE\u6B21, \u5BEB\u771E\u76EE\u6B21, Photo Index, List of Photos, List of Figures)
2243
+ - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, \u5716\u9762 \u76EE\u6B21, \u5716\u9762\u76EE\u6B21, Drawing Index, List of Drawings)
2244
+ - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
2072
2245
  - Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
2073
2246
  - Any other supplementary material indices
2074
2247
 
@@ -2389,19 +2562,34 @@ ${continuation.trim()}`;
2389
2562
 
2390
2563
  I am providing ${pageCount} document page images (pages ${startPage}-${endPage}).
2391
2564
 
2392
- ## Where to Look for TOC:
2565
+ ## How to Identify TOC Pages:
2393
2566
  - TOC typically appears in the first 10-20 pages of a document
2394
- - Look for pages with headings like "\uBAA9\uCC28", "\uCC28\uB840", "Contents", "Table of Contents"
2395
- - Look for structured lists with chapter titles and page numbers
2567
+ - Look for pages with a heading that indicates "Table of Contents":
2568
+ - Korean: "\uBAA9\uCC28", "\uCC28\uB840", "\uBAA9 \uCC28"
2569
+ - Hanja (Chinese characters used in Korean documents): "\u76EE\u6B21", "\u76EE \u6B21"
2570
+ - English: "Contents", "Table of Contents"
2571
+ - The heading may appear in decorative or stylized fonts on a textured background
2572
+ - Below the heading, look for structured lists with chapter/section titles and page numbers connected by dots or leaders
2573
+
2574
+ ## How to Distinguish Main TOC from Supplementary Indices:
2575
+ - **Main TOC** (EXTRACT THIS): The heading is simply "\uBAA9\uCC28", "\u76EE\u6B21", "Contents" with NO prefix qualifier. It lists chapters/sections of the document body.
2576
+ - **Supplementary indices** (DO NOT EXTRACT): The heading has a prefix qualifier specifying a resource type:
2577
+ - Photo indices: "\uC0AC\uC9C4 \uBAA9\uCC28", "\uC0AC\uC9C4\uBAA9\uCC28", "\u5BEB\u771E \u76EE\u6B21", "\u5BEB\u771E\u76EE\u6B21"
2578
+ - Drawing/figure indices: "\uB3C4\uBA74 \uBAA9\uCC28", "\uB3C4\uBA74\uBAA9\uCC28", "\u5716\u9762 \u76EE\u6B21", "\u5716\u9762\u76EE\u6B21"
2579
+ - Table indices: "\uD45C \uBAA9\uCC28", "\uD45C\uBAA9\uCC28", "\u8868 \u76EE\u6B21", "\u8868\u76EE\u6B21"
2580
+ - Key rule: If the heading contains a qualifier word BEFORE "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents", it is a supplementary index. Only extract content from pages whose heading is the unqualified "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents".
2396
2581
 
2397
2582
  ## What to Extract:
2398
- Extract the TOC content as markdown format that matches this exact structure:
2583
+ Extract the TOC content as markdown format with this exact structure:
2399
2584
  - Use "- " prefix for each list item
2400
2585
  - Use 2-space indentation for hierarchy levels
2401
2586
  - Include "..... " followed by page number at the end of each entry
2402
2587
  - Preserve original chapter/section numbering from the document
2588
+ - Preserve the original language of titles (Korean, Hanja, English, or mixed)
2403
2589
 
2404
- ## Output Format Example:
2590
+ ## Output Format Examples:
2591
+
2592
+ ### Example 1: Standard Korean TOC
2405
2593
  \`\`\`
2406
2594
  - \uC81C1\uC7A5 \uC11C\uB860 ..... 1
2407
2595
  - 1. \uC5F0\uAD6C \uBC30\uACBD ..... 3
@@ -2412,21 +2600,30 @@ Extract the TOC content as markdown format that matches this exact structure:
2412
2600
  - \uC81C3\uC7A5 \uC5F0\uAD6C \uACB0\uACFC ..... 25
2413
2601
  \`\`\`
2414
2602
 
2603
+ ### Example 2: Mixed Hanja-Korean TOC (common in archaeological reports)
2604
+ \`\`\`
2605
+ - \u2160. \u8ABF\u67FB\u6982\u8981 ..... 175
2606
+ - \u2161. \u8ABF\u67FB\u5730\u57DF\uC758 \u74B0\u5883 ..... 177
2607
+ - \u2162. \u8ABF\u67FB\u5185\u5BB9 ..... 199
2608
+ - 1. \uC870\uC0AC\uC9C0\uC5ED \uD1F4\uC801\uC591\uC0C1 ..... 199
2609
+ - 2. \uC720\uC801 \uC870\uC0AC\uB0B4\uC6A9 ..... 200
2610
+ - \u2163. \u8ABF\u67FB\u7D50\u679C ..... 228
2611
+ \`\`\`
2612
+
2415
2613
  ## Important Rules:
2416
- 1. Extract ONLY the main document TOC
2417
- 2. DO NOT include supplementary indices:
2418
- - Photo indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28)
2419
- - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28)
2420
- - Figure indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28)
2421
- 3. If no TOC is found, set hasToc to false and tocMarkdown to null
2422
- 4. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
2614
+ 1. Extract ONLY the main document TOC (the one with unqualified heading "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents")
2615
+ 2. DO NOT include supplementary indices (those with prefixed headings like "\uC0AC\uC9C4 \uBAA9\uCC28", "\u5BEB\u771E \u76EE\u6B21", "\u5716\u9762 \u76EE\u6B21", "\u8868 \u76EE\u6B21", etc.)
2616
+ 3. Page numbers can be any valid positive integer - documents in compiled volumes may have high page numbers (e.g., 175, 228, 500+). This is normal and expected.
2617
+ 4. Some pages in the batch may be photographs, illustrations, blank pages, or other non-text content with no TOC. Skip these pages.
2618
+ 5. If no TOC is found, set hasToc to false and tocMarkdown to null
2619
+ 6. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
2423
2620
 
2424
2621
  Please examine these pages and:
2425
- 1. Determine if any page contains a Table of Contents (TOC)
2622
+ 1. Determine if any page contains a main document Table of Contents
2426
2623
  2. If found, extract the complete TOC in markdown format
2427
2624
  3. Indicate if the TOC continues beyond these pages
2428
2625
 
2429
- Remember: Extract the main document TOC only. Ignore photo/table/figure indices.`;
2626
+ Remember: Extract the main document TOC only. Ignore photo/table/figure/drawing indices.`;
2430
2627
  }
2431
2628
  };
2432
2629
 
@@ -2912,7 +3109,10 @@ var PageRangeParser = class extends VisionLLMComponent {
2912
3109
  const imageContents = [];
2913
3110
  for (const pageNo of pageNos) {
2914
3111
  const page = pages[pageNo - 1];
2915
- const imagePath = path3.resolve(this.outputPath, page.image.uri);
3112
+ const imagePath = path3.resolve(
3113
+ this.outputPath,
3114
+ `pages/page_${pageNo - 1}.png`
3115
+ );
2916
3116
  const imageBuffer = fs3.readFileSync(imagePath);
2917
3117
  const base64Image = imageBuffer.toString("base64");
2918
3118
  const mimeType = page.image.mimetype || "image/png";
@@ -3346,283 +3546,6 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
3346
3546
  }
3347
3547
  };
3348
3548
 
3349
- // src/samplers/hanja-quality-sampler.ts
3350
- import { z as z5 } from "zod";
3351
- var MIN_TEXT_LENGTH = 100;
3352
- var MAX_SAMPLE_PAGES = 10;
3353
- var EDGE_TRIM_RATIO = 0.1;
3354
- var IMAGE_PAGE_TEXT_THRESHOLD = 50;
3355
- var CORRUPTION_THRESHOLD = 0.5;
3356
- var MIN_SEVERE_CORRUPTED_COUNT = 3;
3357
- var HanjaQualityResponseSchema = z5.object({
3358
- isCorrupted: z5.boolean().describe(
3359
- "Whether the KCJ (Hanja) characters in the OCR text are significantly corrupted compared to the page image"
3360
- ),
3361
- corruptedCharCount: z5.number().int().nonnegative().describe(
3362
- "Approximate number of corrupted or incorrectly recognized KCJ characters"
3363
- ),
3364
- totalKcjCharCount: z5.number().int().nonnegative().describe("Total number of KCJ characters found in the OCR text"),
3365
- explanation: z5.string().describe("Brief explanation of the assessment result")
3366
- });
3367
- var HanjaQualitySampler = class extends VisionLLMComponent {
3368
- constructor(logger, model, outputPath, maxRetries, fallbackModel, aggregator, abortSignal) {
3369
- super(
3370
- logger,
3371
- model,
3372
- "HanjaQualitySampler",
3373
- outputPath,
3374
- { maxRetries, abortSignal },
3375
- fallbackModel,
3376
- aggregator
3377
- );
3378
- }
3379
- /**
3380
- * Assess the quality of KCJ character recognition in the document
3381
- *
3382
- * @param doclingDoc - DoclingDocument to assess
3383
- * @returns Assessment result indicating whether VLM re-parse is needed
3384
- */
3385
- async assess(doclingDoc) {
3386
- const totalPages = Object.keys(doclingDoc.pages).length;
3387
- this.log("info", "Starting KCJ quality assessment...");
3388
- const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3389
- const imageOnlyPages = this.getImageOnlyPages(doclingDoc);
3390
- this.log(
3391
- "info",
3392
- `Total pages: ${totalPages}, eligible range: (${frontCutoff}, ${backCutoff}], image-only pages excluded: ${imageOnlyPages.size}`
3393
- );
3394
- const textPages = this.getTextPages(doclingDoc);
3395
- if (textPages.length === 0) {
3396
- this.log("info", "No text pages found for assessment");
3397
- return {
3398
- needsVlmReparse: false,
3399
- severity: "none",
3400
- kcjPageCount: 0,
3401
- sampledPageCount: 0,
3402
- corruptedRatio: 0,
3403
- reason: "No text pages found for assessment"
3404
- };
3405
- }
3406
- this.log(
3407
- "info",
3408
- `Found ${textPages.length} text pages (min length: ${MIN_TEXT_LENGTH})`
3409
- );
3410
- const sampled = this.selectSamplePages(
3411
- textPages,
3412
- totalPages,
3413
- imageOnlyPages
3414
- );
3415
- this.log(
3416
- "info",
3417
- `Sampling ${sampled.length} pages: [${sampled.map((p) => p.pageNo).join(", ")}]`
3418
- );
3419
- const results = await this.evaluatePages(sampled);
3420
- const assessment = this.aggregateResults(
3421
- textPages.length,
3422
- sampled.length,
3423
- results
3424
- );
3425
- this.log(
3426
- "info",
3427
- `Assessment complete: severity=${assessment.severity}, corrupted=${assessment.corruptedRatio.toFixed(2)}, needsVlmReparse=${assessment.needsVlmReparse}`
3428
- );
3429
- return assessment;
3430
- }
3431
- /**
3432
- * Get the eligible page range after trimming front/back edges.
3433
- * Returns cutoff values where eligible pages satisfy: pageNo > frontCutoff && pageNo <= backCutoff
3434
- */
3435
- getEligiblePageRange(totalPages) {
3436
- const trimCount = Math.ceil(totalPages * EDGE_TRIM_RATIO);
3437
- return {
3438
- frontCutoff: trimCount,
3439
- backCutoff: totalPages - trimCount
3440
- };
3441
- }
3442
- /**
3443
- * Get page numbers that are "image-only" (contain pictures but minimal text).
3444
- * These pages (e.g., photo plates/도판) are unsuitable for KCJ quality assessment.
3445
- */
3446
- getImageOnlyPages(doclingDoc) {
3447
- const picturePages = /* @__PURE__ */ new Set();
3448
- for (const picture of doclingDoc.pictures) {
3449
- const pageNo = picture.prov?.[0]?.page_no;
3450
- if (pageNo != null) {
3451
- picturePages.add(pageNo);
3452
- }
3453
- }
3454
- if (picturePages.size === 0) return /* @__PURE__ */ new Set();
3455
- const pageTextLength = /* @__PURE__ */ new Map();
3456
- for (const text of doclingDoc.texts) {
3457
- const pageNo = this.getPageNo(text);
3458
- if (picturePages.has(pageNo)) {
3459
- pageTextLength.set(
3460
- pageNo,
3461
- (pageTextLength.get(pageNo) ?? 0) + text.text.length
3462
- );
3463
- }
3464
- }
3465
- const imageOnlyPages = /* @__PURE__ */ new Set();
3466
- for (const pageNo of picturePages) {
3467
- const textLength = pageTextLength.get(pageNo) ?? 0;
3468
- if (textLength < IMAGE_PAGE_TEXT_THRESHOLD) {
3469
- imageOnlyPages.add(pageNo);
3470
- }
3471
- }
3472
- return imageOnlyPages;
3473
- }
3474
- /**
3475
- * Get all pages with substantial text content.
3476
- * Pages with fewer characters than MIN_TEXT_LENGTH (e.g., page numbers, headers only) are excluded.
3477
- */
3478
- getTextPages(doclingDoc) {
3479
- const pageMap = /* @__PURE__ */ new Map();
3480
- for (const text of doclingDoc.texts) {
3481
- const pageNo = this.getPageNo(text);
3482
- const entry = pageMap.get(pageNo) ?? { textLength: 0, texts: [] };
3483
- entry.textLength += text.text.length;
3484
- entry.texts.push(text.text);
3485
- pageMap.set(pageNo, entry);
3486
- }
3487
- return Array.from(pageMap.entries()).filter(([, data]) => data.textLength >= MIN_TEXT_LENGTH).map(([pageNo, data]) => ({
3488
- pageNo,
3489
- textLength: data.textLength,
3490
- texts: data.texts
3491
- }));
3492
- }
3493
- /**
3494
- * Select sample pages with highest text density.
3495
- * Prefers pages in the eligible range (not edge-trimmed) and not image-only.
3496
- * Falls back to all text pages if filtering leaves too few candidates.
3497
- */
3498
- selectSamplePages(textPages, totalPages, imageOnlyPages) {
3499
- const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3500
- const filtered = totalPages > 0 ? textPages.filter(
3501
- (p) => p.pageNo > frontCutoff && p.pageNo <= backCutoff && !imageOnlyPages.has(p.pageNo)
3502
- ) : textPages;
3503
- const candidates = filtered.length > 0 ? filtered : textPages;
3504
- if (candidates !== filtered) {
3505
- this.log(
3506
- "warn",
3507
- `All text pages were filtered out by edge/image exclusion. Falling back to all ${textPages.length} text pages.`
3508
- );
3509
- }
3510
- const sorted = [...candidates].sort((a, b) => b.textLength - a.textLength);
3511
- const count = Math.min(MAX_SAMPLE_PAGES, sorted.length);
3512
- return sorted.slice(0, count);
3513
- }
3514
- /**
3515
- * Evaluate sampled pages using Vision LLM
3516
- */
3517
- async evaluatePages(pages) {
3518
- const results = [];
3519
- for (const page of pages) {
3520
- const result = await this.evaluateSinglePage(page);
3521
- results.push(result);
3522
- }
3523
- return results;
3524
- }
3525
- /**
3526
- * Evaluate a single page by comparing OCR text against the page image
3527
- */
3528
- async evaluateSinglePage(page) {
3529
- const imagePath = `pages/page_${page.pageNo - 1}.png`;
3530
- let imageContent;
3531
- try {
3532
- imageContent = this.buildImageContent(imagePath);
3533
- } catch {
3534
- this.log(
3535
- "warn",
3536
- `Failed to load page image for page ${page.pageNo}, marking as not corrupted`
3537
- );
3538
- return {
3539
- isCorrupted: false,
3540
- corruptedCharCount: 0,
3541
- totalKcjCharCount: 0,
3542
- explanation: `Page image not available for page ${page.pageNo}`
3543
- };
3544
- }
3545
- const ocrText = page.texts.join("\n");
3546
- const messages = [
3547
- {
3548
- role: "user",
3549
- content: [
3550
- imageContent,
3551
- {
3552
- type: "text",
3553
- text: this.buildUserPrompt(ocrText)
3554
- }
3555
- ]
3556
- }
3557
- ];
3558
- const { output } = await this.callVisionLLM(
3559
- HanjaQualityResponseSchema,
3560
- messages,
3561
- `page-${page.pageNo}`
3562
- );
3563
- this.log(
3564
- "info",
3565
- `Page ${page.pageNo}: corrupted=${output.isCorrupted}, ${output.corruptedCharCount}/${output.totalKcjCharCount} KCJ chars corrupted`
3566
- );
3567
- return output;
3568
- }
3569
- /**
3570
- * Aggregate individual page results into final assessment
3571
- */
3572
- aggregateResults(totalTextPages, sampledCount, results) {
3573
- const corruptedCount = results.filter((r) => r.isCorrupted).length;
3574
- const corruptedRatio = sampledCount > 0 ? corruptedCount / sampledCount : 0;
3575
- let severity;
3576
- let needsVlmReparse;
3577
- if (corruptedRatio >= CORRUPTION_THRESHOLD || corruptedCount >= MIN_SEVERE_CORRUPTED_COUNT) {
3578
- severity = "severe";
3579
- needsVlmReparse = true;
3580
- } else if (corruptedCount > 0) {
3581
- severity = "minor";
3582
- needsVlmReparse = false;
3583
- } else {
3584
- severity = "none";
3585
- needsVlmReparse = false;
3586
- }
3587
- const reason = corruptedCount === 0 ? "No Hanja character corruption detected" : `${corruptedCount}/${sampledCount} sampled pages have corrupted Hanja characters (ratio: ${corruptedRatio.toFixed(2)})`;
3588
- return {
3589
- needsVlmReparse,
3590
- severity,
3591
- kcjPageCount: totalTextPages,
3592
- sampledPageCount: sampledCount,
3593
- corruptedRatio,
3594
- reason
3595
- };
3596
- }
3597
- /**
3598
- * Get page number from a text item
3599
- */
3600
- getPageNo(text) {
3601
- return text.prov?.[0]?.page_no ?? 1;
3602
- }
3603
- buildSystemPrompt() {
3604
- return "";
3605
- }
3606
- buildUserPrompt(ocrText) {
3607
- return `You are evaluating the quality of OCR text extraction for Hanja (\u6F22\u5B57) characters used in Korean archaeological reports.
3608
-
3609
- Look at the page image and find any Hanja (\u6F22\u5B57) characters visible in the original document. Then compare them against the OCR text below.
3610
-
3611
- ## Evaluation Criteria
3612
- - Find Hanja (\u6F22\u5B57) characters in the page image first
3613
- - Check if each Hanja character is correctly recognized in the OCR text
3614
- - A character is "corrupted" if a Hanja character in the image was replaced by Korean hangul, garbled text, symbols, or a completely different character in the OCR text
3615
- - Common corruption patterns: Hanja replaced by similar-looking hangul (e.g., \u7C98\u571F \u2192 \uADF8\uC73C), wrong character substitution, partial recognition, question marks or boxes
3616
- - Minor font rendering differences are NOT corruption
3617
- - If no Hanja characters are visible in the page image, report as not corrupted with 0 total characters
3618
-
3619
- ## OCR Text to Evaluate
3620
- ${ocrText}
3621
-
3622
- Compare the Hanja characters visible in the page image against the OCR text above and assess whether they were correctly recognized.`;
3623
- }
3624
- };
3625
-
3626
3549
  // src/validators/base-validator.ts
3627
3550
  var BaseValidator = class extends TextLLMComponent {
3628
3551
  /**
@@ -3682,13 +3605,13 @@ var BaseValidator = class extends TextLLMComponent {
3682
3605
  };
3683
3606
 
3684
3607
  // src/validators/toc-content-validator.ts
3685
- import { z as z6 } from "zod";
3686
- var TocContentValidationSchema = z6.object({
3687
- isValid: z6.boolean().describe("Whether valid main document TOC was found"),
3688
- confidence: z6.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3689
- contentType: z6.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3690
- extractedTocMarkdown: z6.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3691
- reason: z6.string().describe("Brief explanation in English")
3608
+ import { z as z5 } from "zod";
3609
+ var TocContentValidationSchema = z5.object({
3610
+ isValid: z5.boolean().describe("Whether valid main document TOC was found"),
3611
+ confidence: z5.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3612
+ contentType: z5.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3613
+ extractedTocMarkdown: z5.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3614
+ reason: z5.string().describe("Brief explanation in English")
3692
3615
  });
3693
3616
  var TocContentValidator = class extends BaseValidator {
3694
3617
  confidenceThreshold;
@@ -3793,9 +3716,9 @@ When classifying as "mixed", you MUST extract ONLY the main TOC portion and retu
3793
3716
 
3794
3717
  ### 3. resource_only
3795
3718
  The content contains ONLY resource indices such as:
3796
- - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, Photo Index, List of Figures, List of Photos)
3797
- - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
3798
- - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, Drawing Index, List of Drawings)
3719
+ - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \u5BEB\u771E \u76EE\u6B21, \u5BEB\u771E\u76EE\u6B21, Photo Index, List of Figures, List of Photos)
3720
+ - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
3721
+ - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \u5716\u9762 \u76EE\u6B21, \u5716\u9762\u76EE\u6B21, Drawing Index, List of Drawings)
3799
3722
  - Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
3800
3723
 
3801
3724
  ### 4. invalid
@@ -3839,14 +3762,14 @@ ${markdown}`;
3839
3762
  };
3840
3763
 
3841
3764
  // src/validators/caption-validator.ts
3842
- import { z as z7 } from "zod";
3843
- var CaptionValidationItemSchema = z7.object({
3844
- index: z7.number().int().describe("Index of the caption in the input array"),
3845
- isValid: z7.boolean().describe("Whether the parsed caption is correct"),
3846
- reason: z7.string().nullable().describe("Brief explanation if invalid, null if valid")
3765
+ import { z as z6 } from "zod";
3766
+ var CaptionValidationItemSchema = z6.object({
3767
+ index: z6.number().int().describe("Index of the caption in the input array"),
3768
+ isValid: z6.boolean().describe("Whether the parsed caption is correct"),
3769
+ reason: z6.string().nullable().describe("Brief explanation if invalid, null if valid")
3847
3770
  });
3848
- var CaptionValidationBatchSchema = z7.object({
3849
- results: z7.array(CaptionValidationItemSchema)
3771
+ var CaptionValidationBatchSchema = z6.object({
3772
+ results: z6.array(CaptionValidationItemSchema)
3850
3773
  });
3851
3774
  var CaptionValidator = class extends BaseValidator {
3852
3775
  constructor(logger, model, options, fallbackModel, aggregator) {
@@ -4054,13 +3977,13 @@ var DocumentProcessor = class {
4054
3977
  validatorModel;
4055
3978
  visionTocExtractorModel;
4056
3979
  captionParserModel;
4057
- hanjaQualitySamplerModel;
4058
3980
  textCleanerBatchSize;
4059
3981
  captionParserBatchSize;
4060
3982
  captionValidatorBatchSize;
4061
3983
  maxRetries;
4062
3984
  enableFallbackRetry;
4063
3985
  abortSignal;
3986
+ onTokenUsage;
4064
3987
  idGenerator = new IdGenerator();
4065
3988
  refResolver;
4066
3989
  pageRangeParser;
@@ -4081,13 +4004,22 @@ var DocumentProcessor = class {
4081
4004
  this.validatorModel = options.validatorModel ?? options.fallbackModel;
4082
4005
  this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
4083
4006
  this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
4084
- this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
4085
4007
  this.textCleanerBatchSize = options.textCleanerBatchSize;
4086
4008
  this.captionParserBatchSize = options.captionParserBatchSize;
4087
4009
  this.captionValidatorBatchSize = options.captionValidatorBatchSize;
4088
4010
  this.maxRetries = options.maxRetries ?? 3;
4089
4011
  this.enableFallbackRetry = options.enableFallbackRetry ?? false;
4090
4012
  this.abortSignal = options.abortSignal;
4013
+ this.onTokenUsage = options.onTokenUsage;
4014
+ }
4015
+ /**
4016
+ * Emit current token usage report via callback
4017
+ *
4018
+ * Calls the onTokenUsage callback with the current cumulative report
4019
+ * from the usage aggregator. Safe to call even if no callback is set.
4020
+ */
4021
+ emitTokenUsage() {
4022
+ this.onTokenUsage?.(this.usageAggregator.getReport());
4091
4023
  }
4092
4024
  /**
4093
4025
  * Check if abort has been requested and throw error if so
@@ -4101,36 +4033,6 @@ var DocumentProcessor = class {
4101
4033
  throw error;
4102
4034
  }
4103
4035
  }
4104
- /**
4105
- * Assess the quality of KCJ (Hanja) character recognition in the OCR output.
4106
- *
4107
- * Samples pages containing KCJ characters and uses Vision LLM to compare
4108
- * the OCR text against the original page images. Returns an assessment
4109
- * indicating whether the document should be re-parsed with VLM pipeline.
4110
- *
4111
- * @param doclingDoc - Original document extracted from Docling SDK
4112
- * @param outputPath - Path containing pages subdirectory (pages/page_0.png, etc.)
4113
- * @returns Assessment result with severity and recommendation
4114
- */
4115
- async assessHanjaQuality(doclingDoc, outputPath) {
4116
- this.logger.info(
4117
- "[DocumentProcessor] Starting Hanja quality assessment..."
4118
- );
4119
- const sampler = new HanjaQualitySampler(
4120
- this.logger,
4121
- this.hanjaQualitySamplerModel,
4122
- outputPath,
4123
- this.maxRetries,
4124
- this.enableFallbackRetry ? this.fallbackModel : void 0,
4125
- this.usageAggregator,
4126
- this.abortSignal
4127
- );
4128
- const assessment = await sampler.assess(doclingDoc);
4129
- this.logger.info(
4130
- `[DocumentProcessor] Hanja assessment: severity=${assessment.severity}, needsVlmReparse=${assessment.needsVlmReparse}`
4131
- );
4132
- return assessment;
4133
- }
4134
4036
  /**
4135
4037
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
4136
4038
  *
@@ -4172,11 +4074,13 @@ var DocumentProcessor = class {
4172
4074
  this.logger.info(
4173
4075
  `[DocumentProcessor] Page range parsing took ${pageRangeTime}ms`
4174
4076
  );
4077
+ this.emitTokenUsage();
4175
4078
  this.checkAborted();
4176
4079
  const startTimeToc = Date.now();
4177
4080
  const tocEntries = await this.extractTableOfContents(doclingDoc, filtered);
4178
4081
  const tocTime = Date.now() - startTimeToc;
4179
4082
  this.logger.info(`[DocumentProcessor] TOC extraction took ${tocTime}ms`);
4083
+ this.emitTokenUsage();
4180
4084
  this.checkAborted();
4181
4085
  const startTimeResources = Date.now();
4182
4086
  const { images, tables, footnotes } = await this.convertResources(
@@ -4187,6 +4091,7 @@ var DocumentProcessor = class {
4187
4091
  this.logger.info(
4188
4092
  `[DocumentProcessor] Resource conversion took ${resourcesTime}ms`
4189
4093
  );
4094
+ this.emitTokenUsage();
4190
4095
  this.checkAborted();
4191
4096
  const startTimeChapters = Date.now();
4192
4097
  const chapters = await this.convertChapters(
@@ -4446,7 +4351,9 @@ var DocumentProcessor = class {
4446
4351
  throw error;
4447
4352
  }
4448
4353
  }
4354
+ let fromVision = false;
4449
4355
  if (!markdown) {
4356
+ fromVision = true;
4450
4357
  this.logger.info("[DocumentProcessor] Using vision fallback for TOC");
4451
4358
  const totalPages2 = Object.keys(doclingDoc.pages).length;
4452
4359
  markdown = await this.visionTocExtractor.extract(totalPages2);
@@ -4466,12 +4373,52 @@ var DocumentProcessor = class {
4466
4373
  const totalPages = Object.keys(doclingDoc.pages).length;
4467
4374
  const maxTocPageNo = this.extractMaxPageNumber(markdown);
4468
4375
  const effectiveTotalPages = maxTocPageNo > totalPages ? void 0 : totalPages;
4469
- const tocResult = await this.tocExtractor.extract(markdown, {
4470
- totalPages: effectiveTotalPages
4471
- });
4376
+ let tocResult;
4377
+ try {
4378
+ tocResult = await this.tocExtractor.extract(markdown, {
4379
+ totalPages: effectiveTotalPages
4380
+ });
4381
+ } catch (error) {
4382
+ if (error instanceof TocValidationError) {
4383
+ this.logger.warn(
4384
+ `[DocumentProcessor] TOC extraction validation failed: ${error.message}`
4385
+ );
4386
+ tocResult = { entries: [], usages: [] };
4387
+ } else {
4388
+ throw error;
4389
+ }
4390
+ }
4472
4391
  for (const usage of tocResult.usages) {
4473
4392
  this.usageAggregator.track(usage);
4474
4393
  }
4394
+ if (tocResult.entries.length === 0 && !fromVision) {
4395
+ this.logger.warn(
4396
+ "[DocumentProcessor] Text-based TOC extraction yielded 0 entries, retrying with vision"
4397
+ );
4398
+ const visionMarkdown = await this.visionTocExtractor.extract(totalPages);
4399
+ if (visionMarkdown) {
4400
+ this.logger.info(
4401
+ `[DocumentProcessor] Vision extracted TOC markdown (${visionMarkdown.length} chars)`
4402
+ );
4403
+ const visionMaxPageNo = this.extractMaxPageNumber(visionMarkdown);
4404
+ const visionEffectivePages = visionMaxPageNo > totalPages ? void 0 : totalPages;
4405
+ try {
4406
+ const visionResult = await this.tocExtractor.extract(
4407
+ visionMarkdown,
4408
+ {
4409
+ totalPages: visionEffectivePages
4410
+ }
4411
+ );
4412
+ for (const usage of visionResult.usages) {
4413
+ this.usageAggregator.track(usage);
4414
+ }
4415
+ if (visionResult.entries.length > 0) {
4416
+ tocResult = visionResult;
4417
+ }
4418
+ } catch {
4419
+ }
4420
+ }
4421
+ }
4475
4422
  if (tocResult.entries.length === 0) {
4476
4423
  const reason = "TOC area was detected but LLM could not extract any structured entries";
4477
4424
  this.logger.error(`[DocumentProcessor] TOC extraction failed: ${reason}`);
@@ -4486,15 +4433,20 @@ var DocumentProcessor = class {
4486
4433
  * Extract the maximum page number from TOC markdown
4487
4434
  *
4488
4435
  * Parses page numbers from dot-leader patterns (e.g., "..... 175")
4489
- * to detect compiled volume scenarios where TOC page numbers exceed
4490
- * the sub-document's page count.
4436
+ * and table cell patterns (e.g., "| title | 175 |") to detect compiled
4437
+ * volume scenarios where TOC page numbers exceed the sub-document's page count.
4491
4438
  */
4492
4439
  extractMaxPageNumber(markdown) {
4493
- const matches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
4494
- if (matches.length === 0) {
4440
+ const dotLeaderMatches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
4441
+ const tableCellMatches = [...markdown.matchAll(/\|\s*(\d+)\s*\|\s*$/gm)];
4442
+ const allNumbers = [
4443
+ ...dotLeaderMatches.map((m) => parseInt(m[1], 10)),
4444
+ ...tableCellMatches.map((m) => parseInt(m[1], 10))
4445
+ ];
4446
+ if (allNumbers.length === 0) {
4495
4447
  return 0;
4496
4448
  }
4497
- return Math.max(...matches.map((m) => parseInt(m[1], 10)));
4449
+ return Math.max(...allNumbers);
4498
4450
  }
4499
4451
  /**
4500
4452
  * Process resource captions (for images and tables)
@@ -4763,7 +4715,6 @@ export {
4763
4715
  CaptionValidator,
4764
4716
  ChapterConverter,
4765
4717
  DocumentProcessor,
4766
- HanjaQualitySampler,
4767
4718
  PAGE_NUMBER_PATTERN,
4768
4719
  PagePattern,
4769
4720
  PageRangeParseError,