12345678910111213141516171819202122232425262728293031import { describe, expect, it, vi } from "vitest";3233import type { BitMatrix } from "@zxing/library";34import { buildAztecMatrix } from "./aztec";35import { buildChunkEnvelope } from "./helpers";36import {37 cellRectFor,38 claimPDFRenderPixels,39 createPDFRenderBudget,40 decodeAztecFromArgb,41 parseChunkEnvelope,42} from "./pdf-import";43import {44 CODE_SIZE,45 COL_GAP,46 HEADER_GAP,47 HEADER_HEIGHT,48 MARGIN,49 PAGE_HEIGHT,50 PAGE_WIDTH,51 ROW_STEP,52} from "./pdf-layout";5354describe("PB-26-704 PDF aggregate render budget", () => {55 it("rejects cumulative near-limit pages before another canvas allocation", () => {56 const budget = createPDFRenderBudget(100);57 claimPDFRenderPixels(budget, 10, 5);58 claimPDFRenderPixels(budget, 5, 10);59 expect(() => claimPDFRenderPixels(budget, 1, 1)).toThrow(/aggregate render limit/i);60 });61});62636465describe("parseChunkEnvelope", () => {66 const v3 = {67 v: 3 as const,68 c: "PX-TEST-1234",69 s: 0,70 S: 1,71 i: 0,72 t: 15,73 p: false,74 d: "AAAA",75 h: "ABCDEF1234567890ABCDEF1234567890",76 };7778 it("parses a v3 envelope (current generator format)", () => {79 expect(parseChunkEnvelope(JSON.stringify(v3))).toEqual(v3);80 });8182 it("rejects a v2 envelope", () => {83 expect(parseChunkEnvelope(JSON.stringify({ v: 2, c: "PX-TEST-1234" }))).toBeNull();84 });8586 it("rejects a v3 envelope missing stripe metadata", () => {87 const broken = { ...v3, s: undefined, S: undefined };88 expect(parseChunkEnvelope(JSON.stringify(broken))).toBeNull();89 });9091 92 it("parse_chunk_envelope_rejects_non_integer_stripe_fields", () => {93 expect(parseChunkEnvelope(JSON.stringify({ ...v3, s: 0.5 }))).toBeNull();94 expect(parseChunkEnvelope(JSON.stringify({ ...v3, S: 1.5 }))).toBeNull();95 expect(parseChunkEnvelope(JSON.stringify({ ...v3, i: 1.5 }))).toBeNull();96 expect(parseChunkEnvelope(JSON.stringify({ ...v3, t: 15.5 }))).toBeNull();97 });9899 it("returns null for non-JSON input", () => {100 expect(parseChunkEnvelope("not json at all")).toBeNull();101 });102103 it("returns null for non-Peach JSON", () => {104 expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull();105 });106107 it("returns null when version is missing", () => {108 expect(109 parseChunkEnvelope(110 JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }),111 ),112 ).toBeNull();113 });114115 it("returns null when fields have the wrong types", () => {116 expect(117 parseChunkEnvelope(118 JSON.stringify({119 v: 3,120 c: "PX",121 s: 0,122 S: 1,123 i: "0", 124 t: 5,125 p: false,126 d: "A",127 h: "A",128 }),129 ),130 ).toBeNull();131 });132133 it("returns null for v=1 envelopes", () => {134 expect(135 parseChunkEnvelope(136 JSON.stringify({137 v: 1,138 c: "PX",139 i: 0,140 t: 5,141 p: false,142 d: "A",143 h: "A",144 }),145 ),146 ).toBeNull();147 });148});149150151152describe("cellRectFor", () => {153 154 155 const SCALE = 2.0;156 const canvasW = Math.ceil(PAGE_WIDTH * SCALE);157 const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);158159 it("covers every cell of the printed 6×6 grid", () => {160 for (let row = 0; row < 6; row++) {161 for (let col = 0; col < 6; col++) {162 const rect = cellRectFor(canvasW, canvasH, row, col);163 164 const codeXMin = (MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE;165 const codeXMax = codeXMin + CODE_SIZE * SCALE;166 const codeYMin =167 (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE;168 const codeYMax = codeYMin + CODE_SIZE * SCALE;169 expect(rect.sx).toBeLessThanOrEqual(codeXMin);170 expect(rect.sy).toBeLessThanOrEqual(codeYMin);171 expect(rect.sx + rect.sw).toBeGreaterThanOrEqual(codeXMax);172 expect(rect.sy + rect.sh).toBeGreaterThanOrEqual(codeYMax);173 }174 }175 });176177 it("places adjacent columns far enough apart that crops do not overlap into the next code", () => {178 179 180 for (let row = 0; row < 6; row++) {181 for (let col = 0; col < 5; col++) {182 const left = cellRectFor(canvasW, canvasH, row, col);183 const rightCodeXMin =184 (MARGIN + (col + 1) * (CODE_SIZE + COL_GAP)) * SCALE;185 expect(left.sx + left.sw).toBeLessThanOrEqual(rightCodeXMin);186 }187 }188 });189190 it("clamps to canvas bounds", () => {191 const rect = cellRectFor(canvasW, canvasH, 5, 5);192 expect(rect.sx + rect.sw).toBeLessThanOrEqual(canvasW);193 expect(rect.sy + rect.sh).toBeLessThanOrEqual(canvasH);194 });195});196197198199const WHITE = -1;200const BLACK = (0xff << 24) | 0;201202203204205206207208209210function renderMatrixToArgb(211 matrix: BitMatrix,212 moduleSize: number,213 padModules: number,214): { argb: Int32Array; width: number; height: number } {215 const modules = matrix.getWidth();216 const pad = padModules * moduleSize;217 const size = modules * moduleSize + pad * 2;218 const argb = new Int32Array(size * size).fill(WHITE);219 for (let y = 0; y < modules; y++) {220 for (let x = 0; x < modules; x++) {221 if (matrix.get(x, y)) {222 const baseRow = (pad + y * moduleSize) * size + pad + x * moduleSize;223 for (let dy = 0; dy < moduleSize; dy++) {224 for (let dx = 0; dx < moduleSize; dx++) {225 argb[baseRow + dy * size + dx] = BLACK;226 }227 }228 }229 }230 }231 return { argb, width: size, height: size };232}233234235236237238function blitArgb(239 src: Int32Array,240 srcW: number,241 srcH: number,242 dst: Int32Array,243 dstW: number,244 dx: number,245 dy: number,246): void {247 for (let y = 0; y < srcH; y++) {248 const dstRow = (dy + y) * dstW + dx;249 const srcRow = y * srcW;250 for (let x = 0; x < srcW; x++) {251 dst[dstRow + x] = src[srcRow + x]!;252 }253 }254}255256describe("decodeAztecFromArgb", () => {257 it("round-trips a real envelope JSON through the decode path", async () => {258 259 260 const envelope = await buildChunkEnvelope(261 new TextEncoder().encode("hello world".repeat(20)),262 0,263 36,264 false,265 "PX-TEST-1234",266 0,267 1,268 );269 const payload = JSON.stringify(envelope);270271 const matrix = buildAztecMatrix(payload);272 const { argb, width, height } = renderMatrixToArgb(matrix, 4, 2);273274 expect(decodeAztecFromArgb(argb, width, height)).toBe(payload);275 });276277 it("returns null for an all-white buffer", () => {278 const w = 100;279 const h = 100;280 const argb = new Int32Array(w * h).fill(WHITE);281 expect(decodeAztecFromArgb(argb, w, h)).toBeNull();282 });283284 it("returns null for an all-black buffer", () => {285 const w = 100;286 const h = 100;287 const argb = new Int32Array(w * h).fill(BLACK);288 expect(decodeAztecFromArgb(argb, w, h)).toBeNull();289 });290291 it("returns null for random noise", () => {292 const w = 200;293 const h = 200;294 const argb = new Int32Array(w * h);295 296 let s = 0x12345678;297 for (let i = 0; i < argb.length; i++) {298 s = (s * 1664525 + 1013904223) | 0;299 argb[i] = s & 1 ? BLACK : WHITE;300 }301 expect(decodeAztecFromArgb(argb, w, h)).toBeNull();302 });303});304305306307describe("page-grid integration", () => {308 it("decodes all 36 envelopes when laid out at the generator's grid positions", async () => {309 const SCALE = 2.0;310 const canvasW = Math.ceil(PAGE_WIDTH * SCALE);311 const canvasH = Math.ceil(PAGE_HEIGHT * SCALE);312 const page = new Int32Array(canvasW * canvasH).fill(WHITE);313314 315 316 317 const envelopes = await Promise.all(318 Array.from({ length: 36 }, (_, i) => {319 const data = new TextEncoder().encode(`chunk-${i}-`.padEnd(200, "x"));320 return buildChunkEnvelope(data, i, 36, false, "PX-TEST-1234", 0, 1);321 }),322 );323324 325 326 327 const codeBoxPx = Math.floor(CODE_SIZE * SCALE);328 for (let i = 0; i < envelopes.length; i++) {329 const env = envelopes[i]!;330 const matrix = buildAztecMatrix(JSON.stringify(env));331 const modules = matrix.getWidth();332 333 334 const moduleSize = Math.max(1, Math.floor(codeBoxPx / (modules + 4)));335 const { argb, width, height } = renderMatrixToArgb(matrix, moduleSize, 2);336 const row = Math.floor(i / 6);337 const col = i % 6;338 const dx = Math.round((MARGIN + col * (CODE_SIZE + COL_GAP)) * SCALE);339 const dy = Math.round(340 (MARGIN + HEADER_HEIGHT + HEADER_GAP + row * ROW_STEP) * SCALE,341 );342 blitArgb(argb, width, height, page, canvasW, dx, dy);343 }344345 346 347 const recovered: string[] = [];348 for (let row = 0; row < 6; row++) {349 for (let col = 0; col < 6; col++) {350 const { sx, sy, sw, sh } = cellRectFor(canvasW, canvasH, row, col);351 const cell = new Int32Array(sw * sh);352 for (let y = 0; y < sh; y++) {353 const srcRow = (sy + y) * canvasW + sx;354 const dstRow = y * sw;355 for (let x = 0; x < sw; x++) cell[dstRow + x] = page[srcRow + x]!;356 }357 const text = decodeAztecFromArgb(cell, sw, sh);358 expect(text, `cell (row=${row}, col=${col}) failed to decode`).not.toBeNull();359 recovered.push(text!);360 }361 }362363 expect(recovered).toHaveLength(36);364 for (let i = 0; i < 36; i++) {365 const parsed = parseChunkEnvelope(recovered[i]!);366 expect(parsed, `recovered envelope ${i} failed parse`).not.toBeNull();367 expect(parsed!.i).toBe(envelopes[i]!.i);368 expect(parsed!.h).toBe(envelopes[i]!.h);369 expect(parsed!.d).toBe(envelopes[i]!.d);370 }371 }, 30_000);372373 it("MV-5: importCodexPDF rejects an oversized file without reading it", async () => {374 const { importCodexPDF } = await import("./pdf-import");375 const { IMPORT_LIMITS } = await import("../../shared/import-limits");376 const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));377 const fakeFile = {378 size: IMPORT_LIMITS.MAX_PDF_BYTES + 1,379 arrayBuffer,380 } as unknown as File;381382 const result = await importCodexPDF(fakeFile);383384 expect(result.chunksFound).toBe(0);385 expect(result.envelopes).toEqual([]);386 expect(result.errors).toHaveLength(1);387 expect(result.errors[0]).toMatch(/limited to/);388 389 expect(arrayBuffer).not.toHaveBeenCalled();390 });391});