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 { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { PdfTextItem } from './types'; 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 return items
.filter((item) => { .filter((item) => {
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false; 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; return true;
}) })
.map((item) => { .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 width = Math.max(0, Number(item.width ?? 0));
const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1))); 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 and may include non-zero
// pdf.js text transforms are in PDF user-space (origin bottom-left). // page origins via the viewport transform. Map the text baseline into
// Normalize into top-left page coordinates to match rendered image/model boxes. // viewport coordinates first, then convert baseline to top-left.
const y = Math.max(0, pageHeight - baselineY - height); const x = Math.max(0, origin.x);
const y = Math.max(0, origin.y - height);
return { return {
text: item.str, text: item.str,
x, x,

View file

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

View file

@ -196,8 +196,10 @@ function preprocessResized(
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
ctx.imageSmoothingEnabled = true; // Match the upstream image processor more closely. The official
ctx.imageSmoothingQuality = 'high'; // 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); ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
const imageData = ctx.getImageData(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]; return [x0, y0, x1, y1];
} }
function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } { function sigmoid(value: number): number {
let maxLogit = Number.NEGATIVE_INFINITY; if (value >= 0) {
let maxIndex = 0; const z = Math.exp(-value);
for (let i = 0; i < count; i += 1) { return 1 / (1 + z);
const value = logits[offset + i]; }
if (value > maxLogit) { const z = Math.exp(value);
maxLogit = value; return z / (1 + z);
maxIndex = i; }
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; const pointers = Array.from({ length: numQueries }, (_, index) => index)
for (let i = 0; i < count; i += 1) { .sort((a, b) => orderVotes[a]! - orderVotes[b]!);
sum += Math.exp(logits[offset + i] - maxLogit); const orderSeq = new Array<number>(numQueries);
for (let rank = 0; rank < pointers.length; rank += 1) {
const queryIndex = pointers[rank]!;
orderSeq[queryIndex] = rank;
} }
return orderSeq;
const score = sum > 0 ? (1 / sum) : 0;
return { index: maxIndex, score };
} }
function normalizeModelLabel(rawLabel: string): string { 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 logits = output.logits?.data as Float32Array | undefined;
const predBoxes = output.pred_boxes?.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 []; if (!logits || !predBoxes) return [];
const numQueries = Math.floor(predBoxes.length / 4); const numQueries = Math.floor(predBoxes.length / 4);
if (numQueries <= 0) return []; if (numQueries <= 0) return [];
const classCount = Math.floor(logits.length / numQueries); const classCount = Math.floor(logits.length / numQueries);
if (classCount <= 0) return []; 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) { 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 cx = predBoxes[queryIdx * 4 + 0] * pageWidth;
const cy = predBoxes[queryIdx * 4 + 1] * pageHeight; const cy = predBoxes[queryIdx * 4 + 1] * pageHeight;
const w = predBoxes[queryIdx * 4 + 2] * pageWidth; const w = predBoxes[queryIdx * 4 + 2] * pageWidth;
@ -313,7 +332,33 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
cy + h / 2, 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; if (!clamped) continue;
const sizeRule = MIN_REGION_SIZE[mapped]; const sizeRule = MIN_REGION_SIZE[mapped];
@ -326,7 +371,7 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
regions.push({ regions.push({
bbox: clamped, bbox: clamped,
label: mapped, label: mapped,
confidence: cls.score, confidence: detection.score,
}); });
} }

View file

@ -32,7 +32,10 @@ describe('normalizeTextItemsForLayout', () => {
[0, 10, -10, 0, 30, 400], [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).toHaveLength(1);
expect(normalized[0]?.text).toBe('Powered by large language models'); 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 vertical = makeTextItem('Side label', [0, 10, -10, 0, 30, 200]);
const skewed = makeTextItem('Watermark', [10, 5, 2, 10, 200, 500]); 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([]); 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);
});
}); });