* refactor(ui): replace anchor tags with next/link in SidebarNavLink and UserMenu Update SidebarNavLink to use next/link for navigation instead of anchor tags, ensuring proper routing and improved accessibility in Next.js. Refactor UserMenu to remove legacy Link wrappers and directly use SidebarNavLink for signin and signup links. This streamlines navigation components and aligns with Next.js best practices. * fix(pdf): handle non-zero viewport origins and improve layout model ordering Update normalizeTextItemsForLayout to correctly apply viewport transforms, including non-zero page origins, ensuring accurate mapping of PDF text items to top-left coordinates. Refactor runLayoutModel to implement a custom order sequence builder for layout regions using model order logits, improving region ordering consistency with model semantics. Update related tests to cover viewport transform edge cases. * refactor(pdf): improve text normalization with font ascent and vertical overlap logic Enhance text normalization by incorporating font ascent and descent data to more accurately position glyphs, especially for decorative initials. Update merge logic to better detect line membership using vertical overlap, ensuring drop caps and overlapping glyphs are merged correctly. Extend tests to cover these layout scenarios. * style(range): redesign slider with precision gauge and ruler ticks Revamp the range input to feature a minimalist "precision gauge" style. Introduce a hairline rail, ruler notches for discrete steps, and a slim needle thumb. Add CSS variables and logic for per-instance tick sizing and coloring. Remove bulky inline class-based styling in favor of centralized CSS for improved maintainability and visual clarity. * refactor(ui): modularize PDF loader and range slider visuals Move PDF layout scan visualization and range slider styles into dedicated CSS modules, isolating their styles from the global scope. Integrate PdfLayoutScan component into the PDF viewer loader UI for animated parse progress. Refactor progress bars to use a reusable progress-fill class with animated sheen effect. Update range input to use CSS module for precision gauge styling. * style(reader): remove grid overlay from PdfLayoutScan visualization * refactor(api): add staleness detection for inflight worker operation states Integrate isWorkerOperationStateStale checks into document parse API endpoints to ensure inflight worker operation states are not reused if stale. Introduce helper for staleness detection and corresponding unit tests. Enhance SSE event streaming with keepalive intervals and improve progress acknowledgment error handling. * feat(worker): recover and fail stale in-flight pdf ops on startup Add orphaned operation recovery logic to detect and mark stale in-flight pdf_layout jobs as failed during worker startup. Extend OperationStateStore with listOpStates for state enumeration. Update tests and documentation to cover recovery behavior and new environment variable COMPUTE_PDF_JOB_ATTEMPTS. * refactor(worker): distinguish staleness thresholds for running and queued pdf ops Update orphan recovery logic to apply separate timeouts for 'running' and 'queued' pdf_layout operations. Adjust tests to verify that only stale 'running' operations are failed, while stale 'queued' operations remain untouched. * feat(worker): extend orphan recovery to handle whisper_align ops and improve logging Update orphan recovery to detect and fail stale 'running' whisper_align operations in addition to pdf_layout. Refactor recovery logic to generalize staleness checks across operation kinds and enhance log output with detailed operation info. Expand tests to verify correct handling of both whisper_align and pdf_layout operations in running and queued states. * refactor(control-plane): introduce revision-based CAS for operation state updates Add revision tracking and compare-and-set (CAS) semantics to operation state stores, enabling atomic state transitions and preventing lost updates. Extend the OperationStateStore interface with getOpStateRecord and compareAndSetOpState methods. Update orchestrator and worker runtime to utilize CAS for marking operations as failed only if the state is unchanged. Enhance in-memory, JetStream, and test control plane implementations to support revision logic. This change improves concurrency safety and correctness of operation state management across distributed components. * feat(ui): add parse failure state to PDF layout scan animation Display a distinct "parse halted" visual state in the PDF layout scan component and PDF viewer page when parsing fails. The loader animation is replaced by a static, dimmed page with an alert glyph and updated styling, ensuring users are not misled by an active animation after a failure. CSS and component logic updated to support the new state. * feat(worker): extract orphaned operation recovery to module with periodic sweep Move orphaned operation recovery logic into a dedicated orphan-recovery module, introducing a periodic sweep timer that triggers recovery every 15 seconds while the worker is connected. Refactor runtime to delegate orphan detection and handling to the new module, improving modularity and maintainability. Add unit tests for orphan-recovery to ensure correctness. * fix(ui): adjust PDF viewer layout and update parse loader description * refactor(pdf): streamline layout model region extraction and update test coverage - Replace custom order sequence logic with softmax-based class selection in runLayoutModel - Remove unused sigmoid and buildOrderSequence functions - Simplify detection loop to filter and map regions directly - Add targeted tests for layout model extraction logic - Update CSS animation naming for consistency - Clarify test description and add inline comments for orphan recovery scenario * fix(pdf): add strict validation for layout model output shapes and extend test coverage Add explicit error handling for invalid or inconsistent pred_boxes and logits array lengths in runLayoutModel to prevent silent failures. Expand test suite to verify correct region filtering and error scenarios, ensuring only labeled regions are returned and malformed outputs are handled robustly.
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import * as ort from 'onnxruntime-node';
|
|
import { readFile } from 'fs/promises';
|
|
import type { LayoutRegion, PdfTextItem } from './types';
|
|
import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './model';
|
|
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
|
|
|
|
interface RunLayoutInput {
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
textItems: PdfTextItem[];
|
|
pageImage: Buffer;
|
|
}
|
|
|
|
const DEFAULT_INPUT_SIZE = 800;
|
|
const MIN_SCORE = 0.5;
|
|
const CLASS_MIN_SCORE: Partial<Record<LayoutRegion['label'], number>> = {
|
|
header: 0.4,
|
|
footer: 0.4,
|
|
figure_title: 0.45,
|
|
footnote: 0.45,
|
|
vision_footnote: 0.45,
|
|
};
|
|
|
|
const LABEL_MAP: Record<string, LayoutRegion['label'] | null> = {
|
|
// PP-DocLayoutV3 labels
|
|
abstract: 'abstract',
|
|
algorithm: 'algorithm',
|
|
aside_text: 'aside_text',
|
|
chart: 'chart',
|
|
content: 'content',
|
|
display_formula: 'formula',
|
|
doc_title: 'doc_title',
|
|
figure_title: 'figure_title',
|
|
footer: 'footer',
|
|
footer_image: 'footer',
|
|
footnote: 'footnote',
|
|
formula_number: 'formula_number',
|
|
header: 'header',
|
|
header_image: 'header',
|
|
image: 'image',
|
|
inline_formula: 'formula',
|
|
number: 'number',
|
|
paragraph_title: 'paragraph_title',
|
|
reference: 'reference',
|
|
reference_content: 'reference_content',
|
|
seal: 'seal',
|
|
table: 'table',
|
|
text: 'text',
|
|
vertical_text: 'text',
|
|
vision_footnote: 'vision_footnote',
|
|
};
|
|
|
|
const MIN_REGION_SIZE: Partial<Record<LayoutRegion['label'], { minWidth: number; minHeight: number }>> = {
|
|
abstract: { minWidth: 24, minHeight: 14 },
|
|
algorithm: { minWidth: 24, minHeight: 14 },
|
|
aside_text: { minWidth: 24, minHeight: 14 },
|
|
content: { minWidth: 24, minHeight: 14 },
|
|
text: { minWidth: 24, minHeight: 14 },
|
|
reference: { minWidth: 24, minHeight: 14 },
|
|
reference_content: { minWidth: 24, minHeight: 14 },
|
|
paragraph_title: { minWidth: 24, minHeight: 14 },
|
|
doc_title: { minWidth: 24, minHeight: 14 },
|
|
number: { minWidth: 18, minHeight: 12 },
|
|
figure_title: { minWidth: 18, minHeight: 10 },
|
|
footnote: { minWidth: 18, minHeight: 10 },
|
|
vision_footnote: { minWidth: 18, minHeight: 10 },
|
|
header: { minWidth: 18, minHeight: 10 },
|
|
footer: { minWidth: 18, minHeight: 10 },
|
|
};
|
|
|
|
interface ModelPreprocessor {
|
|
inputWidth: number;
|
|
inputHeight: number;
|
|
rescaleFactor: number;
|
|
mean: [number, number, number];
|
|
std: [number, number, number];
|
|
}
|
|
|
|
let sessionPromise: Promise<ort.InferenceSession> | null = null;
|
|
let idToLabelPromise: Promise<Record<number, string>> | null = null;
|
|
let preprocessorPromise: Promise<ModelPreprocessor> | null = null;
|
|
let canvasFnsPromise: Promise<{
|
|
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D };
|
|
loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>;
|
|
}> | null = null;
|
|
|
|
async function getCanvasFns(): Promise<{
|
|
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D };
|
|
loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>;
|
|
}> {
|
|
if (!canvasFnsPromise) {
|
|
canvasFnsPromise = (async () => {
|
|
const mod = await import('@napi-rs/canvas');
|
|
const namespace = mod as Record<string, unknown>;
|
|
const fallback = (namespace.default ?? {}) as Record<string, unknown>;
|
|
const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as
|
|
| ((width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D })
|
|
| undefined;
|
|
const loadImageFn = (namespace.loadImage ?? fallback.loadImage) as
|
|
| ((src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>)
|
|
| undefined;
|
|
|
|
if (typeof createCanvasFn !== 'function' || typeof loadImageFn !== 'function') {
|
|
throw new Error(
|
|
`Canvas runtime missing createCanvas/loadImage exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`,
|
|
);
|
|
}
|
|
|
|
return { createCanvasFn, loadImageFn };
|
|
})();
|
|
}
|
|
return canvasFnsPromise;
|
|
}
|
|
|
|
async function getSession(): Promise<ort.InferenceSession> {
|
|
if (!sessionPromise) {
|
|
sessionPromise = (async () => {
|
|
const modelPath = await ensureModel();
|
|
const onnxThreadsPerJob = getOnnxThreadsPerJob();
|
|
const stableSessionOptions: ort.InferenceSession.SessionOptions = {
|
|
executionProviders: ['cpu'],
|
|
graphOptimizationLevel: 'all',
|
|
intraOpNumThreads: onnxThreadsPerJob,
|
|
interOpNumThreads: 1,
|
|
executionMode: 'sequential',
|
|
};
|
|
return ort.InferenceSession.create(modelPath, {
|
|
...stableSessionOptions,
|
|
});
|
|
})();
|
|
}
|
|
return sessionPromise;
|
|
}
|
|
|
|
async function getIdToLabel(): Promise<Record<number, string>> {
|
|
if (!idToLabelPromise) {
|
|
idToLabelPromise = (async () => {
|
|
await ensureModel();
|
|
const raw = await readFile(MODEL_CONFIG_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw) as { id2label?: Record<string, string> };
|
|
const out: Record<number, string> = {};
|
|
for (const [key, value] of Object.entries(parsed.id2label ?? {})) {
|
|
const n = Number(key);
|
|
if (Number.isFinite(n)) out[n] = String(value ?? '').trim();
|
|
}
|
|
return out;
|
|
})();
|
|
}
|
|
return idToLabelPromise;
|
|
}
|
|
|
|
async function getPreprocessor(): Promise<ModelPreprocessor> {
|
|
if (!preprocessorPromise) {
|
|
preprocessorPromise = (async () => {
|
|
await ensureModel();
|
|
const raw = await readFile(MODEL_PREPROCESSOR_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw) as {
|
|
size?: { width?: number; height?: number };
|
|
rescale_factor?: number;
|
|
image_mean?: number[];
|
|
image_std?: number[];
|
|
};
|
|
|
|
const inputWidth = Math.max(1, Number(parsed.size?.width ?? DEFAULT_INPUT_SIZE));
|
|
const inputHeight = Math.max(1, Number(parsed.size?.height ?? DEFAULT_INPUT_SIZE));
|
|
const rescaleFactor = Number.isFinite(parsed.rescale_factor) ? Number(parsed.rescale_factor) : (1 / 255);
|
|
const mean = [
|
|
Number(parsed.image_mean?.[0] ?? 0),
|
|
Number(parsed.image_mean?.[1] ?? 0),
|
|
Number(parsed.image_mean?.[2] ?? 0),
|
|
] as [number, number, number];
|
|
const std = [
|
|
Number(parsed.image_std?.[0] ?? 1),
|
|
Number(parsed.image_std?.[1] ?? 1),
|
|
Number(parsed.image_std?.[2] ?? 1),
|
|
] as [number, number, number];
|
|
|
|
return {
|
|
inputWidth,
|
|
inputHeight,
|
|
rescaleFactor,
|
|
mean,
|
|
std,
|
|
};
|
|
})();
|
|
}
|
|
return preprocessorPromise;
|
|
}
|
|
|
|
function preprocessResized(
|
|
image: CanvasImageSource,
|
|
preprocessor: ModelPreprocessor,
|
|
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D },
|
|
): ort.Tensor {
|
|
const canvas = createCanvasFn(preprocessor.inputWidth, preprocessor.inputHeight);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
|
// Match the upstream image processor more closely. The official
|
|
// implementation explicitly disables antialiasing during resize to stay
|
|
// close to OpenCV semantics for PP-DocLayoutV3 inputs.
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
|
|
|
const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
|
const chw = new Float32Array(1 * 3 * preprocessor.inputWidth * preprocessor.inputHeight);
|
|
const channelSize = preprocessor.inputWidth * preprocessor.inputHeight;
|
|
for (let y = 0; y < preprocessor.inputHeight; y += 1) {
|
|
for (let x = 0; x < preprocessor.inputWidth; x += 1) {
|
|
const pixelIndex = (y * preprocessor.inputWidth + x) * 4;
|
|
const idx = y * preprocessor.inputWidth + x;
|
|
const r = imageData.data[pixelIndex] * preprocessor.rescaleFactor;
|
|
const g = imageData.data[pixelIndex + 1] * preprocessor.rescaleFactor;
|
|
const b = imageData.data[pixelIndex + 2] * preprocessor.rescaleFactor;
|
|
|
|
chw[idx] = (r - preprocessor.mean[0]) / Math.max(1e-8, preprocessor.std[0]);
|
|
chw[channelSize + idx] = (g - preprocessor.mean[1]) / Math.max(1e-8, preprocessor.std[1]);
|
|
chw[channelSize * 2 + idx] = (b - preprocessor.mean[2]) / Math.max(1e-8, preprocessor.std[2]);
|
|
}
|
|
}
|
|
return new ort.Tensor('float32', chw, [1, 3, preprocessor.inputHeight, preprocessor.inputWidth]);
|
|
}
|
|
|
|
function clampBox(
|
|
bbox: [number, number, number, number],
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
): [number, number, number, number] | null {
|
|
const x0 = Math.max(0, Math.min(pageWidth, bbox[0]));
|
|
const y0 = Math.max(0, Math.min(pageHeight, bbox[1]));
|
|
const x1 = Math.max(0, Math.min(pageWidth, bbox[2]));
|
|
const y1 = Math.max(0, Math.min(pageHeight, bbox[3]));
|
|
if (x1 <= x0 || y1 <= y0) return null;
|
|
return [x0, y0, x1, y1];
|
|
}
|
|
|
|
function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } {
|
|
let maxLogit = Number.NEGATIVE_INFINITY;
|
|
let maxIndex = 0;
|
|
for (let i = 0; i < count; i += 1) {
|
|
const value = logits[offset + i];
|
|
if (value > maxLogit) {
|
|
maxLogit = value;
|
|
maxIndex = i;
|
|
}
|
|
}
|
|
|
|
let sum = 0;
|
|
for (let i = 0; i < count; i += 1) {
|
|
sum += Math.exp(logits[offset + i] - maxLogit);
|
|
}
|
|
|
|
const score = sum > 0 ? (1 / sum) : 0;
|
|
return { index: maxIndex, score };
|
|
}
|
|
|
|
function normalizeModelLabel(rawLabel: string): string {
|
|
const normalized = rawLabel.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
if (normalized.endsWith('_image')) {
|
|
const base = normalized.slice(0, -'_image'.length);
|
|
if (base === 'header' || base === 'footer') return normalized;
|
|
}
|
|
// Some exports suffix duplicate classes (e.g. header_1, footer_1, text_1).
|
|
return normalized.replace(/_\d+$/g, '');
|
|
}
|
|
|
|
export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegion[]> {
|
|
const { pageWidth, pageHeight, textItems, pageImage } = input;
|
|
if (!textItems.length) return [];
|
|
if (!pageImage || pageImage.byteLength === 0) {
|
|
throw new Error('layout-render-missing-page-image');
|
|
}
|
|
|
|
try {
|
|
const [session, idToLabel, preprocessor, canvasFns] = await Promise.all([
|
|
getSession(),
|
|
getIdToLabel(),
|
|
getPreprocessor(),
|
|
getCanvasFns(),
|
|
]);
|
|
|
|
const decodedPageImage = await canvasFns.loadImageFn(pageImage);
|
|
const pixelValues = preprocessResized(decodedPageImage, preprocessor, canvasFns.createCanvasFn);
|
|
const output = await session.run({ pixel_values: pixelValues });
|
|
|
|
const logits = output.logits?.data as Float32Array | undefined;
|
|
const predBoxes = output.pred_boxes?.data as Float32Array | undefined;
|
|
if (!logits || !predBoxes) return [];
|
|
if (predBoxes.length === 0 && logits.length === 0) return [];
|
|
if (predBoxes.length === 0 || logits.length === 0) {
|
|
throw new Error(
|
|
`layout-model-invalid-output-shape: pred_boxes length=${predBoxes.length}, logits length=${logits.length}`,
|
|
);
|
|
}
|
|
|
|
if (predBoxes.length % 4 !== 0) {
|
|
throw new Error(`layout-model-invalid-pred-box-shape: length ${predBoxes.length} is not divisible by 4`);
|
|
}
|
|
const numQueries = predBoxes.length / 4;
|
|
if (numQueries <= 0) {
|
|
throw new Error(`layout-model-invalid-pred-box-shape: expected positive query count, got ${numQueries}`);
|
|
}
|
|
if (logits.length % numQueries !== 0) {
|
|
throw new Error(
|
|
`layout-model-invalid-logit-shape: length ${logits.length} is not divisible by query count ${numQueries}`,
|
|
);
|
|
}
|
|
const classCount = logits.length / numQueries;
|
|
if (classCount <= 0) {
|
|
throw new Error(`layout-model-invalid-logit-shape: expected positive class count, got ${classCount}`);
|
|
}
|
|
const regions: LayoutRegion[] = [];
|
|
|
|
for (let queryIdx = 0; queryIdx < numQueries; queryIdx += 1) {
|
|
const cls = softmaxMax(logits, queryIdx * classCount, classCount);
|
|
const rawLabel = idToLabel[cls.index];
|
|
if (!rawLabel) continue;
|
|
const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)];
|
|
if (!mapped) continue;
|
|
|
|
const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE;
|
|
if (!Number.isFinite(cls.score) || cls.score < minScore) continue;
|
|
|
|
const cx = predBoxes[queryIdx * 4 + 0] * pageWidth;
|
|
const cy = predBoxes[queryIdx * 4 + 1] * pageHeight;
|
|
const w = predBoxes[queryIdx * 4 + 2] * pageWidth;
|
|
const h = predBoxes[queryIdx * 4 + 3] * pageHeight;
|
|
const rawBox: [number, number, number, number] = [
|
|
cx - w / 2,
|
|
cy - h / 2,
|
|
cx + w / 2,
|
|
cy + h / 2,
|
|
];
|
|
|
|
const clamped = clampBox(rawBox, pageWidth, pageHeight);
|
|
if (!clamped) continue;
|
|
|
|
const sizeRule = MIN_REGION_SIZE[mapped];
|
|
if (sizeRule) {
|
|
const width = clamped[2] - clamped[0];
|
|
const height = clamped[3] - clamped[1];
|
|
if (width < sizeRule.minWidth || height < sizeRule.minHeight) continue;
|
|
}
|
|
|
|
regions.push({
|
|
bbox: clamped,
|
|
label: mapped,
|
|
confidence: cls.score,
|
|
});
|
|
}
|
|
|
|
return regions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`layout-model-inference-failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|