@heripo/document-processor 0.1.6 → 0.1.8

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.cjs CHANGED
@@ -39,7 +39,6 @@ __export(index_exports, {
39
39
  CaptionValidator: () => CaptionValidator,
40
40
  ChapterConverter: () => ChapterConverter,
41
41
  DocumentProcessor: () => DocumentProcessor,
42
- HanjaQualitySampler: () => HanjaQualitySampler,
43
42
  PAGE_NUMBER_PATTERN: () => PAGE_NUMBER_PATTERN,
44
43
  PagePattern: () => PagePattern,
45
44
  PageRangeParseError: () => PageRangeParseError,
@@ -136,6 +135,15 @@ var BatchProcessor = class {
136
135
  return results.flat();
137
136
  }
138
137
  };
138
+ function detectProvider(model) {
139
+ const providerId = model.provider;
140
+ if (!providerId || typeof providerId !== "string") return "unknown";
141
+ if (providerId.includes("openai")) return "openai";
142
+ if (providerId.includes("google")) return "google";
143
+ if (providerId.includes("anthropic")) return "anthropic";
144
+ if (providerId.includes("together")) return "togetherai";
145
+ return "unknown";
146
+ }
139
147
  var LLMCaller = class {
140
148
  /**
141
149
  * Extract model name from LanguageModel object
@@ -164,10 +172,95 @@ var LLMCaller = class {
164
172
  totalTokens: response.usage?.totalTokens ?? 0
165
173
  };
166
174
  }
175
+ /**
176
+ * Maximum number of retries when structured output generation fails.
177
+ * Total attempts = MAX_STRUCTURED_OUTPUT_RETRIES + 1.
178
+ *
179
+ * Applied to both:
180
+ * - `Output.object()` path: retries on NoObjectGeneratedError (schema mismatch)
181
+ * - Tool call path: retries when model does not produce a tool call
182
+ */
183
+ static MAX_STRUCTURED_OUTPUT_RETRIES = 10;
184
+ /**
185
+ * Generate structured output via forced tool call.
186
+ *
187
+ * Used for providers (Together AI, unknown) that do not reliably support
188
+ * `Output.object()`. Forces the model to call a tool whose inputSchema
189
+ * is the target Zod schema, then extracts the parsed input.
190
+ *
191
+ * Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times when the model does not
192
+ * produce a tool call, for a total of MAX_STRUCTURED_OUTPUT_RETRIES + 1 attempts.
193
+ *
194
+ * @throws NoObjectGeneratedError when all attempts fail to produce a tool call
195
+ */
196
+ static async generateViaToolCall(model, schema, promptParams) {
197
+ const submitTool = (0, import_ai.tool)({
198
+ description: "Submit the structured result",
199
+ inputSchema: schema
200
+ });
201
+ let lastResult;
202
+ for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
203
+ lastResult = await (0, import_ai.generateText)({
204
+ ...promptParams,
205
+ model,
206
+ tools: { submitResult: submitTool },
207
+ toolChoice: { type: "tool", toolName: "submitResult" },
208
+ stopWhen: (0, import_ai.hasToolCall)("submitResult")
209
+ });
210
+ const toolCall = lastResult.toolCalls?.[0];
211
+ if (toolCall) {
212
+ return {
213
+ output: toolCall.input,
214
+ usage: lastResult.usage
215
+ };
216
+ }
217
+ }
218
+ throw new import_ai.NoObjectGeneratedError({
219
+ message: "Model did not produce a tool call for structured output",
220
+ text: lastResult.text ?? "",
221
+ response: lastResult.response,
222
+ usage: lastResult.usage,
223
+ finishReason: lastResult.finishReason
224
+ });
225
+ }
226
+ /**
227
+ * Generate structured output with provider-aware strategy.
228
+ *
229
+ * Strategy per provider:
230
+ * - OpenAI / Anthropic / Google Gemini: `Output.object()` with schema retry
231
+ * - Together AI / unknown: forced tool call pattern
232
+ *
233
+ * Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times on NoObjectGeneratedError
234
+ * (schema mismatch), re-throwing the last error if all attempts fail.
235
+ */
236
+ static async generateStructuredOutput(model, schema, promptParams) {
237
+ const providerType = detectProvider(model);
238
+ if (providerType === "togetherai" || providerType === "unknown") {
239
+ return this.generateViaToolCall(model, schema, promptParams);
240
+ }
241
+ let lastError;
242
+ for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
243
+ try {
244
+ return await (0, import_ai.generateText)({
245
+ model,
246
+ output: import_ai.Output.object({ schema }),
247
+ ...promptParams
248
+ });
249
+ } catch (error) {
250
+ if (import_ai.NoObjectGeneratedError.isInstance(error)) {
251
+ lastError = error;
252
+ continue;
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+ throw lastError;
258
+ }
167
259
  /**
168
260
  * Execute LLM call with fallback support
169
261
  *
170
262
  * Common execution logic for both text and vision calls.
263
+ * Logs additional details when NoObjectGeneratedError occurs.
171
264
  */
172
265
  static async executeWithFallback(config, generateFn) {
173
266
  const primaryModelName = this.extractModelName(config.primaryModel);
@@ -202,6 +295,8 @@ var LLMCaller = class {
202
295
  * 2. If all fail and fallbackModel provided, try fallback up to maxRetries times
203
296
  * 3. Throw error if all attempts exhausted
204
297
  *
298
+ * Provider-aware strategy is automatically applied based on the model's provider field.
299
+ *
205
300
  * @template TOutput - Output type from schema validation
206
301
  * @param config - LLM call configuration
207
302
  * @returns Result with parsed object and usage information
@@ -210,11 +305,7 @@ var LLMCaller = class {
210
305
  static async call(config) {
211
306
  return this.executeWithFallback(
212
307
  config,
213
- (model) => (0, import_ai.generateText)({
214
- model,
215
- output: import_ai.Output.object({
216
- schema: config.schema
217
- }),
308
+ (model) => this.generateStructuredOutput(model, config.schema, {
218
309
  system: config.systemPrompt,
219
310
  prompt: config.userPrompt,
220
311
  temperature: config.temperature,
@@ -227,6 +318,7 @@ var LLMCaller = class {
227
318
  * Call LLM for vision tasks with message format support
228
319
  *
229
320
  * Same retry and fallback logic as call(), but using message format instead of system/user prompts.
321
+ * Provider-aware strategy is automatically applied based on the model's provider field.
230
322
  *
231
323
  * @template TOutput - Output type from schema validation
232
324
  * @param config - LLM vision call configuration
@@ -236,11 +328,7 @@ var LLMCaller = class {
236
328
  static async callVision(config) {
237
329
  return this.executeWithFallback(
238
330
  config,
239
- (model) => (0, import_ai.generateText)({
240
- model,
241
- output: import_ai.Output.object({
242
- schema: config.schema
243
- }),
331
+ (model) => this.generateStructuredOutput(model, config.schema, {
244
332
  messages: config.messages,
245
333
  temperature: config.temperature,
246
334
  maxRetries: config.maxRetries,
@@ -1496,6 +1584,20 @@ var CONTINUATION_MARKERS = [
1496
1584
  "continued"
1497
1585
  ];
1498
1586
  var PAGE_NUMBER_PATTERN = /\.{2,}\s*\d+\s*$|…+\s*\d+\s*$|\s+\d+\s*$/;
1587
+ var RESOURCE_INDEX_PATTERNS = [
1588
+ /^\[도면\s*\d+\]/,
1589
+ /^\[사진\s*\d+\]/,
1590
+ /^\[표\s*\d+\]/,
1591
+ /^\[그림\s*\d+\]/,
1592
+ /^도면\s*\d+[.:)\s]/,
1593
+ /^사진\s*\d+[.:)\s]/,
1594
+ /^표\s*\d+[.:)\s]/,
1595
+ /^Fig\.\s*\d+/i,
1596
+ /^Photo\.?\s*\d+/i,
1597
+ /^Plate\s*\d+/i,
1598
+ /^Map\s*\d+/i,
1599
+ /^圖\s*\d+/
1600
+ ];
1499
1601
  var TocFinder = class {
1500
1602
  constructor(logger, refResolver, options) {
1501
1603
  this.logger = logger;
@@ -1556,6 +1658,10 @@ var TocFinder = class {
1556
1658
  if (result) {
1557
1659
  return this.expandToConsecutivePages(result, doc);
1558
1660
  }
1661
+ const siblingResult = this.findSiblingTocItem(doc, text.self_ref, pageNo);
1662
+ if (siblingResult) {
1663
+ return this.expandToConsecutivePages(siblingResult, doc);
1664
+ }
1559
1665
  }
1560
1666
  return null;
1561
1667
  }
@@ -1587,7 +1693,10 @@ var TocFinder = class {
1587
1693
  continue;
1588
1694
  }
1589
1695
  if (this.isTableTocLike(table)) {
1590
- const score = this.calculateTableScore(table, pageNo);
1696
+ let score = this.calculateTableScore(table, pageNo);
1697
+ if (this.isResourceIndexTable(table)) {
1698
+ score -= 100;
1699
+ }
1591
1700
  candidates.push({
1592
1701
  result: {
1593
1702
  itemRefs: [table.self_ref],
@@ -1631,6 +1740,37 @@ var TocFinder = class {
1631
1740
  }
1632
1741
  return null;
1633
1742
  }
1743
+ /**
1744
+ * Find adjacent TOC item among body siblings following the keyword text.
1745
+ * Used when findTocContainer returns null (e.g., parent is #/body).
1746
+ */
1747
+ findSiblingTocItem(doc, textRef, pageNo) {
1748
+ const idx = doc.body.children.findIndex((c) => c.$ref === textRef);
1749
+ if (idx < 0) return null;
1750
+ for (let i = idx + 1; i < Math.min(idx + 4, doc.body.children.length); i++) {
1751
+ const ref = doc.body.children[i].$ref;
1752
+ const table = this.refResolver.resolveTable(ref);
1753
+ if (table && table.prov[0]?.page_no === pageNo && this.isTableTocLike(table)) {
1754
+ return {
1755
+ itemRefs: [table.self_ref],
1756
+ startPage: pageNo,
1757
+ endPage: pageNo
1758
+ };
1759
+ }
1760
+ const group = this.refResolver.resolveGroup(ref);
1761
+ if (group) {
1762
+ const groupPage = this.getGroupFirstPage(group);
1763
+ if (groupPage === pageNo && this.isGroupTocLike(group, doc)) {
1764
+ return {
1765
+ itemRefs: [group.self_ref],
1766
+ startPage: pageNo,
1767
+ endPage: pageNo
1768
+ };
1769
+ }
1770
+ }
1771
+ }
1772
+ return null;
1773
+ }
1634
1774
  /**
1635
1775
  * Check if a group contains TOC-like structure
1636
1776
  */
@@ -1672,6 +1812,23 @@ var TocFinder = class {
1672
1812
  }
1673
1813
  return numberCount > 0 && numberCount / (num_rows - 1) > 0.5;
1674
1814
  }
1815
+ /**
1816
+ * Check if a table is a resource index (drawing/photo/figure list)
1817
+ * rather than a main TOC table
1818
+ */
1819
+ isResourceIndexTable(table) {
1820
+ const firstColCells = table.data.table_cells.filter(
1821
+ (c) => c.start_col_offset_idx === 0
1822
+ );
1823
+ if (firstColCells.length === 0) return false;
1824
+ let resourceCount = 0;
1825
+ for (const cell of firstColCells) {
1826
+ if (RESOURCE_INDEX_PATTERNS.some((p) => p.test(cell.text.trim()))) {
1827
+ resourceCount++;
1828
+ }
1829
+ }
1830
+ return resourceCount / firstColCells.length > 0.5;
1831
+ }
1675
1832
  /**
1676
1833
  * Expand TOC area to consecutive pages (both backward and forward)
1677
1834
  */
@@ -1744,7 +1901,7 @@ var TocFinder = class {
1744
1901
  if (table.prov[0]?.page_no !== pageNo) {
1745
1902
  continue;
1746
1903
  }
1747
- if (this.isTableTocLike(table) && !refs.includes(table.self_ref)) {
1904
+ if (this.isTableTocLike(table) && !this.isResourceIndexTable(table) && !refs.includes(table.self_ref)) {
1748
1905
  refs.push(table.self_ref);
1749
1906
  }
1750
1907
  }
@@ -1936,13 +2093,22 @@ var VALIDATION_CODE_DESCRIPTIONS = {
1936
2093
  V006: "Duplicate entry detected (same title and page number).",
1937
2094
  V007: "First TOC entry starts too late in the document. Earlier entries may be missing."
1938
2095
  };
1939
- var TocEntrySchema = import_zod.z.lazy(
1940
- () => import_zod.z.object({
2096
+ function buildTocEntrySchema(maxDepth) {
2097
+ const base = {
1941
2098
  title: import_zod.z.string().describe("Chapter or section title"),
1942
2099
  level: import_zod.z.number().int().min(1).describe("Hierarchy depth (1 = top level)"),
1943
- pageNo: import_zod.z.number().int().min(1).describe("Starting page number"),
1944
- children: import_zod.z.array(TocEntrySchema).describe("Child sections (use empty array [] if none)")
1945
- })
2100
+ pageNo: import_zod.z.number().int().min(1).describe("Starting page number")
2101
+ };
2102
+ if (maxDepth <= 0) {
2103
+ return import_zod.z.object(base);
2104
+ }
2105
+ return import_zod.z.object({
2106
+ ...base,
2107
+ children: import_zod.z.array(buildTocEntrySchema(maxDepth - 1)).describe("Child sections (use empty array [] if none)")
2108
+ });
2109
+ }
2110
+ var TocEntrySchema = buildTocEntrySchema(
2111
+ 4
1946
2112
  );
1947
2113
  var TocResponseSchema = import_zod.z.object({
1948
2114
  entries: import_zod.z.array(TocEntrySchema).describe("Extracted TOC entries")
@@ -2129,9 +2295,9 @@ Re-extract the TOC structure from the original markdown above. Fix all validatio
2129
2295
 
2130
2296
  5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following:
2131
2297
  - **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.
2132
- - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, Photo Index, List of Photos, List of Figures)
2133
- - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, Drawing Index, List of Drawings)
2134
- - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
2298
+ - 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)
2299
+ - 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)
2300
+ - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
2135
2301
  - Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
2136
2302
  - Any other supplementary material indices
2137
2303
 
@@ -2452,19 +2618,34 @@ ${continuation.trim()}`;
2452
2618
 
2453
2619
  I am providing ${pageCount} document page images (pages ${startPage}-${endPage}).
2454
2620
 
2455
- ## Where to Look for TOC:
2621
+ ## How to Identify TOC Pages:
2456
2622
  - TOC typically appears in the first 10-20 pages of a document
2457
- - Look for pages with headings like "\uBAA9\uCC28", "\uCC28\uB840", "Contents", "Table of Contents"
2458
- - Look for structured lists with chapter titles and page numbers
2623
+ - Look for pages with a heading that indicates "Table of Contents":
2624
+ - Korean: "\uBAA9\uCC28", "\uCC28\uB840", "\uBAA9 \uCC28"
2625
+ - Hanja (Chinese characters used in Korean documents): "\u76EE\u6B21", "\u76EE \u6B21"
2626
+ - English: "Contents", "Table of Contents"
2627
+ - The heading may appear in decorative or stylized fonts on a textured background
2628
+ - Below the heading, look for structured lists with chapter/section titles and page numbers connected by dots or leaders
2629
+
2630
+ ## How to Distinguish Main TOC from Supplementary Indices:
2631
+ - **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.
2632
+ - **Supplementary indices** (DO NOT EXTRACT): The heading has a prefix qualifier specifying a resource type:
2633
+ - Photo indices: "\uC0AC\uC9C4 \uBAA9\uCC28", "\uC0AC\uC9C4\uBAA9\uCC28", "\u5BEB\u771E \u76EE\u6B21", "\u5BEB\u771E\u76EE\u6B21"
2634
+ - Drawing/figure indices: "\uB3C4\uBA74 \uBAA9\uCC28", "\uB3C4\uBA74\uBAA9\uCC28", "\u5716\u9762 \u76EE\u6B21", "\u5716\u9762\u76EE\u6B21"
2635
+ - Table indices: "\uD45C \uBAA9\uCC28", "\uD45C\uBAA9\uCC28", "\u8868 \u76EE\u6B21", "\u8868\u76EE\u6B21"
2636
+ - 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".
2459
2637
 
2460
2638
  ## What to Extract:
2461
- Extract the TOC content as markdown format that matches this exact structure:
2639
+ Extract the TOC content as markdown format with this exact structure:
2462
2640
  - Use "- " prefix for each list item
2463
2641
  - Use 2-space indentation for hierarchy levels
2464
2642
  - Include "..... " followed by page number at the end of each entry
2465
2643
  - Preserve original chapter/section numbering from the document
2644
+ - Preserve the original language of titles (Korean, Hanja, English, or mixed)
2645
+
2646
+ ## Output Format Examples:
2466
2647
 
2467
- ## Output Format Example:
2648
+ ### Example 1: Standard Korean TOC
2468
2649
  \`\`\`
2469
2650
  - \uC81C1\uC7A5 \uC11C\uB860 ..... 1
2470
2651
  - 1. \uC5F0\uAD6C \uBC30\uACBD ..... 3
@@ -2475,21 +2656,30 @@ Extract the TOC content as markdown format that matches this exact structure:
2475
2656
  - \uC81C3\uC7A5 \uC5F0\uAD6C \uACB0\uACFC ..... 25
2476
2657
  \`\`\`
2477
2658
 
2659
+ ### Example 2: Mixed Hanja-Korean TOC (common in archaeological reports)
2660
+ \`\`\`
2661
+ - \u2160. \u8ABF\u67FB\u6982\u8981 ..... 175
2662
+ - \u2161. \u8ABF\u67FB\u5730\u57DF\uC758 \u74B0\u5883 ..... 177
2663
+ - \u2162. \u8ABF\u67FB\u5185\u5BB9 ..... 199
2664
+ - 1. \uC870\uC0AC\uC9C0\uC5ED \uD1F4\uC801\uC591\uC0C1 ..... 199
2665
+ - 2. \uC720\uC801 \uC870\uC0AC\uB0B4\uC6A9 ..... 200
2666
+ - \u2163. \u8ABF\u67FB\u7D50\u679C ..... 228
2667
+ \`\`\`
2668
+
2478
2669
  ## Important Rules:
2479
- 1. Extract ONLY the main document TOC
2480
- 2. DO NOT include supplementary indices:
2481
- - Photo indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28)
2482
- - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28)
2483
- - Figure indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28)
2484
- 3. If no TOC is found, set hasToc to false and tocMarkdown to null
2485
- 4. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
2670
+ 1. Extract ONLY the main document TOC (the one with unqualified heading "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents")
2671
+ 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.)
2672
+ 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.
2673
+ 4. Some pages in the batch may be photographs, illustrations, blank pages, or other non-text content with no TOC. Skip these pages.
2674
+ 5. If no TOC is found, set hasToc to false and tocMarkdown to null
2675
+ 6. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
2486
2676
 
2487
2677
  Please examine these pages and:
2488
- 1. Determine if any page contains a Table of Contents (TOC)
2678
+ 1. Determine if any page contains a main document Table of Contents
2489
2679
  2. If found, extract the complete TOC in markdown format
2490
2680
  3. Indicate if the TOC continues beyond these pages
2491
2681
 
2492
- Remember: Extract the main document TOC only. Ignore photo/table/figure indices.`;
2682
+ Remember: Extract the main document TOC only. Ignore photo/table/figure/drawing indices.`;
2493
2683
  }
2494
2684
  };
2495
2685
 
@@ -2975,7 +3165,10 @@ var PageRangeParser = class extends VisionLLMComponent {
2975
3165
  const imageContents = [];
2976
3166
  for (const pageNo of pageNos) {
2977
3167
  const page = pages[pageNo - 1];
2978
- const imagePath = path3.resolve(this.outputPath, page.image.uri);
3168
+ const imagePath = path3.resolve(
3169
+ this.outputPath,
3170
+ `pages/page_${pageNo - 1}.png`
3171
+ );
2979
3172
  const imageBuffer = fs3.readFileSync(imagePath);
2980
3173
  const base64Image = imageBuffer.toString("base64");
2981
3174
  const mimeType = page.image.mimetype || "image/png";
@@ -3409,283 +3602,6 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
3409
3602
  }
3410
3603
  };
3411
3604
 
3412
- // src/samplers/hanja-quality-sampler.ts
3413
- var import_zod5 = require("zod");
3414
- var MIN_TEXT_LENGTH = 100;
3415
- var MAX_SAMPLE_PAGES = 10;
3416
- var EDGE_TRIM_RATIO = 0.1;
3417
- var IMAGE_PAGE_TEXT_THRESHOLD = 50;
3418
- var CORRUPTION_THRESHOLD = 0.5;
3419
- var MIN_SEVERE_CORRUPTED_COUNT = 3;
3420
- var HanjaQualityResponseSchema = import_zod5.z.object({
3421
- isCorrupted: import_zod5.z.boolean().describe(
3422
- "Whether the KCJ (Hanja) characters in the OCR text are significantly corrupted compared to the page image"
3423
- ),
3424
- corruptedCharCount: import_zod5.z.number().int().nonnegative().describe(
3425
- "Approximate number of corrupted or incorrectly recognized KCJ characters"
3426
- ),
3427
- totalKcjCharCount: import_zod5.z.number().int().nonnegative().describe("Total number of KCJ characters found in the OCR text"),
3428
- explanation: import_zod5.z.string().describe("Brief explanation of the assessment result")
3429
- });
3430
- var HanjaQualitySampler = class extends VisionLLMComponent {
3431
- constructor(logger, model, outputPath, maxRetries, fallbackModel, aggregator, abortSignal) {
3432
- super(
3433
- logger,
3434
- model,
3435
- "HanjaQualitySampler",
3436
- outputPath,
3437
- { maxRetries, abortSignal },
3438
- fallbackModel,
3439
- aggregator
3440
- );
3441
- }
3442
- /**
3443
- * Assess the quality of KCJ character recognition in the document
3444
- *
3445
- * @param doclingDoc - DoclingDocument to assess
3446
- * @returns Assessment result indicating whether VLM re-parse is needed
3447
- */
3448
- async assess(doclingDoc) {
3449
- const totalPages = Object.keys(doclingDoc.pages).length;
3450
- this.log("info", "Starting KCJ quality assessment...");
3451
- const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3452
- const imageOnlyPages = this.getImageOnlyPages(doclingDoc);
3453
- this.log(
3454
- "info",
3455
- `Total pages: ${totalPages}, eligible range: (${frontCutoff}, ${backCutoff}], image-only pages excluded: ${imageOnlyPages.size}`
3456
- );
3457
- const textPages = this.getTextPages(doclingDoc);
3458
- if (textPages.length === 0) {
3459
- this.log("info", "No text pages found for assessment");
3460
- return {
3461
- needsVlmReparse: false,
3462
- severity: "none",
3463
- kcjPageCount: 0,
3464
- sampledPageCount: 0,
3465
- corruptedRatio: 0,
3466
- reason: "No text pages found for assessment"
3467
- };
3468
- }
3469
- this.log(
3470
- "info",
3471
- `Found ${textPages.length} text pages (min length: ${MIN_TEXT_LENGTH})`
3472
- );
3473
- const sampled = this.selectSamplePages(
3474
- textPages,
3475
- totalPages,
3476
- imageOnlyPages
3477
- );
3478
- this.log(
3479
- "info",
3480
- `Sampling ${sampled.length} pages: [${sampled.map((p) => p.pageNo).join(", ")}]`
3481
- );
3482
- const results = await this.evaluatePages(sampled);
3483
- const assessment = this.aggregateResults(
3484
- textPages.length,
3485
- sampled.length,
3486
- results
3487
- );
3488
- this.log(
3489
- "info",
3490
- `Assessment complete: severity=${assessment.severity}, corrupted=${assessment.corruptedRatio.toFixed(2)}, needsVlmReparse=${assessment.needsVlmReparse}`
3491
- );
3492
- return assessment;
3493
- }
3494
- /**
3495
- * Get the eligible page range after trimming front/back edges.
3496
- * Returns cutoff values where eligible pages satisfy: pageNo > frontCutoff && pageNo <= backCutoff
3497
- */
3498
- getEligiblePageRange(totalPages) {
3499
- const trimCount = Math.ceil(totalPages * EDGE_TRIM_RATIO);
3500
- return {
3501
- frontCutoff: trimCount,
3502
- backCutoff: totalPages - trimCount
3503
- };
3504
- }
3505
- /**
3506
- * Get page numbers that are "image-only" (contain pictures but minimal text).
3507
- * These pages (e.g., photo plates/도판) are unsuitable for KCJ quality assessment.
3508
- */
3509
- getImageOnlyPages(doclingDoc) {
3510
- const picturePages = /* @__PURE__ */ new Set();
3511
- for (const picture of doclingDoc.pictures) {
3512
- const pageNo = picture.prov?.[0]?.page_no;
3513
- if (pageNo != null) {
3514
- picturePages.add(pageNo);
3515
- }
3516
- }
3517
- if (picturePages.size === 0) return /* @__PURE__ */ new Set();
3518
- const pageTextLength = /* @__PURE__ */ new Map();
3519
- for (const text of doclingDoc.texts) {
3520
- const pageNo = this.getPageNo(text);
3521
- if (picturePages.has(pageNo)) {
3522
- pageTextLength.set(
3523
- pageNo,
3524
- (pageTextLength.get(pageNo) ?? 0) + text.text.length
3525
- );
3526
- }
3527
- }
3528
- const imageOnlyPages = /* @__PURE__ */ new Set();
3529
- for (const pageNo of picturePages) {
3530
- const textLength = pageTextLength.get(pageNo) ?? 0;
3531
- if (textLength < IMAGE_PAGE_TEXT_THRESHOLD) {
3532
- imageOnlyPages.add(pageNo);
3533
- }
3534
- }
3535
- return imageOnlyPages;
3536
- }
3537
- /**
3538
- * Get all pages with substantial text content.
3539
- * Pages with fewer characters than MIN_TEXT_LENGTH (e.g., page numbers, headers only) are excluded.
3540
- */
3541
- getTextPages(doclingDoc) {
3542
- const pageMap = /* @__PURE__ */ new Map();
3543
- for (const text of doclingDoc.texts) {
3544
- const pageNo = this.getPageNo(text);
3545
- const entry = pageMap.get(pageNo) ?? { textLength: 0, texts: [] };
3546
- entry.textLength += text.text.length;
3547
- entry.texts.push(text.text);
3548
- pageMap.set(pageNo, entry);
3549
- }
3550
- return Array.from(pageMap.entries()).filter(([, data]) => data.textLength >= MIN_TEXT_LENGTH).map(([pageNo, data]) => ({
3551
- pageNo,
3552
- textLength: data.textLength,
3553
- texts: data.texts
3554
- }));
3555
- }
3556
- /**
3557
- * Select sample pages with highest text density.
3558
- * Prefers pages in the eligible range (not edge-trimmed) and not image-only.
3559
- * Falls back to all text pages if filtering leaves too few candidates.
3560
- */
3561
- selectSamplePages(textPages, totalPages, imageOnlyPages) {
3562
- const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3563
- const filtered = totalPages > 0 ? textPages.filter(
3564
- (p) => p.pageNo > frontCutoff && p.pageNo <= backCutoff && !imageOnlyPages.has(p.pageNo)
3565
- ) : textPages;
3566
- const candidates = filtered.length > 0 ? filtered : textPages;
3567
- if (candidates !== filtered) {
3568
- this.log(
3569
- "warn",
3570
- `All text pages were filtered out by edge/image exclusion. Falling back to all ${textPages.length} text pages.`
3571
- );
3572
- }
3573
- const sorted = [...candidates].sort((a, b) => b.textLength - a.textLength);
3574
- const count = Math.min(MAX_SAMPLE_PAGES, sorted.length);
3575
- return sorted.slice(0, count);
3576
- }
3577
- /**
3578
- * Evaluate sampled pages using Vision LLM
3579
- */
3580
- async evaluatePages(pages) {
3581
- const results = [];
3582
- for (const page of pages) {
3583
- const result = await this.evaluateSinglePage(page);
3584
- results.push(result);
3585
- }
3586
- return results;
3587
- }
3588
- /**
3589
- * Evaluate a single page by comparing OCR text against the page image
3590
- */
3591
- async evaluateSinglePage(page) {
3592
- const imagePath = `pages/page_${page.pageNo - 1}.png`;
3593
- let imageContent;
3594
- try {
3595
- imageContent = this.buildImageContent(imagePath);
3596
- } catch {
3597
- this.log(
3598
- "warn",
3599
- `Failed to load page image for page ${page.pageNo}, marking as not corrupted`
3600
- );
3601
- return {
3602
- isCorrupted: false,
3603
- corruptedCharCount: 0,
3604
- totalKcjCharCount: 0,
3605
- explanation: `Page image not available for page ${page.pageNo}`
3606
- };
3607
- }
3608
- const ocrText = page.texts.join("\n");
3609
- const messages = [
3610
- {
3611
- role: "user",
3612
- content: [
3613
- imageContent,
3614
- {
3615
- type: "text",
3616
- text: this.buildUserPrompt(ocrText)
3617
- }
3618
- ]
3619
- }
3620
- ];
3621
- const { output } = await this.callVisionLLM(
3622
- HanjaQualityResponseSchema,
3623
- messages,
3624
- `page-${page.pageNo}`
3625
- );
3626
- this.log(
3627
- "info",
3628
- `Page ${page.pageNo}: corrupted=${output.isCorrupted}, ${output.corruptedCharCount}/${output.totalKcjCharCount} KCJ chars corrupted`
3629
- );
3630
- return output;
3631
- }
3632
- /**
3633
- * Aggregate individual page results into final assessment
3634
- */
3635
- aggregateResults(totalTextPages, sampledCount, results) {
3636
- const corruptedCount = results.filter((r) => r.isCorrupted).length;
3637
- const corruptedRatio = sampledCount > 0 ? corruptedCount / sampledCount : 0;
3638
- let severity;
3639
- let needsVlmReparse;
3640
- if (corruptedRatio >= CORRUPTION_THRESHOLD || corruptedCount >= MIN_SEVERE_CORRUPTED_COUNT) {
3641
- severity = "severe";
3642
- needsVlmReparse = true;
3643
- } else if (corruptedCount > 0) {
3644
- severity = "minor";
3645
- needsVlmReparse = false;
3646
- } else {
3647
- severity = "none";
3648
- needsVlmReparse = false;
3649
- }
3650
- const reason = corruptedCount === 0 ? "No Hanja character corruption detected" : `${corruptedCount}/${sampledCount} sampled pages have corrupted Hanja characters (ratio: ${corruptedRatio.toFixed(2)})`;
3651
- return {
3652
- needsVlmReparse,
3653
- severity,
3654
- kcjPageCount: totalTextPages,
3655
- sampledPageCount: sampledCount,
3656
- corruptedRatio,
3657
- reason
3658
- };
3659
- }
3660
- /**
3661
- * Get page number from a text item
3662
- */
3663
- getPageNo(text) {
3664
- return text.prov?.[0]?.page_no ?? 1;
3665
- }
3666
- buildSystemPrompt() {
3667
- return "";
3668
- }
3669
- buildUserPrompt(ocrText) {
3670
- return `You are evaluating the quality of OCR text extraction for Hanja (\u6F22\u5B57) characters used in Korean archaeological reports.
3671
-
3672
- 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.
3673
-
3674
- ## Evaluation Criteria
3675
- - Find Hanja (\u6F22\u5B57) characters in the page image first
3676
- - Check if each Hanja character is correctly recognized in the OCR text
3677
- - 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
3678
- - 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
3679
- - Minor font rendering differences are NOT corruption
3680
- - If no Hanja characters are visible in the page image, report as not corrupted with 0 total characters
3681
-
3682
- ## OCR Text to Evaluate
3683
- ${ocrText}
3684
-
3685
- Compare the Hanja characters visible in the page image against the OCR text above and assess whether they were correctly recognized.`;
3686
- }
3687
- };
3688
-
3689
3605
  // src/validators/base-validator.ts
3690
3606
  var BaseValidator = class extends TextLLMComponent {
3691
3607
  /**
@@ -3745,13 +3661,13 @@ var BaseValidator = class extends TextLLMComponent {
3745
3661
  };
3746
3662
 
3747
3663
  // src/validators/toc-content-validator.ts
3748
- var import_zod6 = require("zod");
3749
- var TocContentValidationSchema = import_zod6.z.object({
3750
- isValid: import_zod6.z.boolean().describe("Whether valid main document TOC was found"),
3751
- confidence: import_zod6.z.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3752
- contentType: import_zod6.z.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3753
- extractedTocMarkdown: import_zod6.z.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3754
- reason: import_zod6.z.string().describe("Brief explanation in English")
3664
+ var import_zod5 = require("zod");
3665
+ var TocContentValidationSchema = import_zod5.z.object({
3666
+ isValid: import_zod5.z.boolean().describe("Whether valid main document TOC was found"),
3667
+ confidence: import_zod5.z.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3668
+ contentType: import_zod5.z.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3669
+ extractedTocMarkdown: import_zod5.z.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3670
+ reason: import_zod5.z.string().describe("Brief explanation in English")
3755
3671
  });
3756
3672
  var TocContentValidator = class extends BaseValidator {
3757
3673
  confidenceThreshold;
@@ -3856,9 +3772,9 @@ When classifying as "mixed", you MUST extract ONLY the main TOC portion and retu
3856
3772
 
3857
3773
  ### 3. resource_only
3858
3774
  The content contains ONLY resource indices such as:
3859
- - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, Photo Index, List of Figures, List of Photos)
3860
- - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
3861
- - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, Drawing Index, List of Drawings)
3775
+ - 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)
3776
+ - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
3777
+ - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \u5716\u9762 \u76EE\u6B21, \u5716\u9762\u76EE\u6B21, Drawing Index, List of Drawings)
3862
3778
  - Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
3863
3779
 
3864
3780
  ### 4. invalid
@@ -3902,14 +3818,14 @@ ${markdown}`;
3902
3818
  };
3903
3819
 
3904
3820
  // src/validators/caption-validator.ts
3905
- var import_zod7 = require("zod");
3906
- var CaptionValidationItemSchema = import_zod7.z.object({
3907
- index: import_zod7.z.number().int().describe("Index of the caption in the input array"),
3908
- isValid: import_zod7.z.boolean().describe("Whether the parsed caption is correct"),
3909
- reason: import_zod7.z.string().nullable().describe("Brief explanation if invalid, null if valid")
3821
+ var import_zod6 = require("zod");
3822
+ var CaptionValidationItemSchema = import_zod6.z.object({
3823
+ index: import_zod6.z.number().int().describe("Index of the caption in the input array"),
3824
+ isValid: import_zod6.z.boolean().describe("Whether the parsed caption is correct"),
3825
+ reason: import_zod6.z.string().nullable().describe("Brief explanation if invalid, null if valid")
3910
3826
  });
3911
- var CaptionValidationBatchSchema = import_zod7.z.object({
3912
- results: import_zod7.z.array(CaptionValidationItemSchema)
3827
+ var CaptionValidationBatchSchema = import_zod6.z.object({
3828
+ results: import_zod6.z.array(CaptionValidationItemSchema)
3913
3829
  });
3914
3830
  var CaptionValidator = class extends BaseValidator {
3915
3831
  constructor(logger, model, options, fallbackModel, aggregator) {
@@ -4117,13 +4033,13 @@ var DocumentProcessor = class {
4117
4033
  validatorModel;
4118
4034
  visionTocExtractorModel;
4119
4035
  captionParserModel;
4120
- hanjaQualitySamplerModel;
4121
4036
  textCleanerBatchSize;
4122
4037
  captionParserBatchSize;
4123
4038
  captionValidatorBatchSize;
4124
4039
  maxRetries;
4125
4040
  enableFallbackRetry;
4126
4041
  abortSignal;
4042
+ onTokenUsage;
4127
4043
  idGenerator = new IdGenerator();
4128
4044
  refResolver;
4129
4045
  pageRangeParser;
@@ -4144,13 +4060,22 @@ var DocumentProcessor = class {
4144
4060
  this.validatorModel = options.validatorModel ?? options.fallbackModel;
4145
4061
  this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
4146
4062
  this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
4147
- this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
4148
4063
  this.textCleanerBatchSize = options.textCleanerBatchSize;
4149
4064
  this.captionParserBatchSize = options.captionParserBatchSize;
4150
4065
  this.captionValidatorBatchSize = options.captionValidatorBatchSize;
4151
4066
  this.maxRetries = options.maxRetries ?? 3;
4152
4067
  this.enableFallbackRetry = options.enableFallbackRetry ?? false;
4153
4068
  this.abortSignal = options.abortSignal;
4069
+ this.onTokenUsage = options.onTokenUsage;
4070
+ }
4071
+ /**
4072
+ * Emit current token usage report via callback
4073
+ *
4074
+ * Calls the onTokenUsage callback with the current cumulative report
4075
+ * from the usage aggregator. Safe to call even if no callback is set.
4076
+ */
4077
+ emitTokenUsage() {
4078
+ this.onTokenUsage?.(this.usageAggregator.getReport());
4154
4079
  }
4155
4080
  /**
4156
4081
  * Check if abort has been requested and throw error if so
@@ -4164,36 +4089,6 @@ var DocumentProcessor = class {
4164
4089
  throw error;
4165
4090
  }
4166
4091
  }
4167
- /**
4168
- * Assess the quality of KCJ (Hanja) character recognition in the OCR output.
4169
- *
4170
- * Samples pages containing KCJ characters and uses Vision LLM to compare
4171
- * the OCR text against the original page images. Returns an assessment
4172
- * indicating whether the document should be re-parsed with VLM pipeline.
4173
- *
4174
- * @param doclingDoc - Original document extracted from Docling SDK
4175
- * @param outputPath - Path containing pages subdirectory (pages/page_0.png, etc.)
4176
- * @returns Assessment result with severity and recommendation
4177
- */
4178
- async assessHanjaQuality(doclingDoc, outputPath) {
4179
- this.logger.info(
4180
- "[DocumentProcessor] Starting Hanja quality assessment..."
4181
- );
4182
- const sampler = new HanjaQualitySampler(
4183
- this.logger,
4184
- this.hanjaQualitySamplerModel,
4185
- outputPath,
4186
- this.maxRetries,
4187
- this.enableFallbackRetry ? this.fallbackModel : void 0,
4188
- this.usageAggregator,
4189
- this.abortSignal
4190
- );
4191
- const assessment = await sampler.assess(doclingDoc);
4192
- this.logger.info(
4193
- `[DocumentProcessor] Hanja assessment: severity=${assessment.severity}, needsVlmReparse=${assessment.needsVlmReparse}`
4194
- );
4195
- return assessment;
4196
- }
4197
4092
  /**
4198
4093
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
4199
4094
  *
@@ -4235,11 +4130,13 @@ var DocumentProcessor = class {
4235
4130
  this.logger.info(
4236
4131
  `[DocumentProcessor] Page range parsing took ${pageRangeTime}ms`
4237
4132
  );
4133
+ this.emitTokenUsage();
4238
4134
  this.checkAborted();
4239
4135
  const startTimeToc = Date.now();
4240
4136
  const tocEntries = await this.extractTableOfContents(doclingDoc, filtered);
4241
4137
  const tocTime = Date.now() - startTimeToc;
4242
4138
  this.logger.info(`[DocumentProcessor] TOC extraction took ${tocTime}ms`);
4139
+ this.emitTokenUsage();
4243
4140
  this.checkAborted();
4244
4141
  const startTimeResources = Date.now();
4245
4142
  const { images, tables, footnotes } = await this.convertResources(
@@ -4250,6 +4147,7 @@ var DocumentProcessor = class {
4250
4147
  this.logger.info(
4251
4148
  `[DocumentProcessor] Resource conversion took ${resourcesTime}ms`
4252
4149
  );
4150
+ this.emitTokenUsage();
4253
4151
  this.checkAborted();
4254
4152
  const startTimeChapters = Date.now();
4255
4153
  const chapters = await this.convertChapters(
@@ -4509,7 +4407,9 @@ var DocumentProcessor = class {
4509
4407
  throw error;
4510
4408
  }
4511
4409
  }
4410
+ let fromVision = false;
4512
4411
  if (!markdown) {
4412
+ fromVision = true;
4513
4413
  this.logger.info("[DocumentProcessor] Using vision fallback for TOC");
4514
4414
  const totalPages2 = Object.keys(doclingDoc.pages).length;
4515
4415
  markdown = await this.visionTocExtractor.extract(totalPages2);
@@ -4527,12 +4427,54 @@ var DocumentProcessor = class {
4527
4427
  );
4528
4428
  }
4529
4429
  const totalPages = Object.keys(doclingDoc.pages).length;
4530
- const tocResult = await this.tocExtractor.extract(markdown, {
4531
- totalPages
4532
- });
4430
+ const maxTocPageNo = this.extractMaxPageNumber(markdown);
4431
+ const effectiveTotalPages = maxTocPageNo > totalPages ? void 0 : totalPages;
4432
+ let tocResult;
4433
+ try {
4434
+ tocResult = await this.tocExtractor.extract(markdown, {
4435
+ totalPages: effectiveTotalPages
4436
+ });
4437
+ } catch (error) {
4438
+ if (error instanceof TocValidationError) {
4439
+ this.logger.warn(
4440
+ `[DocumentProcessor] TOC extraction validation failed: ${error.message}`
4441
+ );
4442
+ tocResult = { entries: [], usages: [] };
4443
+ } else {
4444
+ throw error;
4445
+ }
4446
+ }
4533
4447
  for (const usage of tocResult.usages) {
4534
4448
  this.usageAggregator.track(usage);
4535
4449
  }
4450
+ if (tocResult.entries.length === 0 && !fromVision) {
4451
+ this.logger.warn(
4452
+ "[DocumentProcessor] Text-based TOC extraction yielded 0 entries, retrying with vision"
4453
+ );
4454
+ const visionMarkdown = await this.visionTocExtractor.extract(totalPages);
4455
+ if (visionMarkdown) {
4456
+ this.logger.info(
4457
+ `[DocumentProcessor] Vision extracted TOC markdown (${visionMarkdown.length} chars)`
4458
+ );
4459
+ const visionMaxPageNo = this.extractMaxPageNumber(visionMarkdown);
4460
+ const visionEffectivePages = visionMaxPageNo > totalPages ? void 0 : totalPages;
4461
+ try {
4462
+ const visionResult = await this.tocExtractor.extract(
4463
+ visionMarkdown,
4464
+ {
4465
+ totalPages: visionEffectivePages
4466
+ }
4467
+ );
4468
+ for (const usage of visionResult.usages) {
4469
+ this.usageAggregator.track(usage);
4470
+ }
4471
+ if (visionResult.entries.length > 0) {
4472
+ tocResult = visionResult;
4473
+ }
4474
+ } catch {
4475
+ }
4476
+ }
4477
+ }
4536
4478
  if (tocResult.entries.length === 0) {
4537
4479
  const reason = "TOC area was detected but LLM could not extract any structured entries";
4538
4480
  this.logger.error(`[DocumentProcessor] TOC extraction failed: ${reason}`);
@@ -4543,6 +4485,25 @@ var DocumentProcessor = class {
4543
4485
  );
4544
4486
  return tocResult.entries;
4545
4487
  }
4488
+ /**
4489
+ * Extract the maximum page number from TOC markdown
4490
+ *
4491
+ * Parses page numbers from dot-leader patterns (e.g., "..... 175")
4492
+ * and table cell patterns (e.g., "| title | 175 |") to detect compiled
4493
+ * volume scenarios where TOC page numbers exceed the sub-document's page count.
4494
+ */
4495
+ extractMaxPageNumber(markdown) {
4496
+ const dotLeaderMatches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
4497
+ const tableCellMatches = [...markdown.matchAll(/\|\s*(\d+)\s*\|\s*$/gm)];
4498
+ const allNumbers = [
4499
+ ...dotLeaderMatches.map((m) => parseInt(m[1], 10)),
4500
+ ...tableCellMatches.map((m) => parseInt(m[1], 10))
4501
+ ];
4502
+ if (allNumbers.length === 0) {
4503
+ return 0;
4504
+ }
4505
+ return Math.max(...allNumbers);
4506
+ }
4546
4507
  /**
4547
4508
  * Process resource captions (for images and tables)
4548
4509
  *
@@ -4811,7 +4772,6 @@ var DocumentProcessor = class {
4811
4772
  CaptionValidator,
4812
4773
  ChapterConverter,
4813
4774
  DocumentProcessor,
4814
- HanjaQualitySampler,
4815
4775
  PAGE_NUMBER_PATTERN,
4816
4776
  PagePattern,
4817
4777
  PageRangeParseError,