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.
This commit is contained in:
Richard R 2026-06-02 22:12:16 -06:00
parent cb430d5db0
commit 529f42528a
4 changed files with 132 additions and 37 deletions

View file

@ -1,7 +1,29 @@
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { PdfTextItem } from './types';
export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] {
interface ViewportLike {
height: number;
transform: readonly number[];
}
function applyViewportTransform(
x: number,
y: number,
transform: readonly number[],
): { x: number; y: number } {
const a = Number(transform[0] ?? 1);
const b = Number(transform[1] ?? 0);
const c = Number(transform[2] ?? 0);
const d = Number(transform[3] ?? 1);
const e = Number(transform[4] ?? 0);
const f = Number(transform[5] ?? 0);
return {
x: (a * x) + (c * y) + e,
y: (b * x) + (d * y) + f,
};
}
export function normalizeTextItemsForLayout(items: TextItem[], viewport: ViewportLike): PdfTextItem[] {
return items
.filter((item) => {
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false;
@ -17,13 +39,18 @@ export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: numbe
return true;
})
.map((item) => {
const x = Number(item.transform[4] ?? 0);
const origin = applyViewportTransform(
Number(item.transform[4] ?? 0),
Number(item.transform[5] ?? 0),
viewport.transform,
);
const width = Math.max(0, Number(item.width ?? 0));
const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1)));
const baselineY = Number(item.transform[5] ?? 0);
// pdf.js text transforms are in PDF user-space (origin bottom-left).
// Normalize into top-left page coordinates to match rendered image/model boxes.
const y = Math.max(0, pageHeight - baselineY - height);
// pdf.js text transforms are in PDF user-space and may include non-zero
// page origins via the viewport transform. Map the text baseline into
// viewport coordinates first, then convert baseline to top-left.
const x = Math.max(0, origin.x);
const y = Math.max(0, origin.y - height);
return {
text: item.str,
x,

View file

@ -72,7 +72,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
const textContent = await page.getTextContent();
const textItems = normalizeTextItemsForLayout(
textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item),
viewport.height,
viewport,
);
if (textItems.length > 0) sawText = true;

View file

@ -196,8 +196,10 @@ function preprocessResized(
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 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);
@ -232,24 +234,40 @@ function clampBox(
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;
function sigmoid(value: number): number {
if (value >= 0) {
const z = Math.exp(-value);
return 1 / (1 + z);
}
const z = Math.exp(value);
return z / (1 + z);
}
function buildOrderSequence(orderLogits: Float32Array, numQueries: number): number[] {
const orderVotes = new Float32Array(numQueries);
for (let col = 0; col < numQueries; col += 1) {
let votes = 0;
for (let row = 0; row < col; row += 1) {
votes += sigmoid(orderLogits[row * numQueries + col] ?? 0);
}
for (let row = col + 1; row < numQueries; row += 1) {
votes += 1 - sigmoid(orderLogits[col * numQueries + row] ?? 0);
}
orderVotes[col] = votes;
}
let sum = 0;
for (let i = 0; i < count; i += 1) {
sum += Math.exp(logits[offset + i] - maxLogit);
const pointers = Array.from({ length: numQueries }, (_, index) => index)
.sort((a, b) => orderVotes[a]! - orderVotes[b]!);
const orderSeq = new Array<number>(numQueries);
for (let rank = 0; rank < pointers.length; rank += 1) {
const queryIndex = pointers[rank]!;
orderSeq[queryIndex] = rank;
}
const score = sum > 0 ? (1 / sum) : 0;
return { index: maxIndex, score };
return orderSeq;
}
function normalizeModelLabel(rawLabel: string): string {
@ -283,25 +301,26 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
const logits = output.logits?.data as Float32Array | undefined;
const predBoxes = output.pred_boxes?.data as Float32Array | undefined;
const orderLogits = output.order_logits?.data as Float32Array | undefined;
if (!logits || !predBoxes) return [];
const numQueries = Math.floor(predBoxes.length / 4);
if (numQueries <= 0) return [];
const classCount = Math.floor(logits.length / numQueries);
if (classCount <= 0) return [];
const orderSeq = orderLogits && orderLogits.length >= numQueries * numQueries
? buildOrderSequence(orderLogits, numQueries)
: null;
const regions: LayoutRegion[] = [];
const detections: Array<{
queryIdx: number;
classIdx: number;
score: number;
order: number;
rawBox: [number, number, number, number];
}> = [];
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;
@ -313,7 +332,33 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
cy + h / 2,
];
const clamped = clampBox(rawBox, pageWidth, pageHeight);
for (let classIdx = 0; classIdx < classCount; classIdx += 1) {
detections.push({
queryIdx,
classIdx,
score: sigmoid(logits[queryIdx * classCount + classIdx] ?? 0),
order: orderSeq?.[queryIdx] ?? queryIdx,
rawBox,
});
}
}
const regions: LayoutRegion[] = [];
detections.sort((a, b) => {
if (Math.abs(b.score - a.score) > 1e-6) return b.score - a.score;
return a.order - b.order;
});
for (const detection of detections.slice(0, numQueries)) {
const rawLabel = idToLabel[detection.classIdx];
if (!rawLabel) continue;
const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)];
if (!mapped) continue;
const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE;
if (!Number.isFinite(detection.score) || detection.score < minScore) continue;
const clamped = clampBox(detection.rawBox, pageWidth, pageHeight);
if (!clamped) continue;
const sizeRule = MIN_REGION_SIZE[mapped];
@ -326,7 +371,7 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
regions.push({
bbox: clamped,
label: mapped,
confidence: cls.score,
confidence: detection.score,
});
}

View file

@ -32,7 +32,10 @@ describe('normalizeTextItemsForLayout', () => {
[0, 10, -10, 0, 30, 400],
);
const normalized = normalizeTextItemsForLayout([horizontal, rotated], 800);
const normalized = normalizeTextItemsForLayout([horizontal, rotated], {
height: 800,
transform: [1, 0, 0, -1, 0, 800],
});
expect(normalized).toHaveLength(1);
expect(normalized[0]?.text).toBe('Powered by large language models');
});
@ -41,7 +44,27 @@ describe('normalizeTextItemsForLayout', () => {
const vertical = makeTextItem('Side label', [0, 10, -10, 0, 30, 200]);
const skewed = makeTextItem('Watermark', [10, 5, 2, 10, 200, 500]);
const normalized = normalizeTextItemsForLayout([vertical, skewed], 800);
const normalized = normalizeTextItemsForLayout([vertical, skewed], {
height: 800,
transform: [1, 0, 0, -1, 0, 800],
});
expect(normalized).toEqual([]);
});
test('accounts for non-zero page origins in the viewport transform', () => {
const croppedPageLine = makeTextItem(
'Vasher turned away.',
[11.2, 0, 0, 11.2, 127.5, 644.4128],
100,
);
const normalized = normalizeTextItemsForLayout([croppedPageLine], {
height: 666.0074,
transform: [1, 0, 0, -1, -53.4352, 720.565],
});
expect(normalized).toHaveLength(1);
expect(normalized[0]?.x).toBeCloseTo(74.0648, 4);
expect(normalized[0]?.y).toBeCloseTo(64.9522, 4);
});
});