hoi4-agent-tools 4.0.5 → 4.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  7. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  8. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  9. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  10. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  11. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  12. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  13. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  14. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  15. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  16. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  17. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  18. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  19. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  20. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  21. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  22. package/dist/hoi4_agent_tools/map/render.js +9 -1
  23. package/dist/hoi4_agent_tools/map/service.js +222 -18
  24. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  25. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  26. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  27. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  28. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  29. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  30. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  31. package/dist/hoi4_agent_tools/map/world.js +374 -253
  32. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  33. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  34. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  35. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  36. package/dist/hoi4_agent_tools/version.js +2 -2
  37. package/docs/images/reference-maps/political.png +0 -0
  38. package/docs/images/reference-maps/sketch.png +0 -0
  39. package/docs/map.md +45 -0
  40. package/docs/third-party-notices.md +6 -0
  41. package/npm-shrinkwrap.json +124 -2
  42. package/package.json +4 -1
  43. package/schemas/configuration.schema.json +1 -1
  44. package/schemas/continuous-focus-palette.schema.json +1 -1
  45. package/schemas/custom-weighted-pool.schema.json +1 -1
  46. package/schemas/decision-inspect-input.schema.json +1 -1
  47. package/schemas/error-log-input.schema.json +1 -1
  48. package/schemas/event-feature-manifest.schema.json +1 -1
  49. package/schemas/focus-plan.schema.json +1 -1
  50. package/schemas/focus-planning-sidecar.schema.json +1 -1
  51. package/schemas/gui-animation-source.schema.json +1 -1
  52. package/schemas/gui-helper.schema.json +1 -1
  53. package/schemas/gui-scenario.schema.json +1 -1
  54. package/schemas/helper-expansion-request.schema.json +1 -1
  55. package/schemas/helper-expansion-summary.schema.json +1 -1
  56. package/schemas/impact-inspect-input.schema.json +1 -1
  57. package/schemas/map-operation.schema.json +373 -70
  58. package/schemas/mechanic-test-input.schema.json +1 -1
  59. package/schemas/mod-index-input.schema.json +1 -1
  60. package/schemas/operation-result.schema.json +1 -1
  61. package/schemas/package-check-input.schema.json +1 -1
  62. package/schemas/package-manifest.schema.json +1 -1
  63. package/schemas/probability-analysis-result.schema.json +1 -1
  64. package/schemas/probability-compare-input.schema.json +1 -1
  65. package/schemas/probability-evaluate-input.schema.json +1 -1
  66. package/schemas/probability-inspect-input.schema.json +1 -1
  67. package/schemas/probability-render-input.schema.json +1 -1
  68. package/schemas/probability-scenario-set.schema.json +1 -1
  69. package/schemas/probability-sequence-input.schema.json +1 -1
  70. package/schemas/probability-simulate-input.schema.json +1 -1
  71. package/schemas/probability-sweep-input.schema.json +1 -1
  72. package/schemas/reference-context-input.schema.json +1 -1
  73. package/schemas/reference-read-input.schema.json +1 -1
  74. package/schemas/reference-search-input.schema.json +1 -1
  75. package/schemas/scenario-suite.schema.json +1 -1
  76. package/schemas/scenario-test-input.schema.json +1 -1
  77. package/schemas/script-validate-input.schema.json +1 -1
  78. package/schemas/shared-scenario.schema.json +1 -1
  79. package/schemas/source-lookup-input.schema.json +1 -1
  80. package/server.json +2 -2
@@ -136,9 +136,9 @@ function coastPoint(raster, shape, isSea) {
136
136
  [0, 1],
137
137
  [0, -1],
138
138
  ]) {
139
- const nx = x + dx;
139
+ const nx = (x + dx + raster.width) % raster.width;
140
140
  const ny = y + dy;
141
- if (nx < 0 || ny < 0 || nx >= raster.width || ny >= raster.height)
141
+ if (ny < 0 || ny >= raster.height)
142
142
  continue;
143
143
  const other = raster.provinceIds[ny * raster.width + nx];
144
144
  if (other < 0 || !isSea(other))
@@ -154,6 +154,19 @@ function coastPoint(raster, shape, isSea) {
154
154
  const middle = points[Math.floor(points.length / 2)];
155
155
  return { ...middle, sea };
156
156
  }
157
+ /** A sea pixel next to the landing province, staying inside narrow inlets and map edges. */
158
+ function floatingHarborPoint(raster, coast) {
159
+ for (const distance of [2, 1]) {
160
+ const x = (coast.x + coast.dx * distance + raster.width) % raster.width;
161
+ const y = coast.y + coast.dy * distance;
162
+ if (y < 0 || y >= raster.height)
163
+ continue;
164
+ // Keep the same sea association selected from the real coastal contact.
165
+ if (raster.provinceIds[y * raster.width + x] === coast.sea)
166
+ return { x, y };
167
+ }
168
+ throw new ServiceError('MAP_FLOATING_HARBOR_WATER_UNAVAILABLE', 'The selected coastal contact has no adjacent sea pixel for a floating harbor');
169
+ }
157
170
  function rotationFor(dx, dy) {
158
171
  // Bitmap y grows downward; map z grows upward.
159
172
  return Math.atan2(-dy, dx);
@@ -315,9 +328,8 @@ function planBuildingPositions(index, request) {
315
328
  continue;
316
329
  for (let copy = 0; copy < perOwner && produced < wanted - have; copy += 1, produced += 1) {
317
330
  const floating = slot.name === 'floating_harbor';
318
- // A floating harbour sits on the water two pixels out and names its land province.
319
- const x = floating ? coast.x + coast.dx * 2 : coast.x;
320
- const y = floating ? coast.y + coast.dy * 2 : coast.y;
331
+ // Prefer two pixels offshore, with a verified adjacent-water fallback.
332
+ const { x, y } = floating ? floatingHarborPoint(raster, coast) : coast;
321
333
  added.push([
322
334
  state.id,
323
335
  slot.name,
@@ -350,7 +362,10 @@ function planBuildingPositions(index, request) {
350
362
  }
351
363
  return {
352
364
  relativePath: document?.file.relativePath ?? `${mapRoot(index)}/buildings.txt`,
353
- content: rewriteLines(document, drop, added),
365
+ // The native building-position reader treats a trailing newline as an empty CSV record.
366
+ content: Buffer.from(rewriteLines(document, drop, added)
367
+ .toString('utf8')
368
+ .replace(/\r?\n$/u, ''), 'utf8'),
354
369
  };
355
370
  }
356
371
  function planUnitPositions(index, request) {
@@ -4,7 +4,7 @@ import { errorResult, toolResult } from '../core/operation-result.js';
4
4
  import { ServiceError } from '../core/result.js';
5
5
  import { MAP_VIEW_FIELDS, mapInspectRequestSchema, mapRewriteRequestSchema, } from '../schemas/map-requests.js';
6
6
  import { AgentNudger } from './service.js';
7
- import { inspectMap, completeMapRewrite, normalizeMapInspectRequest, normalizeMapRenderRequest, normalizeMapRewriteRequest, prepareMapRewrite, renderMapView, } from './tool-operations.js';
7
+ import { inspectMap, completeMapRewrite, normalizeMapInspectRequest, normalizeMapRenderRequest, normalizeMapRewriteRequest, prepareMapRewrite, previewMapRewrite, renderMapView, } from './tool-operations.js';
8
8
  const wireResult = z.record(z.string(), z.json());
9
9
  /** Registers read-only map operations without invoking transport handlers. */
10
10
  export function registerMapJobs(operations, engine) {
@@ -54,12 +54,18 @@ export function registerMapJobs(operations, engine) {
54
54
  throw new ServiceError('JOB_ARGUMENT_SCOPE_MISMATCH', 'The request workspace does not match the authorized job workspace');
55
55
  const release = lifetime.begin();
56
56
  try {
57
- return await prepareMapRewrite(nudger, normalizeMapRewriteRequest(input), {
57
+ const operationContext = {
58
58
  workspaceId: context.workspaceId,
59
59
  ...(context.principal === undefined ? {} : { principal: context.principal }),
60
60
  signal: context.signal,
61
61
  progress: (completed, total, message) => context.progress({ completed, total, message }),
62
- });
62
+ };
63
+ const normalized = normalizeMapRewriteRequest(input);
64
+ if (normalized.preview)
65
+ return {
66
+ readOnlyPreview: wireResult.parse(JSON.parse(JSON.stringify(toolResult(await previewMapRewrite(nudger, normalized, operationContext))))),
67
+ };
68
+ return await prepareMapRewrite(nudger, normalized, operationContext);
63
69
  }
64
70
  finally {
65
71
  release();
@@ -9,6 +9,8 @@ import { derivedStateCapital, encodeTextDocument, LEVEL_STATE_BUILDINGS, MapWork
9
9
  import { planReferenceRemap, ReferenceRemapError } from './references.js';
10
10
  import { generateWorld } from './world.js';
11
11
  import { planMapPositions, planSupplyRebuild } from './derived.js';
12
+ import { buildWorldContext } from './world-context.js';
13
+ import { reconcileWorldOutputs } from './world-outputs.js';
12
14
  function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
13
15
  if (iteration % stride !== 0)
14
16
  return;
@@ -3250,6 +3252,14 @@ export const WORLD_REPLACED_ROOTS = [
3250
3252
  'map/supplyareas',
3251
3253
  'common/bookmarks',
3252
3254
  ];
3255
+ const STARTING_SCENARIO_REPLACED_ROOTS = [
3256
+ 'common/on_actions',
3257
+ 'common/ai_areas',
3258
+ 'common/ai_strategy_plans',
3259
+ 'common/ai_strategy',
3260
+ 'common/decisions',
3261
+ 'events',
3262
+ ];
3253
3263
  /** Add replace_path lines to descriptor.mod, creating a minimal descriptor when none exists. */
3254
3264
  function descriptorWithReplacePaths(index, roots) {
3255
3265
  const existing = index.sourceFiles.find((file) => file.rootKind === 'mod' && normalizeRelative(file.relativePath) === 'descriptor.mod');
@@ -3262,36 +3272,56 @@ function descriptorWithReplacePaths(index, roots) {
3262
3272
  const body = textValue.endsWith('\n') ? textValue : `${textValue}${newline}`;
3263
3273
  return Buffer.from(`${body}${missing.map((root) => `replace_path="${root}"`).join(newline)}${missing.length > 0 ? newline : ''}`, 'utf8');
3264
3274
  }
3265
- function applyCreateWorld(base, changes, operation) {
3275
+ function applyCreateWorld(base, changes, operation, context) {
3276
+ const reference = context.referenceWorlds?.get(operation.id);
3277
+ if (operation.reference !== undefined && reference === undefined)
3278
+ throw new ServiceError('MAP_REFERENCE_NOT_PREPARED', 'Reference worlds must be prepared by the authorized workspace service');
3266
3279
  const mapRoot = base.sourceRoots.map[0] ?? 'map';
3280
+ const startingScenario = operation.startingScenario ?? reference !== undefined;
3267
3281
  const world = generateWorld({
3268
- width: operation.width ?? 1_024,
3269
- height: operation.height ?? 512,
3282
+ startingScenario,
3283
+ ...(operation.worldName === undefined ? {} : { worldName: operation.worldName }),
3284
+ width: operation.width ?? reference?.surface.width ?? 1_024,
3285
+ height: operation.height ?? reference?.surface.height ?? 512,
3270
3286
  seed: operation.seed ?? 1,
3271
3287
  landCoverage: operation.landCoverage ?? 0.35,
3272
3288
  continents: operation.continents ?? 3,
3273
3289
  ...(operation.landPolygons === undefined ? {} : { landPolygons: operation.landPolygons }),
3274
3290
  landProvincePixels: operation.landProvincePixels ?? 400,
3275
3291
  seaProvincePixels: operation.seaProvincePixels ?? 2_500,
3276
- provincesPerState: operation.provincesPerState ?? 5,
3277
- landProvincesPerRegion: operation.landProvincesPerRegion ?? 25,
3278
- seaProvincesPerRegion: operation.seaProvincesPerRegion ?? 10,
3279
- countries: operation.countries,
3292
+ provincesPerState: operation.provincesPerState ?? (reference === undefined ? 5 : 12),
3293
+ landProvincesPerRegion: operation.landProvincesPerRegion ?? (reference === undefined ? 25 : 60),
3294
+ seaProvincesPerRegion: operation.seaProvincesPerRegion ?? (reference === undefined ? 10 : 30),
3295
+ countries: operation.countries ??
3296
+ reference?.countries ?? [
3297
+ { tag: 'X01', name: 'Aster' },
3298
+ { tag: 'X02', name: 'Brum' },
3299
+ { tag: 'X03', name: 'Corvel' },
3300
+ { tag: 'X04', name: 'Doran' },
3301
+ ],
3302
+ ...(reference === undefined
3303
+ ? {}
3304
+ : {
3305
+ referenceSurface: reference.surface,
3306
+ referenceCities: reference.cities,
3307
+ referenceLabels: reference.labels,
3308
+ }),
3280
3309
  ...(operation.continentNames === undefined
3281
3310
  ? {}
3282
3311
  : { continentNames: operation.continentNames }),
3283
3312
  startDate: operation.startDate ?? '1936.1.1',
3284
- rivers: operation.rivers ?? 8,
3285
- }, {
3286
- palettes: {},
3287
- mapRoot,
3288
- statesRoot: base.sourceRoots.states[0] ?? 'history/states',
3289
- localisationRoot: base.sourceRoots.localisation[0] ?? 'localisation',
3290
- });
3291
- const roots = operation.replaceBaseGame === false ? [] : [...WORLD_REPLACED_ROOTS];
3313
+ rivers: operation.rivers ?? (reference === undefined ? 8 : 80),
3314
+ }, buildWorldContext(base));
3315
+ const roots = operation.replaceBaseGame === false
3316
+ ? []
3317
+ : [...WORLD_REPLACED_ROOTS, ...(startingScenario ? STARTING_SCENARIO_REPLACED_ROOTS : [])];
3292
3318
  replacedRootsByPlan.set(changes, roots);
3293
3319
  for (const file of world.files)
3294
3320
  addChange(changes, file.relativePath, file.content, operation.id, file.mediaType);
3321
+ // Remove only unchanged outputs recorded by a previous generation, before indexing new IDs.
3322
+ for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
3323
+ if (file.content === null)
3324
+ addChange(changes, file.relativePath, null, operation.id, 'text/plain');
3295
3325
  // Start from empty derived files so no base-game position or supply line survives.
3296
3326
  for (const name of [
3297
3327
  'buildings.txt',
@@ -3309,7 +3339,28 @@ function applyCreateWorld(base, changes, operation) {
3309
3339
  const positioned = currentIndex(base, changes);
3310
3340
  for (const file of planSupplyRebuild(positioned, { mode: 'replace' }))
3311
3341
  addChange(changes, file.relativePath, file.content, operation.id, 'text/plain');
3342
+ for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
3343
+ addChange(changes, file.relativePath, file.content, operation.id, file.mediaType ?? 'text/plain');
3312
3344
  return [
3345
+ ...(reference?.diagnostics ?? []).map((message) => ({
3346
+ code: 'MAP_REFERENCE_INTERPRETATION',
3347
+ severity: 'warning',
3348
+ category: 'map',
3349
+ operationId: operation.id,
3350
+ message,
3351
+ })),
3352
+ ...(world.warnings.length === 0
3353
+ ? []
3354
+ : [
3355
+ {
3356
+ code: 'MAP_WORLD_GENERATION_REVIEW',
3357
+ severity: 'warning',
3358
+ category: 'map',
3359
+ operationId: operation.id,
3360
+ message: `Drainage generation reported ${world.warnings.length} review notes`,
3361
+ details: { warningCount: world.warnings.length, samples: world.warnings.slice(0, 5) },
3362
+ },
3363
+ ]),
3313
3364
  {
3314
3365
  code: 'MAP_WORLD_CREATED',
3315
3366
  severity: 'info',
@@ -3405,7 +3456,7 @@ function applyEntityLocator(base, changes, operation) {
3405
3456
  ]);
3406
3457
  addChange(changes, locator.file.relativePath, output, operation.id, 'text/plain');
3407
3458
  }
3408
- function* planMapOperationSteps(base, operations, signal) {
3459
+ function* planMapOperationSteps(base, operations, signal, context = {}) {
3409
3460
  yield* cancellationCheckpoint(signal);
3410
3461
  const changes = new Map();
3411
3462
  const diagnostics = [];
@@ -3443,7 +3494,7 @@ function* planMapOperationSteps(base, operations, signal) {
3443
3494
  const boundsCheckpoint = expectedChangedBounds;
3444
3495
  try {
3445
3496
  if (operation.kind === 'create_world')
3446
- diagnostics.push(...applyCreateWorld(base, changes, operation));
3497
+ diagnostics.push(...applyCreateWorld(base, changes, operation, context));
3447
3498
  else if (operation.kind === 'move_state_provinces')
3448
3499
  applyMoveStateProvinces(base, changes, operation);
3449
3500
  else if (operation.kind === 'update_state')
@@ -3522,12 +3573,12 @@ function* planMapOperationSteps(base, operations, signal) {
3522
3573
  operations: manifestOperations,
3523
3574
  };
3524
3575
  }
3525
- export function planMapOperations(base, operations, signal) {
3526
- return completeSynchronously(planMapOperationSteps(base, operations, signal));
3576
+ export function planMapOperations(base, operations, signal, context = {}) {
3577
+ return completeSynchronously(planMapOperationSteps(base, operations, signal, context));
3527
3578
  }
3528
3579
  /** Plans map mutations while periodically yielding so protocol cancellation can be delivered. */
3529
- export async function planMapOperationsAsync(base, operations, signal) {
3530
- return completeCooperatively(planMapOperationSteps(base, operations, signal), signal);
3580
+ export async function planMapOperationsAsync(base, operations, signal, context = {}) {
3581
+ return completeCooperatively(planMapOperationSteps(base, operations, signal, context), signal);
3531
3582
  }
3532
3583
  export function mapOperationId(operation) {
3533
3584
  return deterministicId('map_operation', operation);
@@ -0,0 +1,238 @@
1
+ /** Offline English OCR for reference artwork. No image paths, URLs, commands or model paths
2
+ * are accepted from callers. The pinned Tesseract worker protocol is used directly because
3
+ * its public createWorker promise hides the worker handle until initialization completes;
4
+ * owning the thread from creation lets us terminate stalled initialization as well as OCR.
5
+ * The synthetic integration test must pass before changing the pinned OCR dependencies.
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import { createRequire } from 'node:module';
9
+ import { Worker } from 'node:worker_threads';
10
+ import sharp from 'sharp';
11
+ import { z } from 'zod';
12
+ import { compareCodeUnits } from '../core/canonical.js';
13
+ import { ServiceError } from '../core/result.js';
14
+ const MAX_BYTES = 32 * 1024 * 1024;
15
+ const MAX_PIXELS = 40_000_000;
16
+ const MAX_OCR_SIDE = 2560;
17
+ const MAX_LABELS = 512;
18
+ const MAX_TSV_BYTES = 2 * 1024 * 1024;
19
+ const OCR_TIMEOUT_MS = 30_000;
20
+ const require = createRequire(import.meta.url);
21
+ let active = false;
22
+ function hasRasterSignature(image) {
23
+ return (image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
24
+ image.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
25
+ ['GIF87a', 'GIF89a'].includes(image.subarray(0, 6).toString('ascii')) ||
26
+ (image.subarray(0, 4).toString('ascii') === 'RIFF' &&
27
+ image.subarray(8, 12).toString('ascii') === 'WEBP') ||
28
+ image.subarray(0, 4).equals(Buffer.from([73, 73, 42, 0])) ||
29
+ image.subarray(0, 4).equals(Buffer.from([77, 77, 0, 42])));
30
+ }
31
+ const packetSchema = z.object({
32
+ jobId: z.string(),
33
+ status: z.enum(['resolve', 'reject', 'progress']),
34
+ data: z.unknown(),
35
+ });
36
+ /** Validate and normalize the engine's TSV instead of trusting its untyped nested output. */
37
+ export function parseReferenceLabelTsv(tsv, width, height) {
38
+ if (Buffer.byteLength(tsv) > MAX_TSV_BYTES ||
39
+ !Number.isSafeInteger(width) ||
40
+ !Number.isSafeInteger(height) ||
41
+ width <= 0 ||
42
+ height <= 0) {
43
+ return { labels: [], warnings: ['Reference text output exceeded safe bounds.'] };
44
+ }
45
+ const lines = new Map();
46
+ const uncertainLines = new Set();
47
+ let rejected = 0;
48
+ for (const row of tsv.split(/\r?\n/).slice(1)) {
49
+ if (!row.trim())
50
+ continue;
51
+ const fields = row.split('\t');
52
+ if (fields[0] !== '5')
53
+ continue;
54
+ const key = fields.slice(1, 5).join(':');
55
+ const text = fields.slice(11).join(' ').normalize('NFC').trim();
56
+ const [left, top, wordWidth, wordHeight, confidence] = fields.slice(6, 11).map(Number);
57
+ if (fields.length < 12 ||
58
+ !text ||
59
+ left === undefined ||
60
+ top === undefined ||
61
+ wordWidth === undefined ||
62
+ wordHeight === undefined ||
63
+ confidence === undefined ||
64
+ ![left, top, wordWidth, wordHeight, confidence].every(Number.isFinite) ||
65
+ left < 0 ||
66
+ top < 0 ||
67
+ wordWidth <= 0 ||
68
+ wordHeight <= 0 ||
69
+ left + wordWidth > width ||
70
+ top + wordHeight > height ||
71
+ confidence < 70 ||
72
+ confidence > 100 ||
73
+ !/^[\p{L}\p{M}\d][\p{L}\p{M}\d.,'’()-]*$/u.test(text) ||
74
+ text.length > 80) {
75
+ rejected++;
76
+ uncertainLines.add(key);
77
+ continue;
78
+ }
79
+ const group = lines.get(key) ?? [];
80
+ group.push({ text, confidence, left, top, width: wordWidth, height: wordHeight });
81
+ lines.set(key, group);
82
+ }
83
+ const labels = [];
84
+ for (const [key, words] of lines) {
85
+ // A readable half of an uncertain multiword name is not a reliable place name.
86
+ if (uncertainLines.has(key))
87
+ continue;
88
+ words.sort((a, b) => a.left - b.left || a.top - b.top);
89
+ const groups = [];
90
+ for (const word of words) {
91
+ const group = groups.at(-1);
92
+ const previous = group?.at(-1);
93
+ if (!previous ||
94
+ word.left - (previous.left + previous.width) > 2.5 * Math.max(previous.height, word.height)) {
95
+ groups.push([word]);
96
+ }
97
+ else
98
+ group.push(word);
99
+ }
100
+ for (const group of groups) {
101
+ const text = group.map((word) => word.text).join(' ');
102
+ const confidence = group.reduce((sum, word) => sum + word.confidence, 0) / group.length;
103
+ if (confidence < 80 || text.length < 2 || text.length > 80 || !/[\p{L}]/u.test(text)) {
104
+ rejected++;
105
+ continue;
106
+ }
107
+ const left = Math.min(...group.map((word) => word.left));
108
+ const top = Math.min(...group.map((word) => word.top));
109
+ const right = Math.max(...group.map((word) => word.left + word.width));
110
+ const bottom = Math.max(...group.map((word) => word.top + word.height));
111
+ labels.push({
112
+ text,
113
+ confidence: Math.round(confidence * 100) / 100,
114
+ x: (left + right) / (2 * width),
115
+ y: (top + bottom) / (2 * height),
116
+ width: (right - left) / width,
117
+ height: (bottom - top) / height,
118
+ });
119
+ }
120
+ }
121
+ labels.sort((a, b) => a.y - b.y || a.x - b.x || compareCodeUnits(a.text, b.text));
122
+ const warnings = [];
123
+ if (rejected > 0)
124
+ warnings.push(`${rejected} uncertain or invalid reference text fragment(s) were omitted.`);
125
+ if (labels.length > MAX_LABELS)
126
+ warnings.push('Reference text was limited to 512 labels.');
127
+ if (labels.length === 0)
128
+ warnings.push('No reliable English labels were readable in the reference image.');
129
+ return { labels: labels.slice(0, MAX_LABELS), warnings };
130
+ }
131
+ /** One active OCR thread per process; contention is retryable instead of changing generated
132
+ * names. No queue retains reference image bytes. Worker stdout is never forwarded.
133
+ */
134
+ export async function extractReferenceLabels(image) {
135
+ if (!Buffer.isBuffer(image) || image.length === 0 || image.length > MAX_BYTES) {
136
+ return { labels: [], warnings: ['Reference text image exceeded safe byte bounds.'] };
137
+ }
138
+ if (!hasRasterSignature(image)) {
139
+ return {
140
+ labels: [],
141
+ warnings: ['Reference text recognition requires a supported raster image.'],
142
+ };
143
+ }
144
+ if (active)
145
+ throw new ServiceError('MAP_REFERENCE_OCR_BUSY', 'Offline reference text recognition is busy; retry the request.', { retryable: true });
146
+ active = true;
147
+ let worker;
148
+ let timer;
149
+ try {
150
+ const decoded = sharp(image, { limitInputPixels: MAX_PIXELS, animated: false });
151
+ const metadata = await decoded.metadata();
152
+ if (!['png', 'jpeg', 'webp', 'tiff', 'gif'].includes(metadata.format) ||
153
+ !metadata.width ||
154
+ !metadata.height) {
155
+ return {
156
+ labels: [],
157
+ warnings: ['Reference text recognition requires a supported raster image.'],
158
+ };
159
+ }
160
+ const { data: png, info } = await decoded
161
+ .flatten({ background: '#ffffff' })
162
+ .resize(MAX_OCR_SIDE, MAX_OCR_SIDE, { fit: 'inside', withoutEnlargement: true })
163
+ .png()
164
+ .toBuffer({ resolveWithObject: true });
165
+ // Paths resolve only fixed, installed, shrinkwrap-pinned package resources. Missing model
166
+ // data fails closed; we never let Tesseract choose its default remote language URL.
167
+ const model = await readFile(require.resolve('@tesseract.js-data/eng/4.0.0_best_int/eng.traineddata.gz'));
168
+ worker = new Worker(require.resolve('tesseract.js/src/worker-script/node/index.js'), {
169
+ stdout: true,
170
+ stderr: true,
171
+ resourceLimits: { maxOldGenerationSizeMb: 192 },
172
+ execArgv: [],
173
+ });
174
+ // Drain diagnostics without forwarding OCR input, recognized names, paths or stdout.
175
+ worker.stdout.resume();
176
+ worker.stderr.resume();
177
+ const currentWorker = worker;
178
+ let sequence = 0;
179
+ let rejectPending;
180
+ const timeout = new Promise((_resolve, reject) => {
181
+ timer = setTimeout(() => reject(new ServiceError('MAP_REFERENCE_OCR_TIMEOUT', 'Offline reference text recognition exceeded its time limit; retry the request.', { retryable: true })), OCR_TIMEOUT_MS);
182
+ });
183
+ currentWorker.on('error', () => rejectPending?.(new Error('OCR worker failed')));
184
+ currentWorker.on('exit', () => rejectPending?.(new Error('OCR worker exited')));
185
+ const send = (action, payload) => {
186
+ const jobId = `reference-${sequence++}`;
187
+ return new Promise((resolve, reject) => {
188
+ rejectPending = reject;
189
+ const receive = (raw) => {
190
+ const packet = packetSchema.safeParse(raw);
191
+ if (!packet.success || packet.data.jobId !== jobId || packet.data.status === 'progress')
192
+ return;
193
+ currentWorker.off('message', receive);
194
+ rejectPending = undefined;
195
+ if (packet.data.status === 'reject')
196
+ reject(new Error('OCR recognition failed'));
197
+ else
198
+ resolve(packet.data.data);
199
+ };
200
+ currentWorker.on('message', receive);
201
+ currentWorker.postMessage({ workerId: 'reference-labels', jobId, action, payload });
202
+ });
203
+ };
204
+ const recognize = async () => {
205
+ await send('load', { options: { lstmOnly: true, logging: false } });
206
+ await send('loadLanguage', {
207
+ langs: [{ code: 'eng', data: model }],
208
+ options: { cacheMethod: 'none', gzip: true, lstmOnly: true },
209
+ });
210
+ await send('initialize', { langs: 'eng', oem: 1, config: {} });
211
+ await send('setParameters', {
212
+ params: { tessedit_pageseg_mode: '11', user_defined_dpi: '300' },
213
+ });
214
+ return send('recognize', { image: png, options: {}, output: { text: false, tsv: true } });
215
+ };
216
+ const result = await Promise.race([recognize(), timeout]);
217
+ const output = z.object({ tsv: z.string().max(MAX_TSV_BYTES) }).safeParse(result);
218
+ if (!output.success)
219
+ throw new Error('Unsupported OCR output');
220
+ return parseReferenceLabelTsv(output.data.tsv, info.width, info.height);
221
+ }
222
+ catch (error) {
223
+ if (error instanceof ServiceError)
224
+ throw error;
225
+ throw new ServiceError('MAP_REFERENCE_OCR_UNAVAILABLE', 'Offline reference text recognition could not complete; verify the installed OCR dependencies and retry the request.', { retryable: true });
226
+ }
227
+ finally {
228
+ if (timer)
229
+ clearTimeout(timer);
230
+ try {
231
+ if (worker)
232
+ await worker.terminate();
233
+ }
234
+ finally {
235
+ active = false;
236
+ }
237
+ }
238
+ }