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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +373 -70
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- 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 (
|
|
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
|
-
//
|
|
319
|
-
const x = floating ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3269
|
-
|
|
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
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
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
|
+
}
|