diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb53f3f..125f54eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Formát je inspirovaný Keep a Changelog. ### Opraveno +- CI fixture bootstrap nyní používá strict `classic-v2` master pod legacy testovacím klíčem `classic-v1`, takže historický PDF 1.4 master není omylem posuzován jako nový PDF/X-4 výstup; publikovaný náhled zároveň neprovádí synchronní změnu React state v efektu. +- Prepress inspekce ICC profilů nyní správně reportuje verzi podle ICC header encoding, takže oficiální ColorPoint profil je uváděn jako `2.4.0` místo `0.2.4`; profilové bytes, OutputIntent i SHA-256 se nemění. +- Připraven nový immutable `classic-v2` master pro ColorPoint/Fujifilm Revoria uncoated workflow s originálním CMYK OutputIntent profilem; historický `classic-v1` ani produkční default se automaticky nemění. +- Tiskový PRINT a STOCK export voucherů nyní zachovává dokumentový OutputIntent s ověřeným CMYK ICC profilem masteru, vytváří přesnou PDF/X-4 XMP identitu (`GTS_PDFXVersion=PDF/X-4`), vyžaduje PDF verzi minimálně 1.6 a sdílí neměnné master resources napříč vícestránkovou sérií; strict preflight nevyhovující tiskové mastery odmítne. - Publikovaný náhled voucherové šablony nyní vykresluje textové placeholdery podle absolutní pozice uložené baseline; po publikaci se už nesesunou k horní hraně náhledu. - QR kód v PDF má včetně bílého podkladu a ochranné zóny odsazení 0,5 mm od hran své oblasti, aby nepřekrýval ohraničení v tiskovém podkladu; editor zobrazuje stejný výsledek. - Editor voucherových šablon nyní zobrazuje obsah pouze z náhledu vyrenderovaného stejným testovacím PDF, takže QR rámečky a polohy textů odpovídají exportu i u masterů s vlastními překryvy. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 47cc74ac..4bc473b9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -16,7 +16,9 @@ Rollout musí držet následující pořadí; nový web ani worker nesmí mezi m 4. Spusť `npx prisma migrate deploy`. 5. Z adresáře nového release spusť povinný `npm run voucher:templates:bootstrap` se stejným env (`DATABASE_URL`, `MEDIA_STORAGE_ROOT`) a dependencies jako nový runtime. Bootstrap je idempotentní: vytvoří nebo načte publikovaný `classic-v1`, validuje master i preview, opraví neplatný preview, backfilluje legitimní historické vouchery na `VoucherTemplate.id` a ověří, že počet řádků s `templateId IS NULL` je nula. 6. Teprve po úspěšném bootstrapu atomicky aktivuj nový release, spusť web a worker a proveď health/smoke kontroly. Při selhání migrace nebo bootstrapu release neaktivuj. -7. U tiskárny kontroluj geometrii ColorPoint (trim 210 × 99 mm, bleed 3 mm). Výstup není deklarován jako PDF/X-4 VERIFIED bez externího validačního nástroje. +7. U tiskárny kontroluj geometrii ColorPoint (trim 210 × 99 mm, bleed 3 mm). Interní preflight ověřuje tiskový kontrakt a reportuje `STRUCTURALLY_VALIDATED`; bez externího validačního nástroje se výstup neoznačuje jako externě ověřený PDF/X-4. + +Připravený `classic-v2` master s oficiálním profilem ColorPoint/Fujifilm Revoria uncoated je pouze kandidátní asset: bootstrap ani runtime automaticky nemění produkční default a nevytvářejí databázový záznam. Schválený rollout musí samostatně nahrát master do privátního storage, vytvořit a validovat publikovaný `VoucherTemplate` s klíčem `classic-v2`, provést PRINT/STOCK smoke test a teprve po schválení změnit `SiteSettings` default; historické vouchery `classic-v1` musí dál odkazovat na původní template. - Před releasem s migrací `20260710110000_availability_slot_capacity_one` ověř `AvailabilitySlot` dotazem `WHERE "capacity" <> 1`. Migrace se při nálezu bezpečně zastaví; hodnoty neopravuj hromadně bez kontroly souběžných rezervací. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7093147f..67c2b447 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -33,7 +33,7 @@ Pro centralizovaný přehled hlavních route handler kontraktů používej i [`d - `VoucherTemplate` je verzovaný immutable master: draft lze upravit, `PUBLISHED` a `INACTIVE` se nikdy nepřepisují. Nový design vzniká klonem do nové verze. - Master PDF je privátní soubor v `MEDIA_STORAGE_ROOT/private/voucher-templates`; databáze drží pouze relativní cestu a SHA-256. Pro first rollout spusť explicitně `npm run voucher:templates:bootstrap` po migraci, nikdy při web requestu. - `Voucher.templateId`, `VoucherPrintBatch.templateId` a `SiteSettings.voucherDefaultTemplateId` jsou referenční vazby. Default musí odkazovat na publikovanou šablonu; před deaktivací jej změň. -- PRINT (216 × 105 mm), DIGITAL (vektorový crop 210 × 99 mm) i STOCK používají stejný persistentní master. PDF/X-4 conformity renderer neověřuje; preflight proto reportuje `NOT VERIFIED`, ale kontroluje geometrii a čitelnost PDF. +- PRINT (216 × 105 mm), DIGITAL (vektorový crop 210 × 99 mm) i STOCK používají stejný persistentní master. Tiskový preflight strukturálně kontroluje PDF header minimálně 1.6, geometrii, OutputIntent, ICC header vhodný pro CMYK tisk (`N=4`, `acsp`, class, color space, PCS), přesné katalogové PDF/X-4 XMP metadata (`GTS_PDFXVersion=PDF/X-4` bez `GTS_PDFXConformance`), rotaci, encryption a všechny stránky; bez externího PDF/X validátoru reportuje `STRUCTURALLY_VALIDATED`, nikoli externí certifikaci. ### Vývoj e-mailů @@ -332,6 +332,7 @@ Při změně schématu dodrž standardní Prisma workflow: před migrací `npx p - Detail voucheru má navíc read-only sekci `Odeslání e-mailem`, která z `EmailLog` typu `VOUCHER_SENT` vrací jen bezpečnou historii posledních 5 záznamů. Read model nesmí vracet raw payload, `processingToken`, `providerMessageId` ani jiná technická pole; pokud dotaz na historii selže, detail voucheru má dál fungovat a jen zobrazit prázdný stav. - PDF voucheru běží přes route handlery `/admin/vouchery/[voucherId]/pdf` a `/admin/provoz/vouchery/[voucherId]/pdf`, sdílený handler `createAdminVoucherPdfRoute(...)`, Next.js wrapper `src/features/vouchers/lib/voucher-pdf.ts` se `import "server-only"` a worker-safe core `src/features/vouchers/lib/voucher-pdf-core.ts`. Handler vždy ověřuje session a roli `OWNER` nebo `SALON`, PDF neukládá do DB ani na disk a generuje ho z aktuálního read modelu voucheru. - Voucherová šablona je po bootstrapu výhradně perzistentní záznam `VoucherTemplate` s privátním masterem v `MEDIA_STORAGE_ROOT`; každý voucher ukládá přesný `templateKey` i `templateId` a renderer historického voucheru nefallbackuje na aktuální default. Seed `classic-v1.pdf` je neveřejný bootstrap asset v `src/features/vouchers/bootstrap-assets/`, který je explicitně zahrnutý do Next output-file trace. Nový design znamená nový key (`classic-v2`, `christmas-v1`), nikoli změnu geometrie `classic-v1`; skutečný runtime master se nikdy nenačítá z `public/`. +- `classic-v2.pdf` je připravený ColorPoint/Fujifilm Revoria uncoated kandidát s embedded profilem z oficiálního ColorPoint URL; jeho finální schválení tiskárnou, instalace do persistentní `VoucherTemplate` a přepnutí defaultu jsou samostatné rollout kroky. Reprodukční příprava používá `npm run voucher:templates:prepare-classic-v2 -- classic-v1.pdf profil.icc classic-v2.pdf` a profil se do repozitáře neukládá samostatně. - Každá template definition nese vlastní `layout`, včetně page size, TrimBoxu, dynamických oblastí, souřadnic QR a typografických parametrů. Renderer je obecný a pouze řeší `templateKey -> registry -> master + layout`; neobsahuje větvení pro konkrétní designy. DIGITAL a PRINT používají stejný layout, DIGITAL je pouze vektorový crop PRINT stránky. Nová šablona musí projít úplným checklistem výše včetně trusted asset readeru, registry, layoutu a regresních/preflight testů; formulář vytvoření i Nastavení → Vouchery ji pak načtou automaticky z aktivních definition. `allowedTypes` filtruje UI a je znovu vynuceno serverem. - Preview šablon je lokální asset z registry, nikoli Media Manager; chybějící PNG zobrazí v admin formuláři náhradní stav a neblokuje voucherový backend ani PDF generování. - Sdílený worker-safe core v `src/features/vouchers/lib/voucher-pdf-core.ts` pro `classic-v1` personalizuje master PRINT stránku 216 × 105 mm s bleedem a TrimBoxem 210 × 99 mm na offsetu 3 mm. Tyto hodnoty jsou uloženy v layoutu `classic-v1` a exportované konstanty v core jsou pouze zpětně kompatibilní aliasy. DIGITAL vzniká vektorovým výřezem stejné PRINT stránky; routy `/pdf` a `/pdf/tisk` zachovávají původní URL kontrakt. diff --git a/package.json b/package.json index e4917df2..9e12ebf6 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,9 @@ "db:clear-booking-data": "node scripts/clear-booking-data.mjs", "media:audit": "tsx scripts/media-audit.ts", "ci:prepare-voucher-fixtures": "node --conditions=react-server --import tsx scripts/prepare-ci-voucher-fixtures.ts", - "voucher:templates:bootstrap": "node --conditions=react-server --env-file=.env --import tsx scripts/bootstrap-voucher-templates.ts" + "voucher:templates:bootstrap": "node --conditions=react-server --env-file=.env --import tsx scripts/bootstrap-voucher-templates.ts", + "voucher:pdf:inspect": "node --conditions=react-server --import tsx scripts/inspect-voucher-pdf.mjs", + "voucher:templates:prepare-classic-v2": "node --conditions=react-server --import tsx scripts/prepare-voucher-classic-v2.mjs" }, "dependencies": { "@fontsource/noto-sans": "^5.3.0", diff --git a/scripts/inspect-voucher-pdf.mjs b/scripts/inspect-voucher-pdf.mjs new file mode 100644 index 00000000..ebf1f23c --- /dev/null +++ b/scripts/inspect-voucher-pdf.mjs @@ -0,0 +1,127 @@ +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRef, PDFStream, decodePDFRawStream } from "pdf-lib"; + +import { inspectIccProfile, inspectPdfPrepressMetadata } from "../src/features/vouchers/lib/voucher-pdf-prepress.ts"; +import { defaultVoucherTemplateLayout } from "../src/features/vouchers/lib/voucher-template-defaults.ts"; + +const filePath = process.argv[2]; +if (!filePath) { + console.error("Použití: npm run voucher:pdf:inspect -- file.pdf"); + process.exit(2); +} + +const mm = (value) => value * 25.4 / 72; +const formatBox = (box) => `${mm(box.width).toFixed(2)}×${mm(box.height).toFixed(2)} mm @ ${mm(box.x).toFixed(2)}×${mm(box.y).toFixed(2)} mm`; + +let pdf; +try { + pdf = await PDFDocument.load(await readFile(filePath), { ignoreEncryption: true, updateMetadata: false }); +} catch (error) { + console.error(`PDF nelze načíst: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + +const metadata = inspectPdfPrepressMetadata(pdf); +const pages = pdf.getPages(); +const objects = pdf.context.enumerateIndirectObjects(); +const streams = objects.filter(([, object]) => object instanceof PDFStream); +const images = streams.filter(([, object]) => object.dict.lookupMaybe(PDFName.of("Subtype"), PDFName)?.asString() === "/Image"); +const embeddedFontStreams = streams.filter(([, object]) => object.dict.get(PDFName.of("Length1")) || object.dict.get(PDFName.of("FontFile")) || object.dict.get(PDFName.of("FontFile2")) || object.dict.get(PDFName.of("FontFile3"))); +const fontDictionaries = objects.filter(([, object]) => object instanceof PDFDict && object.lookupMaybe(PDFName.of("Type"), PDFName)?.asString() === "/Font"); +const bytes = await readFile(filePath); +const pdfVersion = /^%PDF-(\d+\.\d+)/.exec(bytes.subarray(0, 16).toString("latin1"))?.[1] ?? null; +const [pdfMajor, pdfMinor] = pdfVersion?.split(".").map(Number) ?? []; +const pdfVersionValid = Number.isInteger(pdfMajor) && Number.isInteger(pdfMinor) && pdfMajor * 10 + pdfMinor >= 16; +const describe = (object) => { + if (object instanceof PDFName) return object.asString(); + if (object instanceof PDFRef) return `${object.objectNumber} ${object.generationNumber} R`; + if (object instanceof PDFArray) return `[${Array.from({ length: object.size() }, (_, index) => describe(object.get(index))).join(" ")}]`; + return object?.constructor?.name ?? String(object); +}; +const pageResources = pages[0]?.node.lookupMaybe(PDFName.of("Resources"), PDFDict); +const pageColorSpaces = pageResources?.lookupMaybe(PDFName.of("ColorSpace"), PDFDict); +const colorSpaceEntries = pageColorSpaces + ? Array.from(pageColorSpaces.entries()).map(([name, value]) => `${name.asString()}=${describe(value)}`).join(", ") + : "none"; +const iccObjects = streams.flatMap(([ref, object]) => { + const n = object.dict.get(PDFName.of("N")); + if (!n) return []; + let profile; + let decodedSize = null; + try { + const decoded = decodePDFRawStream(object).decode(); + decodedSize = decoded.length; + profile = inspectIccProfile(decoded); + } catch { profile = null; } + return [{ ref: describe(ref), size: decodedSize, encodedSize: object.getContents().length, channels: n.asNumber?.() ?? null, sha256: profile ? createHash("sha256").update(decodePDFRawStream(object).decode()).digest("hex") : null, profile }]; +}); +const imageColorSpaces = streams + .filter(([, object]) => object.dict.lookupMaybe(PDFName.of("Subtype"), PDFName)?.asString() === "/Image") + .map(([ref, object]) => `${describe(ref)}=${describe(object.dict.get(PDFName.of("ColorSpace")))}`) + .join(", "); +const trim = defaultVoucherTemplateLayout.trim; +const dynamicAreas = [defaultVoucherTemplateLayout.valueArea, defaultVoucherTemplateLayout.serviceArea, defaultVoucherTemplateLayout.validityArea, defaultVoucherTemplateLayout.codeArea, defaultVoucherTemplateLayout.qrArea]; +const dynamicSafeAreaValid = dynamicAreas.every((area) => Math.min( + area.xMm - trim.xMm, + area.yMm - trim.yMm, + trim.xMm + trim.widthMm - (area.xMm + area.widthMm), + trim.yMm + trim.heightMm - (area.yMm + area.heightMm), +) >= 3); +const extGStates = objects.filter(([, object]) => object instanceof PDFDict && Array.from(object.keys()).some((key) => key.asString() === "/CA" || key.asString() === "/ca" || key.asString() === "/BM" || key.asString() === "/SMask")); +const softMasks = extGStates.filter(([, object]) => object.get(PDFName.of("SMask")) && object.get(PDFName.of("SMask"))?.toString() !== "/None"); +const blendModes = extGStates.filter(([, object]) => object.get(PDFName.of("BM")) && object.get(PDFName.of("BM"))?.toString() !== "/Normal"); +const overprintObjects = objects.filter(([, object]) => object instanceof PDFDict && (object.get(PDFName.of("OP")) || object.get(PDFName.of("op")) || object.get(PDFName.of("OPM")))); +const javaScriptObjects = objects.filter(([, object]) => object instanceof PDFDict && Array.from(object.keys()).some((key) => key.asString() === "/JavaScript")); +const form = pdf.catalog.lookupMaybe(PDFName.of("AcroForm"), PDFDict); +const first = pages[0]; +const geometryValid = pages.length > 0 && pages.every((page) => { + const media = page.getMediaBox(); + const trim = page.getTrimBox(); + const bleed = page.getBleedBox(); + const crop = page.getCropBox(); + return Math.abs(mm(media.width) - 216) < 0.01 + && Math.abs(mm(media.height) - 105) < 0.01 + && Math.abs(mm(crop.width) - 216) < 0.01 + && Math.abs(mm(crop.height) - 105) < 0.01 + && Math.abs(mm(bleed.width) - 216) < 0.01 + && Math.abs(mm(bleed.height) - 105) < 0.01 + && Math.abs(mm(trim.x) - 3) < 0.01 + && Math.abs(mm(trim.y) - 3) < 0.01 + && Math.abs(mm(trim.width) - 210) < 0.01 + && Math.abs(mm(trim.height) - 99) < 0.01 + && page.getRotation().angle === 0; +}); +const pass = !pdf.isEncrypted && pdfVersionValid && geometryValid && metadata.outputIntent.valid && metadata.xmp.valid && !metadata.pdfXConformanceInfoPresent; + +console.log(`Pages: ${pages.length}`); +console.log(`PDF version: ${pdfVersion ?? "unknown"} (${pdfVersionValid ? "PASS" : "FAIL"})`); +console.log(`MediaBox: ${first ? formatBox(first.getMediaBox()) : "missing"}`); +console.log(`TrimBox: ${first ? formatBox(first.getTrimBox()) : "missing"}`); +console.log(`BleedBox: ${first ? formatBox(first.getBleedBox()) : "missing"}`); +console.log(`CropBox: ${first ? formatBox(first.getCropBox()) : "missing"}`); +console.log(`Page ColorSpace resources: ${colorSpaceEntries}`); +console.log(`Image ColorSpace usage: ${imageColorSpaces || "none"}`); +console.log(`Dynamic safe area (default voucher layout): ${dynamicSafeAreaValid ? "PASS" : "FAIL"}`); +console.log("Master artwork safe area: NOT AUTOMATICALLY VERIFIED"); +console.log(`Rotation: ${pages.every((page) => page.getRotation().angle === 0) ? "PASS" : "FAIL"}`); +console.log(`OutputIntent: ${metadata.outputIntent.valid ? "PASS" : "FAIL"} (${metadata.outputIntent.count})`); +console.log(`ICC profile: ${metadata.outputIntent.profileValid ? "PASS" : "FAIL"}${metadata.outputIntent.outputConditionIdentifier ? ` (${metadata.outputIntent.outputConditionIdentifier})` : ""}`); +for (const icc of iccObjects) { + console.log(`Embedded ICC ${icc.ref}: ${icc.profile?.valid ? "VALID" : "INVALID"}, sha256=${icc.sha256 ?? "unknown"}, decodedSize=${icc.size}, encodedSize=${icc.encodedSize}, N=${icc.channels}, signature=${icc.profile?.signatureValid ? "acsp" : "invalid"}, class=${icc.profile?.profileClass ?? "unknown"}, colorSpace=${icc.profile?.colorSpace ?? "unknown"}, PCS=${icc.profile?.pcs ?? "unknown"}, description=${icc.profile?.description ?? "unknown"}, version=${icc.profile?.version ?? "unknown"}, renderingIntent=${icc.profile?.renderingIntent ?? "unknown"}, tagCount=${icc.profile?.tagCount ?? "unknown"}, copyright=${icc.profile?.copyright ?? "unknown"}, manufacturer=${icc.profile?.manufacturer ?? "unknown"}, model=${icc.profile?.model ?? "unknown"}`); +} +console.log(`PDF/X declaration: ${metadata.xmp.claim ?? "missing"}`); +console.log(`PDF/X properties: ${metadata.xmp.pdfxidProperties.join(", ") || "none"}`); +console.log(`PDF/X conformance Info key: ${metadata.pdfXConformanceInfoPresent ? "FAIL" : "PASS"}`); +console.log(`PDF/X structural checks: ${pass ? "PASS" : "FAIL"}`); +console.log(`Fonts: ${fontDictionaries.length} dictionaries, ${embeddedFontStreams.length} embedded streams`); +console.log(`Images: ${images.length}`); +console.log(`Transparency: ExtGState=${extGStates.length}, soft masks=${softMasks.length}, non-Normal blend modes=${blendModes.length}, overprint dictionaries=${overprintObjects.length}`); +console.log(`Security: encryption=${pdf.isEncrypted ? "YES" : "NO"}, forms=${form ? "YES" : "NO"}, JavaScript objects=${javaScriptObjects.length}`); +console.log(`File size: ${bytes.length} bytes`); +console.log(`ICC streams: ${objects.filter(([, object]) => object instanceof PDFStream && object.dict.get(PDFName.of("N"))).length}`); +console.log(`PDF objects: ${objects.length}, streams: ${streams.length}`); +console.log(`RESULT: ${pass ? "PASS" : "FAIL"}`); + +if (!pass) process.exitCode = 1; diff --git a/scripts/prepare-ci-voucher-fixtures.ts b/scripts/prepare-ci-voucher-fixtures.ts index 5d9c9719..113812bc 100644 --- a/scripts/prepare-ci-voucher-fixtures.ts +++ b/scripts/prepare-ci-voucher-fixtures.ts @@ -1,5 +1,8 @@ import "dotenv/config"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + import { AdminRole } from "@/generated/prisma/client"; import { bootstrapVoucherTemplates } from "@/features/vouchers/lib/voucher-template-bootstrap"; import { hashPassword } from "@/lib/auth/password"; @@ -48,7 +51,11 @@ async function prepareCiVoucherFixtures() { }, }); - const result = await bootstrapVoucherTemplates(); + // CI používá legacy key classic-v1 kvůli existujícím fixture scénářům, ale + // musí testovat současný strict PDF/X master. Historický classic-v1.pdf je + // záměrně PDF 1.4 a nesmí se kvůli CI ani produkčnímu bootstrapu přepisovat. + const strictCiMaster = await readFile(path.join(process.cwd(), "src", "features", "vouchers", "bootstrap-assets", "classic-v2.pdf")); + const result = await bootstrapVoucherTemplates({ readMaster: async () => strictCiMaster }); console.info( `CI voucher fixtures připraveny; backfill=${result.backfilledVouchers}; remainingNulls=${result.remainingNulls}.`, ); diff --git a/scripts/prepare-voucher-classic-v2.mjs b/scripts/prepare-voucher-classic-v2.mjs new file mode 100644 index 00000000..471076eb --- /dev/null +++ b/scripts/prepare-voucher-classic-v2.mjs @@ -0,0 +1,46 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +import { PDFDocument, PDFName, PDFString } from "pdf-lib"; + +import { ensurePrintPdfMetadata, inspectIccProfile, inspectPdfPrepressMetadata } from "../src/features/vouchers/lib/voucher-pdf-prepress.ts"; + +const [sourcePath, profilePath, outputPath] = process.argv.slice(2); +if (!sourcePath || !profilePath || !outputPath) { + console.error("Použití: node --conditions=react-server --import tsx scripts/prepare-voucher-classic-v2.mjs source.pdf profile.icc output.pdf"); + process.exit(2); +} + +const sourceBytes = await readFile(sourcePath); +const profileBytes = await readFile(profilePath); +const profile = inspectIccProfile(profileBytes); +if (!profile.valid) throw new Error(`ICC profil neprošel strukturální kontrolou: ${profile.errors.join("; ")}`); + +const pdf = await PDFDocument.load(sourceBytes, { updateMetadata: false }); +const profileRef = pdf.context.register(pdf.context.stream(profileBytes, { N: 4 })); +const outputIntent = pdf.context.obj({ + Type: "OutputIntent", + S: "GTS_PDFX", + OutputConditionIdentifier: PDFString.of(profile.description ?? "Fujifilm Revoria ColorPoint uncoated"), + Info: PDFString.of(profile.description ?? "Fujifilm Revoria ColorPoint uncoated"), + DestOutputProfile: profileRef, +}); +pdf.catalog.set(PDFName.of("OutputIntents"), pdf.context.obj([pdf.context.register(outputIntent)])); +ensurePrintPdfMetadata(pdf); + +const outputBytes = Buffer.from(await pdf.save({ useObjectStreams: false })); +Buffer.from("%PDF-1.7").copy(outputBytes, 0); +const inspection = inspectPdfPrepressMetadata(await PDFDocument.load(outputBytes, { updateMetadata: false })); +if (!inspection.outputIntent.valid || !inspection.xmp.valid || inspection.pdfXConformanceInfoPresent) { + throw new Error("Nový classic-v2 master neprošel prepress metadata kontrolou."); +} + +await writeFile(outputPath, outputBytes); +console.log(JSON.stringify({ + outputPath, + pdfBytes: outputBytes.length, + pdfSha256: createHash("sha256").update(outputBytes).digest("hex"), + profileSha256: createHash("sha256").update(profileBytes).digest("hex"), + outputConditionIdentifier: profile.description, + pdfXVersion: inspection.xmp.claim, +}, null, 2)); diff --git a/src/features/admin/components/voucher-template-published-preview.tsx b/src/features/admin/components/voucher-template-published-preview.tsx index c67934e1..a734d914 100644 --- a/src/features/admin/components/voucher-template-published-preview.tsx +++ b/src/features/admin/components/voucher-template-published-preview.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Image from "next/image"; import type { VoucherTemplateLayoutV1 } from "@/features/vouchers/lib/voucher-template-layout"; @@ -29,11 +29,7 @@ const placeholderText: Record, string> = { export function VoucherTemplatePublishedPreview({ src, alt, layout }: { src?: string; alt: string; layout: VoucherTemplateLayoutV1 }) { const [mode, setMode] = useState("VALUE"); - const [previewQr, setPreviewQr] = useState(() => createVoucherTemplatePreviewQr()); - - useEffect(() => { - setPreviewQr(createVoucherTemplatePreviewQr(window.location.origin)); - }, []); + const [previewQr] = useState(() => createVoucherTemplatePreviewQr(typeof window === "undefined" ? undefined : window.location.origin)); const visibleAreas = getVisibleAreas(mode); return ( diff --git a/src/features/admin/lib/admin-voucher-stock-pdf-route.ts b/src/features/admin/lib/admin-voucher-stock-pdf-route.ts index 537788cc..944800e5 100644 --- a/src/features/admin/lib/admin-voucher-stock-pdf-route.ts +++ b/src/features/admin/lib/admin-voucher-stock-pdf-route.ts @@ -44,7 +44,8 @@ export function createAdminVoucherStockPdfRoute(dependencies: VoucherStockPdfRou })); const generatePdf = dependencies.generatePdf ?? (async (batch) => { if (!batch.templateId) throw new Error("Tisková série nemá přiřazenou šablonu."); - return generateResolvedVoucherBatchPrintPdf({ batchNumber: batch.batchNumber, items: batch.items }, await resolveVoucherTemplate(await requireVoucherTemplateById(batch.templateId))); + const template = await resolveVoucherTemplate(await requireVoucherTemplateById(batch.templateId)); + return generateResolvedVoucherBatchPrintPdf({ batchNumber: batch.batchNumber, items: batch.items }, template, { requirePrepress: template.validationPolicy === "STRICT_V1" }); }); return async function AdminVoucherStockPdfRoute( diff --git a/src/features/vouchers/bootstrap-assets/classic-v2.pdf b/src/features/vouchers/bootstrap-assets/classic-v2.pdf new file mode 100644 index 00000000..1b5f1d29 Binary files /dev/null and b/src/features/vouchers/bootstrap-assets/classic-v2.pdf differ diff --git a/src/features/vouchers/lib/voucher-pdf-core.ts b/src/features/vouchers/lib/voucher-pdf-core.ts index 833368d3..0c33a6dd 100644 --- a/src/features/vouchers/lib/voucher-pdf-core.ts +++ b/src/features/vouchers/lib/voucher-pdf-core.ts @@ -3,7 +3,7 @@ import path from "node:path"; import fontkit from "@pdf-lib/fontkit"; import { VoucherType } from "@/generated/prisma/browser"; -import { PDFDocument, cmyk, clip, endPath, popGraphicsState, pushGraphicsState, rectangle, type PDFFont, type PDFPage } from "pdf-lib"; +import { PDFDocument, PDFObjectCopier, PDFPage, cmyk, clip, endPath, popGraphicsState, pushGraphicsState, rectangle, type PDFFont } from "pdf-lib"; import QRCode from "qrcode"; import { formatVoucherValue } from "@/features/vouchers/lib/voucher-format"; @@ -15,11 +15,12 @@ import { requireVoucherTemplateById, resolveVoucherTemplate } from "@/features/v import { getVoucherQrRenderGeometry, VOUCHER_PRINT_GEOMETRY, VOUCHER_QR_QUIET_ZONE_MODULES } from "@/features/vouchers/lib/voucher-template-layout"; import { fitVoucherTextToArea, VOUCHER_TEXT_HORIZONTAL_INSET_MM } from "@/features/vouchers/lib/voucher-text-fit"; import { getPersistedVoucherRenderMode } from "@/features/vouchers/lib/voucher-render-policy"; +import { ensurePrintPdfMetadata } from "@/features/vouchers/lib/voucher-pdf-prepress"; type VoucherPdfData = NonNullable>>; type VoucherStockBatchPdfItem = Pick; type VoucherTemplateRenderDefinition = Pick; -type VoucherRenderOptions = { failOnTextOverflow?: boolean; renderMode?: "STRICT" | "HISTORICAL" }; +type VoucherRenderOptions = { failOnTextOverflow?: boolean; renderMode?: "STRICT" | "HISTORICAL"; requirePrepress?: boolean }; export const MM_TO_PT = 72 / 25.4; const MASTER_PAGE_SIZE_TOLERANCE_MM = 0.35; @@ -103,7 +104,8 @@ export function buildVoucherPdfOverlayData( export async function generatePersistedVoucherPrintPdf(voucher: VoucherPdfData) { if (!voucher.templateId) throw new VoucherTemplateError(voucher.templateKey, { message: "Voucher nemá přiřazenou šablonu." }); - return generateResolvedVoucherPrintPdf(voucher, await resolveVoucherTemplate(await requireVoucherTemplateById(voucher.templateId)), { renderMode: getPersistedVoucherRenderMode(voucher) }); + const renderMode = getPersistedVoucherRenderMode(voucher); + return generateResolvedVoucherPrintPdf(voucher, await resolveVoucherTemplate(await requireVoucherTemplateById(voucher.templateId)), { renderMode, requirePrepress: renderMode === "STRICT" }); } export async function generatePersistedVoucherDigitalPdf(voucher: VoucherPdfData) { @@ -114,7 +116,7 @@ export async function generatePersistedVoucherDigitalPdf(voucher: VoucherPdfData /** Pure runtime renderer: receives an already resolved DB template, never Prisma or filesystem paths. */ export async function generateResolvedVoucherPrintPdf(voucher: VoucherPdfData, template: ResolvedVoucherTemplate, options: VoucherRenderOptions = {}) { const masterBytes = template.masterBytes; - const pdf = await PDFDocument.load(masterBytes); + const pdf = await PDFDocument.load(masterBytes, { updateMetadata: false }); validateMasterPageSize(pdf, template); const page = pdf.getPage(0); @@ -144,8 +146,11 @@ export async function generateResolvedVoucherPrintPdf(voucher: VoucherPdfData, t drawVoucherOverlay(page, voucher, template, regularFont, boldFont, qrCode, options); setPrintPageBoxes(page, template); + ensurePrintPdfMetadata(pdf); - return pdf.save(); + const bytes = await pdf.save(); + if (options.requirePrepress) await assertFinalPrintPreflight(bytes, template, 1); + return bytes; } export function buildVoucherStockPdfFilename(batchNumber: string) { @@ -162,18 +167,40 @@ export async function generateResolvedVoucherBatchPrintPdf( const [regularLatinBytes, regularLatinExtBytes, boldLatinBytes, boldLatinExtBytes] = await Promise.all([ readFile(fontRegularLatinPath), readFile(fontRegularLatinExtPath), readFile(fontBoldLatinPath), readFile(fontBoldLatinExtPath), ]); - const masterPdf = await PDFDocument.load(template.masterBytes); + const masterPdf = await PDFDocument.load(template.masterBytes, { updateMetadata: false }); validateMasterPageSize(masterPdf, template); - const pdf = await PDFDocument.create(); pdf.registerFontkit(fontkit); + const pdf = await PDFDocument.create({ updateMetadata: false }); pdf.registerFontkit(fontkit); + // One copier is deliberately reused for the whole batch. pdf-lib's public + // copyPages() creates a fresh copier per call, which duplicates immutable + // master images, fonts, and other resources for every stock page. + const copier = PDFObjectCopier.for(masterPdf.context, pdf.context); + const masterPage = masterPdf.getPage(0); const regularFont = createFontPair(await pdf.embedFont(regularLatinBytes, { subset: true }), await pdf.embedFont(regularLatinExtBytes, { subset: true })); const boldFont = createFontPair(await pdf.embedFont(boldLatinBytes, { subset: true }), await pdf.embedFont(boldLatinExtBytes, { subset: true })); for (const item of batch.items) { - const [page] = await pdf.copyPages(masterPdf, [0]); pdf.addPage(page); + const copiedPageNode = copier.copy(masterPage.node); + const copiedPage = PDFPage.of(copiedPageNode, pdf.context.register(copiedPageNode), pdf); + pdf.addPage(copiedPage); + const page = copiedPage; drawVoucherCodeAndQrOverlay(page, item.code, template, regularFont, boldFont, QRCode.create(buildVoucherVerificationUrl(item.code), { errorCorrectionLevel: "M" }), options); setPrintPageBoxes(page, template); } + ensurePrintPdfMetadata(pdf, masterPdf, copier); pdf.setTitle(`Předtištěné vouchery ${batch.batchNumber}`); pdf.setAuthor("PP Studio"); pdf.setSubject(`Tisková série voucherů PP Studio · ${template.label}`); pdf.setCreator("PP Studio administrace"); pdf.setProducer("PP Studio administrace"); - return pdf.save(); + const bytes = await pdf.save(); + if (options.requirePrepress) await assertFinalPrintPreflight(bytes, template, batch.items.length); + return bytes; +} + +async function assertFinalPrintPreflight(bytes: Uint8Array, template: VoucherTemplateRenderDefinition, expectedPageCount: number) { + const { preflightFinalVoucherPrint } = await import("./voucher-template-preflight"); + const result = await preflightFinalVoucherPrint(bytes, expectedPageCount); + if (result.errors.length > 0) { + throw new VoucherTemplateError(template.key, { + code: "invalid_print_pdf", + message: "Finální tiskové PDF neprošlo interním preflightem.", + }); + } } function validateMasterPageSize(pdf: PDFDocument, template: VoucherTemplateRenderDefinition) { diff --git a/src/features/vouchers/lib/voucher-pdf-prepress.test.ts b/src/features/vouchers/lib/voucher-pdf-prepress.test.ts new file mode 100644 index 00000000..d5323238 --- /dev/null +++ b/src/features/vouchers/lib/voucher-pdf-prepress.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PDFDocument, PDFName, PDFString } from "pdf-lib"; + +import { ensurePrintPdfMetadata, inspectIccProfile, inspectPdfPrepressMetadata } from "./voucher-pdf-prepress"; + +function makeTestCmykIccProfile() { + const bytes = Buffer.alloc(128); + bytes.writeUInt32BE(bytes.length, 0); + bytes[8] = 0x43; + bytes.write("prtr", 12, "ascii"); + bytes.write("CMYK", 16, "ascii"); + bytes.write("XYZ ", 20, "ascii"); + bytes.write("acsp", 36, "ascii"); + return bytes; +} + +test("ICC random bytes jsou neplatné", () => { + const result = inspectIccProfile(Buffer.from("test ICC profile")); + assert.equal(result.valid, false); + assert.match(result.errors.join(" "), /128|acsp|CMYK/); +}); + +test("ICC příliš krátký stream je neplatný", () => { + const result = inspectIccProfile(Buffer.alloc(127)); + assert.equal(result.valid, false); + assert.match(result.errors.join(" "), /128/); +}); + +test("ICC bez acsp signature je neplatný", () => { + const bytes = makeTestCmykIccProfile(); + bytes.write("xxxx", 36, "ascii"); + const result = inspectIccProfile(bytes); + assert.equal(result.valid, false); + assert.equal(result.signatureValid, false); +}); + +test("ICC deklarovaná velikost nesmí přesahovat data", () => { + const bytes = makeTestCmykIccProfile(); + bytes.writeUInt32BE(bytes.length + 1, 0); + const result = inspectIccProfile(bytes); + assert.equal(result.valid, false); + assert.match(result.errors.join(" "), /přesahuje/); +}); + +test("ICC RGB profil je pro PP Studio tiskový contract neplatný", () => { + const bytes = makeTestCmykIccProfile(); + bytes.write("RGB ", 16, "ascii"); + const result = inspectIccProfile(bytes); + assert.equal(result.valid, false); + assert.equal(result.colorSpace, "RGB "); + assert.match(result.errors.join(" "), /CMYK/); +}); + +test("ICC TEST ONLY CMYK profil se strukturálně ověří", () => { + const result = inspectIccProfile(makeTestCmykIccProfile()); + assert.equal(result.valid, true); + assert.equal(result.declaredSize, 128); + assert.equal(result.signatureValid, true); + assert.equal(result.profileClass, "prtr"); + assert.equal(result.colorSpace, "CMYK"); + assert.equal(result.pcs, "XYZ "); +}); + +test("CMYK ICC profil s OutputIntent N != 4 musí být odmítnut", async () => { + const pdf = await PDFDocument.create(); + pdf.addPage([100, 100]); + const profileRef = pdf.context.register(pdf.context.stream(makeTestCmykIccProfile(), { N: 3 })); + const intent = pdf.context.obj({ + Type: "OutputIntent", + S: "GTS_PDFX", + OutputConditionIdentifier: PDFString.of("TEST ONLY CMYK"), + DestOutputProfile: profileRef, + }); + pdf.catalog.set(PDFName.of("OutputIntents"), pdf.context.obj([pdf.context.register(intent)])); + const inspected = inspectPdfPrepressMetadata(await PDFDocument.load(await pdf.save())); + assert.equal(inspected.outputIntent.profilePresent, true); + assert.equal(inspected.outputIntent.profileValid, false); + assert.equal(inspected.outputIntent.valid, false); +}); + +async function inspectXmpClaim(claim?: string, properties = ["GTS_PDFXVersion"]) { + const pdf = await PDFDocument.create({ updateMetadata: false }); + pdf.addPage([100, 100]); + const propertyMarkup = properties.map((property) => `${property === "GTS_PDFXVersion" ? claim ?? "" : "PDF/X-4"}`).join(""); + const xmp = `${propertyMarkup}`; + const metadata = pdf.context.stream(Buffer.from(xmp, "utf8"), { Type: "Metadata", Subtype: "XML" }); + pdf.catalog.set(PDFName.of("Metadata"), pdf.context.register(metadata)); + return inspectPdfPrepressMetadata(await PDFDocument.load(await pdf.save(), { updateMetadata: false })); +} + +test("PDF/X-4 XMP identita přijímá přesně PDF/X-4", async () => { + const inspected = await inspectXmpClaim("PDF/X-4"); + assert.equal(inspected.xmp.claim, "PDF/X-4"); + assert.equal(inspected.xmp.valid, true); + assert.deepEqual(inspected.xmp.pdfxidProperties, ["GTS_PDFXVersion"]); + assert.equal(inspected.xmp.hasConformanceProperty, false); +}); + +for (const claim of ["PDF/X-4:2010", "PDF/X-4p", "PDF/X-4-foo"]) { + test(`PDF/X-4 XMP identita odmítá ${claim}`, async () => { + const inspected = await inspectXmpClaim(claim); + assert.equal(inspected.xmp.valid, false); + }); +} + +test("PDF/X-4 XMP identita odmítá chybějící GTS_PDFXVersion", async () => { + const inspected = await inspectXmpClaim(undefined, []); + assert.equal(inspected.xmp.claim, null); + assert.equal(inspected.xmp.valid, false); +}); + +test("PDF/X-4 XMP nepřidává conformance property ani Info key", async () => { + const pdf = await PDFDocument.create({ updateMetadata: false }); + pdf.addPage([100, 100]); + const profileRef = pdf.context.register(pdf.context.stream(makeTestCmykIccProfile(), { N: 4 })); + const intent = pdf.context.obj({ Type: "OutputIntent", S: "GTS_PDFX", OutputConditionIdentifier: PDFString.of("TEST ONLY CMYK"), DestOutputProfile: profileRef }); + pdf.catalog.set(PDFName.of("OutputIntents"), pdf.context.obj([pdf.context.register(intent)])); + ensurePrintPdfMetadata(pdf); + const inspected = inspectPdfPrepressMetadata(await PDFDocument.load(await pdf.save(), { updateMetadata: false })); + assert.deepEqual(inspected.xmp.pdfxidProperties, ["GTS_PDFXVersion"]); + assert.equal(inspected.xmp.hasConformanceProperty, false); + assert.equal(inspected.pdfXConformanceInfoPresent, false); +}); diff --git a/src/features/vouchers/lib/voucher-pdf-prepress.ts b/src/features/vouchers/lib/voucher-pdf-prepress.ts new file mode 100644 index 00000000..fce0defb --- /dev/null +++ b/src/features/vouchers/lib/voucher-pdf-prepress.ts @@ -0,0 +1,310 @@ +import { + PDFArray, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFNumber, + PDFObjectCopier, + PDFRawStream, + PDFStream, + PDFString, + decodePDFRawStream, +} from "pdf-lib"; + +const OUTPUT_INTENTS = PDFName.of("OutputIntents"); +const OUTPUT_INTENT = PDFName.of("OutputIntent"); +const METADATA = PDFName.of("Metadata"); +const TYPE = PDFName.of("Type"); +const SUBTYPE = PDFName.of("Subtype"); +const XML = PDFName.of("XML"); +const PDF_X4_VERSION = "PDF/X-4"; + +export type PdfOutputIntentInspection = { + present: boolean; + valid: boolean; + count: number; + profilePresent: boolean; + profileValid: boolean; + outputConditionIdentifier: string | null; + info: string | null; + iccProfile: IccProfileInspection | null; +}; + +export type IccProfileInspection = { + valid: boolean; + declaredSize: number | null; + signatureValid: boolean; + profileClass: string | null; + colorSpace: string | null; + pcs: string | null; + version: string | null; + description: string | null; + copyright: string | null; + manufacturer: string | null; + model: string | null; + renderingIntent: number | null; + tagCount: number | null; + errors: string[]; +}; + +export type PdfXmpInspection = { + present: boolean; + valid: boolean; + claim: string | null; + pdfxidProperties: string[]; + hasConformanceProperty: boolean; + raw: string | null; +}; + +export type PdfPrepressMetadataInspection = { + outputIntent: PdfOutputIntentInspection; + xmp: PdfXmpInspection; + pdfXConformanceInfoPresent: boolean; +}; + +function getCatalogArray(pdf: PDFDocument) { + return pdf.catalog.lookupMaybe(OUTPUT_INTENTS, PDFArray); +} + +function getStringValue(value: PDFString | PDFHexString | undefined) { + return value?.decodeText() ?? null; +} + +function decodeStream(stream: PDFStream) { + try { + if (stream instanceof PDFRawStream) return decodePDFRawStream(stream).decode(); + return stream.getContents(); + } catch { + return null; + } +} + +function getStreamType(stream: PDFStream, key: PDFName) { + return stream.dict.lookupMaybe(key, PDFName); +} + +function readSignature(bytes: Uint8Array, offset: number) { + return bytes.length >= offset + 4 ? Buffer.from(bytes.subarray(offset, offset + 4)).toString("ascii") : null; +} + +function readUInt32(bytes: Uint8Array, offset: number) { + return bytes.length >= offset + 4 + ? ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0 + : null; +} + +function readVersion(bytes: Uint8Array) { + return bytes.length >= 12 ? `${bytes[8]}.${bytes[9] >> 4}.${bytes[9] & 0x0f}` : null; +} + +function readTag(bytes: Uint8Array, tagSignature: string) { + const tagCount = readUInt32(bytes, 128); + if (tagCount === null || bytes.length < 132 + tagCount * 12) return null; + for (let index = 0; index < tagCount; index += 1) { + const offset = 132 + index * 12; + if (readSignature(bytes, offset) !== tagSignature) continue; + const tagOffset = readUInt32(bytes, offset + 4); + const tagSize = readUInt32(bytes, offset + 8); + if (tagOffset === null || tagSize === null || tagOffset + tagSize > bytes.length || tagSize < 12) return null; + return { type: readSignature(bytes, tagOffset), offset: tagOffset, size: tagSize }; + } + return null; +} + +function readTagText(bytes: Uint8Array, tagSignature: string) { + const tag = readTag(bytes, tagSignature); + if (!tag) return null; + if (tag.type === "text") { + return Buffer.from(bytes.subarray(tag.offset + 8, tag.offset + tag.size)).toString("ascii").replace(/\0+$/, "").trim() || null; + } + if (tag.type === "desc") { + const asciiLength = readUInt32(bytes, tag.offset + 8); + if (asciiLength === null || asciiLength < 1 || tag.offset + 12 + asciiLength > bytes.length) return null; + return Buffer.from(bytes.subarray(tag.offset + 12, tag.offset + 12 + asciiLength - 1)).toString("ascii"); + } + return null; +} + +/** Structural ICC sanity check for the CMYK print OutputIntent contract. */ +export function inspectIccProfile(bytes: Uint8Array): IccProfileInspection { + const errors: string[] = []; + const declaredSize = readUInt32(bytes, 0); + const signature = readSignature(bytes, 36); + const profileClass = readSignature(bytes, 12); + const colorSpace = readSignature(bytes, 16); + const pcs = readSignature(bytes, 20); + const version = readVersion(bytes); + const tagCount = readUInt32(bytes, 128); + const renderingIntent = readUInt32(bytes, 64); + + if (bytes.length < 128) errors.push("ICC profil je kratší než 128 bytes."); + if (declaredSize === null || declaredSize < 128) errors.push("ICC header nemá platnou deklarovanou velikost."); + else if (declaredSize > bytes.length) errors.push("Deklarovaná velikost ICC profilu přesahuje dostupná data."); + if (signature !== "acsp") errors.push("ICC header nemá signature acsp."); + if (!profileClass || !["scnr", "mntr", "prtr", "link", "spac", "abst", "nmcl"].includes(profileClass)) errors.push("ICC header nemá rozpoznatelnou device/profile class."); + if (!colorSpace || !/^(GRAY|RGB |CMYK|CIE |[2-9A-F]CLR)$/.test(colorSpace)) errors.push("ICC header nemá rozpoznatelný data color space."); + if (!pcs || !["XYZ ", "Lab "].includes(pcs)) errors.push("ICC header nemá rozpoznatelný PCS."); + if (colorSpace !== "CMYK") errors.push("ICC profil není CMYK profil."); + + return { + valid: errors.length === 0, + declaredSize, + signatureValid: signature === "acsp", + profileClass, + colorSpace, + pcs, + version, + description: readTagText(bytes, "desc"), + copyright: readTagText(bytes, "cprt"), + manufacturer: readTagText(bytes, "dmnd"), + model: readTagText(bytes, "dmdd"), + renderingIntent, + tagCount, + errors, + }; +} + +/** Reads document-level prepress objects through pdf-lib's parsed object graph. */ +export function inspectPdfPrepressMetadata(pdf: PDFDocument): PdfPrepressMetadataInspection { + const outputIntents = getCatalogArray(pdf); + let validIntent: PDFDict | undefined; + let profilePresent = false; + let profileValid = false; + let outputConditionIdentifier: string | null = null; + let info: string | null = null; + let iccProfile: IccProfileInspection | null = null; + + if (outputIntents) { + for (let index = 0; index < outputIntents.size(); index += 1) { + const intent = pdf.context.lookupMaybe(outputIntents.get(index), PDFDict); + if (!intent) continue; + + const type = intent.lookupMaybe(TYPE, PDFName)?.asString(); + const subtype = intent.lookupMaybe(PDFName.of("S"), PDFName)?.asString(); + const profile = pdf.context.lookupMaybe(intent.get(PDFName.of("DestOutputProfile")), PDFStream); + const profileN = profile?.dict.lookupMaybe(PDFName.of("N"), PDFNumber)?.asNumber(); + const profileBytes = profile ? decodeStream(profile) : null; + const hasProfile = Boolean(profile); + const inspectedProfile = profileBytes ? inspectIccProfile(profileBytes) : null; + const hasValidProfile = Boolean(profile && profileN === 4 && inspectedProfile?.valid); + + if (type === OUTPUT_INTENT.asString() && subtype === "/GTS_PDFX" && hasProfile && hasValidProfile && !validIntent) { + validIntent = intent; + profilePresent = true; + profileValid = true; + iccProfile = inspectedProfile; + outputConditionIdentifier = getStringValue(intent.lookupMaybe(PDFName.of("OutputConditionIdentifier"), PDFString, PDFHexString)); + info = getStringValue(intent.lookupMaybe(PDFName.of("Info"), PDFString, PDFHexString)); + } else if (hasProfile) { + profilePresent = true; + profileValid ||= hasValidProfile; + if (!iccProfile && inspectedProfile) iccProfile = inspectedProfile; + } + } + } + + const outputIntent: PdfOutputIntentInspection = { + present: Boolean(outputIntents && outputIntents.size() > 0), + valid: Boolean(validIntent), + count: outputIntents?.size() ?? 0, + profilePresent, + profileValid, + outputConditionIdentifier, + info, + iccProfile, + }; + + const metadata = pdf.context.lookupMaybe(pdf.catalog.get(METADATA), PDFStream); + const metadataType = metadata ? getStreamType(metadata, TYPE)?.asString() : undefined; + const metadataSubtype = metadata ? getStreamType(metadata, SUBTYPE)?.asString() : undefined; + const metadataBytes = metadata ? decodeStream(metadata) : null; + const raw = metadataBytes ? Buffer.from(metadataBytes).toString("utf8") : null; + const claim = raw + ? /<[^>]*GTS_PDFXVersion[^>]*>([^<]+)\s]+)[^>]*>/gi), (match) => match[1]) : []; + const hasConformanceProperty = Boolean(raw && /GTS_PDFXConformance/i.test(raw)); + const pdfXConformanceInfoPresent = (() => { + const infoDictionary = pdf.context.trailerInfo.Info + ? pdf.context.lookupMaybe(pdf.context.trailerInfo.Info, PDFDict) + : undefined; + return Boolean(infoDictionary && Array.from(infoDictionary.keys()).some((key) => key.asString() === "/GTS_PDFXConformance")); + })(); + + const xmp: PdfXmpInspection = { + present: Boolean(metadata && metadataType === "/Metadata" && metadataSubtype === XML.asString() && raw), + valid: Boolean( + metadata + && metadataType === "/Metadata" + && metadataSubtype === XML.asString() + && claim === PDF_X4_VERSION + && pdfxidProperties.length === 1 + && pdfxidProperties[0] === "GTS_PDFXVersion" + && !hasConformanceProperty, + ), + claim, + pdfxidProperties, + hasConformanceProperty, + raw, + }; + + return { outputIntent, xmp, pdfXConformanceInfoPresent }; +} + +function buildPdfX4Xmp() { + return ` + + + + ${PDF_X4_VERSION} + + + +`; +} + +function setPdfX4Metadata(pdf: PDFDocument) { + const stream = pdf.context.stream(Buffer.from(buildPdfX4Xmp(), "utf8"), { + Type: "Metadata", + Subtype: "XML", + }); + pdf.catalog.set(METADATA, pdf.context.register(stream)); +} + +/** + * Copies catalog OutputIntents without copying page resources a second time. + * The optional copier must be shared with page copying for STOCK exports. + */ +export function applyPdfOutputIntent( + source: PDFDocument, + destination: PDFDocument, + copier = source === destination ? undefined : PDFObjectCopier.for(source.context, destination.context), +) { + const outputIntents = getCatalogArray(source); + if (!outputIntents) return inspectPdfPrepressMetadata(destination).outputIntent; + + const copied = source === destination ? outputIntents : copier!.copy(outputIntents); + destination.catalog.set(OUTPUT_INTENTS, copied); + return inspectPdfPrepressMetadata(destination).outputIntent; +} + +/** + * Ensures that a print document carries the master's real OutputIntent and a + * matching PDF/X-4 XMP packet. It never invents an ICC profile when the master + * does not provide a structurally usable one. + */ +export function ensurePrintPdfMetadata( + document: PDFDocument, + master: PDFDocument = document, + copier?: PDFObjectCopier, +) { + const sourceInspection = inspectPdfPrepressMetadata(master); + applyPdfOutputIntent(master, document, copier); + if (sourceInspection.outputIntent.valid) setPdfX4Metadata(document); + return inspectPdfPrepressMetadata(document); +} + +export const PDF_X4_DECLARATION = PDF_X4_VERSION; diff --git a/src/features/vouchers/lib/voucher-pdf.test.ts b/src/features/vouchers/lib/voucher-pdf.test.ts index 20c23b27..c01ceff1 100644 --- a/src/features/vouchers/lib/voucher-pdf.test.ts +++ b/src/features/vouchers/lib/voucher-pdf.test.ts @@ -5,9 +5,10 @@ import { inflateSync } from "node:zlib"; import test from "node:test"; import { VoucherStatus, VoucherType } from "@/generated/prisma/browser"; -import { PDFDocument, rgb } from "pdf-lib"; +import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRawStream, PDFStream, PDFString, decodePDFRawStream, rgb } from "pdf-lib"; import { defaultVoucherTemplateLayout } from "./voucher-template-defaults"; import { voucherTemplateLayoutSchema, voucherTemplateStoredLayoutSchema } from "./voucher-template-layout"; +import { ensurePrintPdfMetadata, inspectIccProfile } from "./voucher-pdf-prepress"; import { createVoucherTemplateRegistry, @@ -26,12 +27,51 @@ process.env.ADMIN_STAFF_EMAIL ??= "staff@example.com"; process.env.ADMIN_STAFF_PASSWORD ??= "change-me-staff"; process.env.EMAIL_DELIVERY_MODE ??= "log"; +// TEST ONLY: minimal deterministic ICC v4 CMYK header for structural tests. +function makeTestCmykIccProfile() { + const bytes = Buffer.alloc(128); + bytes.writeUInt32BE(bytes.length, 0); + bytes[8] = 0x43; + bytes.write("prtr", 12, "ascii"); + bytes.write("CMYK", 16, "ascii"); + bytes.write("XYZ ", 20, "ascii"); + bytes.write("acsp", 36, "ascii"); + return bytes; +} + +test("ICC profile version čte major z byte 8 a minor/bugfix z byte 9", () => { + for (const [header, expected] of [ + [[0x02, 0x40, 0x00, 0x00], "2.4.0"], + [[0x04, 0x20, 0x00, 0x00], "4.2.0"], + [[0x04, 0x30, 0x00, 0x00], "4.3.0"], + ] as const) { + const profile = makeTestCmykIccProfile(); + profile.set(header, 8); + assert.equal(inspectIccProfile(profile).version, expected); + } +}); + +async function makeStrictMaster(masterBytes: Buffer) { + const pdf = await PDFDocument.load(masterBytes, { updateMetadata: false }); + const profileRef = pdf.context.register(pdf.context.stream(makeTestCmykIccProfile(), { N: 4 })); + const outputIntent = pdf.context.obj({ + Type: "OutputIntent", + S: "GTS_PDFX", + OutputConditionIdentifier: PDFString.of("sRGB IEC61966-2.1 (test fixture)"), + Info: PDFString.of("Test fixture ICC profile"), + DestOutputProfile: profileRef, + }); + pdf.catalog.set(PDFName.of("OutputIntents"), pdf.context.obj([pdf.context.register(outputIntent)])); + ensurePrintPdfMetadata(pdf); + return Buffer.from(await pdf.save()); +} + test("classic-v1 master existuje ve správné cestě", async () => { await access("src/features/vouchers/bootstrap-assets/classic-v1.pdf"); }); test("výchozí VALUE layout projde strict schematem a finálním publish preflightem", async () => { - const masterBytes = await readFile("src/features/vouchers/bootstrap-assets/classic-v1.pdf"); + const masterBytes = await makeStrictMaster(await readFile("src/features/vouchers/bootstrap-assets/classic-v1.pdf")); const { preflightVoucherTemplateForPublish } = await import("./voucher-template-publish-preflight"); const layout = voucherTemplateLayoutSchema.parse(defaultVoucherTemplateLayout); const template = { @@ -47,8 +87,48 @@ test("výchozí VALUE layout projde strict schematem a finálním publish prefli assert.match(invalid.errors.join(" "), /Řádkování/); }); -test("publish preflight odmítne VALUE oblast, která zvládne 1 500 Kč, ale ne maximum", async () => { +test("publish preflight odmítne strict master bez OutputIntentu", async () => { + const masterBytes = await readFile("src/features/vouchers/bootstrap-assets/classic-v1.pdf"); + const { preflightVoucherTemplateForPublish } = await import("./voucher-template-publish-preflight"); + const template = { + id: "missing-prepress", + key: "missing-prepress-v1", + label: "Bez prepress metadat", + status: "DRAFT" as const, + allowedTypes: [VoucherType.VALUE], + layout: voucherTemplateLayoutSchema.parse(defaultVoucherTemplateLayout), + masterSha256: createHash("sha256").update(masterBytes).digest("hex"), + masterBytes, + }; + + const result = await preflightVoucherTemplateForPublish({ ...template, allowedTypes: [...template.allowedTypes] }); + + assert.equal(result.ok, false); + assert.match(result.errors.join(" "), /OutputIntent/); +}); + +test("strict runtime PRINT nevrátí PDF bez prepress kontraktu", async () => { + const { generateResolvedVoucherPrintPdf } = await import("./voucher-pdf-core"); const masterBytes = await readFile("src/features/vouchers/bootstrap-assets/classic-v1.pdf"); + const template = { + id: "strict-runtime-invalid", + key: "strict-runtime-invalid-v1", + label: "Neplatný runtime master", + status: "PUBLISHED" as const, + allowedTypes: [VoucherType.VALUE], + layout: defaultVoucherTemplateLayout, + masterSha256: "test", + masterBytes, + }; + + await assert.rejects( + () => generateResolvedVoucherPrintPdf(buildVoucherFixture(), template, { requirePrepress: true }), + (error: unknown) => error instanceof VoucherTemplateError && error.code === "invalid_print_pdf", + ); +}); + +test("publish preflight odmítne VALUE oblast, která zvládne 1 500 Kč, ale ne maximum", async () => { + const masterBytes = await makeStrictMaster(await readFile("src/features/vouchers/bootstrap-assets/classic-v1.pdf")); const { preflightVoucherTemplateForPublish } = await import("./voucher-template-publish-preflight"); const layout = voucherTemplateLayoutSchema.parse({ ...defaultVoucherTemplateLayout, @@ -203,6 +283,7 @@ test("strict render odmítne layout, který by dynamický text musel oříznout" const classic = requireVoucherTemplate("classic-v1"); const overflowTemplate = { ...classic, + allowedTypes: [...classic.allowedTypes], key: "overflow-v1", layout: { ...classic.layout, @@ -378,7 +459,7 @@ test("tiskové PDF zachová rám masteru na všech čtyřech hranách QR oblasti color: rgb(0.8, 0.8, 0.8), borderColor: rgb(1, 0, 0), borderWidth: mm(0.5), }); const pdf = await generateResolvedVoucherPrintPdf(buildVoucherFixture(), { - ...requireVoucherTemplate("classic-v1"), layout: defaultVoucherTemplateLayout, + ...requireVoucherTemplate("classic-v1"), id: "qr-frame-test", status: "PUBLISHED", masterSha256: "test", allowedTypes: [...requireVoucherTemplate("classic-v1").allowedTypes], layout: defaultVoucherTemplateLayout, masterBytes: Buffer.from(await master.save()), } as Parameters[1]); const { data, info } = await sharp(await renderVoucherTemplatePreview(Buffer.from(pdf))).removeAlpha().raw().toBuffer({ resolveWithObject: true }); @@ -415,6 +496,105 @@ test("předtištěná stránka vykreslí pouze kód a QR a batch PDF má jednu s assert.notDeepEqual(getPageOverlayContent(batchPdf.getPage(0)), getPageOverlayContent(batchPdf.getPage(1))); }); +test("strict PRINT a STOCK zachovají OutputIntent, XMP a sdílený ICC profil", async () => { + const { generateResolvedVoucherBatchPrintPdf, generateResolvedVoucherPrintPdf, mm } = await import("./voucher-pdf-core"); + const { preflightFinalVoucherPrint } = await import("./voucher-template-preflight"); + const masterBytes = await makeStrictMaster(await readFile("src/features/vouchers/bootstrap-assets/classic-v1.pdf")); + const template = { + ...requireVoucherTemplate("classic-v1"), + id: "strict-prepress-test", + status: "PUBLISHED", + masterSha256: "test", + allowedTypes: [...requireVoucherTemplate("classic-v1").allowedTypes], + layout: defaultVoucherTemplateLayout, + masterBytes, + } as unknown as Parameters[1]; + const print = await generateResolvedVoucherPrintPdf(buildVoucherFixture(), template); + const printPreflight = await preflightFinalVoucherPrint(print, 1); + assert.deepEqual(printPreflight.errors, []); + assert.equal(printPreflight.pdfVersion, "1.7"); + assert.equal(printPreflight.pdfXVerification, "STRUCTURALLY_VALIDATED"); + assert.equal(printPreflight.outputConditionIdentifier, "sRGB IEC61966-2.1 (test fixture)"); + + const stock = await generateResolvedVoucherBatchPrintPdf({ + batchNumber: "TEST-PREPRESS", + items: [{ code: "PP-PRE-001" }, { code: "PP-PRE-002" }, { code: "PP-PRE-003" }], + }, template); + const stockPdf = await PDFDocument.load(stock, { updateMetadata: false }); + const stockPreflight = await preflightFinalVoucherPrint(stock, 3); + assert.deepEqual(stockPreflight.errors, []); + assert.equal(stockPreflight.pdfVersion, "1.7"); + assert.equal(stockPreflight.pdfXVerification, "STRUCTURALLY_VALIDATED"); + assert.equal(stockPreflight.pageCount, 3); + for (const page of stockPdf.getPages()) { + assert.deepEqual(roundBox(page.getMediaBox()), roundBox({ x: 0, y: 0, width: mm(216), height: mm(105) })); + assert.deepEqual(roundBox(page.getCropBox()), roundBox({ x: 0, y: 0, width: mm(216), height: mm(105) })); + assert.deepEqual(roundBox(page.getBleedBox()), roundBox({ x: 0, y: 0, width: mm(216), height: mm(105) })); + assert.deepEqual(roundBox(page.getTrimBox()), roundBox({ x: mm(3), y: mm(3), width: mm(210), height: mm(99) })); + } + assert.notDeepEqual(getPageOverlayContent(stockPdf.getPage(0)), getPageOverlayContent(stockPdf.getPage(1))); + const iccStreams = stockPdf.context.enumerateIndirectObjects().filter(([, object]) => object instanceof PDFStream && object.dict.get(PDFName.of("N"))); + assert.ok(iccStreams.length <= 2, `ICC streamů je ${iccStreams.length}, očekáváno nejvýše 2 sdílené profily.`); +}); + +test("classic-v2 ColorPoint master projde reálným PRINT/STOCK rendererem", async () => { + const { generateResolvedVoucherBatchPrintPdf, generateResolvedVoucherPrintPdf } = await import("./voucher-pdf-core"); + const { preflightFinalVoucherPrint, preflightVoucherTemplateMaster } = await import("./voucher-template-preflight"); + const masterBytes = await readFile("src/features/vouchers/bootstrap-assets/classic-v2.pdf"); + const classic = requireVoucherTemplate("classic-v1"); + const template = { + ...classic, + allowedTypes: [...classic.allowedTypes], + id: "classic-v2", + key: "classic-v2", + status: "PUBLISHED" as const, + label: "Klasický v2 · ColorPoint uncoated", + masterBytes, + masterSha256: "a4d4e85b79f1c3ea062e4b568fac438a603b8c68e46d7813ead336e13c30cceb", + } as unknown as Parameters[1]; + const masterPreflight = await preflightVoucherTemplateMaster(masterBytes); + assert.deepEqual(masterPreflight.errors, []); + assert.equal(masterPreflight.pdfVersion, "1.7"); + assert.equal(masterPreflight.pdfXClaim, "PDF/X-4"); + assert.equal(masterPreflight.outputConditionIdentifier, "FujiFilm Revoria Uncoated (Fogra 52)"); + const masterPdf = await PDFDocument.load(masterBytes, { updateMetadata: false }); + const outputIntents = masterPdf.catalog.lookup(PDFName.of("OutputIntents"), PDFArray); + const intentDictionary = masterPdf.context.lookup(outputIntents.get(0), PDFDict); + assert.equal(intentDictionary.lookup(PDFName.of("Type"), PDFName).asString(), "/OutputIntent"); + const profileStream = masterPdf.context.lookup(intentDictionary.get(PDFName.of("DestOutputProfile")), PDFStream); + assert.equal(createHash("sha256").update(decodePDFRawStream(profileStream as PDFRawStream).decode()).digest("hex"), "f05e0b3e453b5efb77714375df096abaa6d66e8cbcb96691bfa9260f930207ec"); + + const trim = classic.layout.trim; + const dynamicAreas = [classic.layout.valueArea, classic.layout.serviceArea, classic.layout.validityArea, classic.layout.codeArea, classic.layout.qrArea]; + for (const area of dynamicAreas) { + const distances = [ + area.xMm - trim.xMm, + area.yMm - trim.yMm, + trim.xMm + trim.widthMm - (area.xMm + area.widthMm), + trim.yMm + trim.heightMm - (area.yMm + area.heightMm), + ]; + assert.ok(Math.min(...distances) >= 3, `Dynamická oblast není 3mm safe-area: ${JSON.stringify(area)}`); + } + + const print = await generateResolvedVoucherPrintPdf(buildVoucherFixture({ templateKey: "classic-v2" }), template, { requirePrepress: true }); + const printPreflight = await preflightFinalVoucherPrint(print, 1); + assert.deepEqual(printPreflight.errors, []); + assert.equal(printPreflight.pdfVersion, "1.7"); + assert.equal(printPreflight.pdfXClaim, "PDF/X-4"); + + const items = Array.from({ length: 50 }, (_, index) => ({ code: `PP-V2-${String(index + 1).padStart(3, "0")}` })); + const stock = await generateResolvedVoucherBatchPrintPdf({ batchNumber: "COLORPOINT-V2-50", items }, template, { requirePrepress: true }); + const stockPreflight = await preflightFinalVoucherPrint(stock, items.length); + assert.deepEqual(stockPreflight.errors, []); + assert.equal(stockPreflight.pageCount, 50); + assert.equal(stockPreflight.pdfVersion, "1.7"); + assert.equal(stockPreflight.pdfXClaim, "PDF/X-4"); + const stockPdf = await PDFDocument.load(stock, { updateMetadata: false }); + const pageOverlays = stockPdf.getPages().map((page) => getPageOverlayContent(page).toString("latin1")); + assert.equal(new Set(pageOverlays).size, 50, "50 stránek musí mít unikátní dynamický overlay/QR."); + assert.equal(stockPdf.context.enumerateIndirectObjects().filter(([, object]) => object instanceof PDFStream && object.dict.lookupMaybe(PDFName.of("Subtype"), PDFName)?.asString() === "/Image").length, 2, "QR musí zůstat vektorový a nesmí vytvořit raster image stream pro každou stránku."); +}); + function roundBox(box: { x?: number; y?: number; width: number; height: number }) { return { x: Math.round((box.x ?? 0) * 100) / 100, diff --git a/src/features/vouchers/lib/voucher-template-error.ts b/src/features/vouchers/lib/voucher-template-error.ts index 3b8e6bac..797fe154 100644 --- a/src/features/vouchers/lib/voucher-template-error.ts +++ b/src/features/vouchers/lib/voucher-template-error.ts @@ -1,9 +1,9 @@ export class VoucherTemplateError extends Error { - readonly code: "unknown_template" | "invalid_master_page_size" | "text_overflow"; + readonly code: "unknown_template" | "invalid_master_page_size" | "text_overflow" | "invalid_print_pdf"; constructor( readonly templateKey: string | null | undefined, - options: { code?: "unknown_template" | "invalid_master_page_size" | "text_overflow"; message?: string } = {}, + options: { code?: "unknown_template" | "invalid_master_page_size" | "text_overflow" | "invalid_print_pdf"; message?: string } = {}, ) { super(options.message ?? `Voucher template "${templateKey ?? ""}" is not available.`); this.code = options.code ?? "unknown_template"; diff --git a/src/features/vouchers/lib/voucher-template-preflight.test.ts b/src/features/vouchers/lib/voucher-template-preflight.test.ts index e1c11f6e..5b391822 100644 --- a/src/features/vouchers/lib/voucher-template-preflight.test.ts +++ b/src/features/vouchers/lib/voucher-template-preflight.test.ts @@ -1,14 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { PDFDocument, degrees } from "pdf-lib"; +import { PDFDocument, PDFName, PDFString, degrees } from "pdf-lib"; import { VOUCHER_PRINT_GEOMETRY } from "./voucher-template-layout"; import { preflightVoucherTemplateMaster } from "./voucher-template-preflight"; +import { ensurePrintPdfMetadata } from "./voucher-pdf-prepress"; const mm = (value: number) => value * 72 / 25.4; -async function makeMaster(rotation: number) { +// TEST ONLY: minimal deterministic ICC v4 CMYK header for structural tests. +function makeTestCmykIccProfile() { + const bytes = Buffer.alloc(128); + bytes.writeUInt32BE(bytes.length, 0); + bytes[8] = 0x43; + bytes.write("prtr", 12, "ascii"); + bytes.write("CMYK", 16, "ascii"); + bytes.write("XYZ ", 20, "ascii"); + bytes.write("acsp", 36, "ascii"); + return bytes; +} + +async function makeMaster(rotation: number, withPrepress = true) { const pdf = await PDFDocument.create(); const page = pdf.addPage([mm(VOUCHER_PRINT_GEOMETRY.widthMm), mm(VOUCHER_PRINT_GEOMETRY.heightMm)]); page.setTrimBox( @@ -19,6 +32,7 @@ async function makeMaster(rotation: number) { ); page.setBleedBox(0, 0, mm(VOUCHER_PRINT_GEOMETRY.widthMm), mm(VOUCHER_PRINT_GEOMETRY.heightMm)); page.setRotation(degrees(rotation)); + if (withPrepress) addTestOutputIntent(pdf); return Buffer.from(await pdf.save()); } @@ -31,9 +45,23 @@ async function makeMasterWithoutExplicitBleedBox() { mm(VOUCHER_PRINT_GEOMETRY.trimWidthMm), mm(VOUCHER_PRINT_GEOMETRY.trimHeightMm), ); + addTestOutputIntent(pdf); return Buffer.from(await pdf.save()); } +function addTestOutputIntent(pdf: PDFDocument) { + const profile = pdf.context.stream(makeTestCmykIccProfile(), { N: 4 }); + const profileRef = pdf.context.register(profile); + const outputIntent = pdf.context.obj({ + Type: "OutputIntent", + S: "GTS_PDFX", + OutputConditionIdentifier: PDFString.of("Test CMYK"), + DestOutputProfile: profileRef, + }); + pdf.catalog.set(PDFName.of("OutputIntents"), pdf.context.obj([pdf.context.register(outputIntent)])); + ensurePrintPdfMetadata(pdf); +} + test("preflight masteru přijme efektivní Rotate 0", async () => { const result = await preflightVoucherTemplateMaster(await makeMaster(0)); @@ -54,3 +82,62 @@ test("preflight masteru přijme BleedBox odvozený z MediaBoxu", async () => { assert.equal(result.geometryValid, true); assert.deepEqual(result.errors, []); }); + +test("preflight masteru odmítne chybějící OutputIntent", async () => { + const result = await preflightVoucherTemplateMaster(await makeMaster(0, false)); + + assert.equal(result.outputIntentPresent, false); + assert.equal(result.pdfXVerification, "DECLARED"); + assert.match(result.errors.join(" "), /OutputIntent/); + assert.match(result.errors.join(" "), /XMP/); +}); + +test("preflight odmítne OutputIntent bez DestOutputProfile", async () => { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([mm(VOUCHER_PRINT_GEOMETRY.widthMm), mm(VOUCHER_PRINT_GEOMETRY.heightMm)]); + page.setTrimBox(mm(3), mm(3), mm(210), mm(99)); + page.setBleedBox(0, 0, mm(216), mm(105)); + const outputIntent = pdf.context.obj({ Type: "OutputIntent", S: "GTS_PDFX" }); + pdf.catalog.set(PDFName.of("OutputIntents"), pdf.context.obj([pdf.context.register(outputIntent)])); + + const result = await preflightVoucherTemplateMaster(Buffer.from(await pdf.save())); + + assert.equal(result.outputIntentPresent, true); + assert.equal(result.outputIntentValid, false); + assert.match(result.errors.join(" "), /DestOutputProfile/); +}); + +test("preflight finalu kontroluje všechny stránky a očekávaný počet", async () => { + const pdf = await PDFDocument.create(); + for (let index = 0; index < 2; index += 1) { + const page = pdf.addPage([mm(VOUCHER_PRINT_GEOMETRY.widthMm), mm(VOUCHER_PRINT_GEOMETRY.heightMm)]); + page.setCropBox(0, 0, mm(216), mm(105)); + page.setBleedBox(0, 0, mm(216), mm(105)); + page.setTrimBox(mm(3), mm(3), mm(210), mm(99)); + } + addTestOutputIntent(pdf); + const { preflightFinalVoucherPrint } = await import("./voucher-template-preflight"); + const result = await preflightFinalVoucherPrint(await pdf.save(), 3); + + assert.equal(result.pageCount, 2); + assert.equal(result.geometryValid, true); + assert.match(result.errors.join(" "), /3 strán/); + assert.equal(result.pdfXVerification, "DECLARED"); +}); + +test("preflight odmítne PDF/X-4 PDF verzi nižší než 1.6", async () => { + const bytes = await makeMaster(0); + bytes.write("%PDF-1.5", 0, "ascii"); + const result = await preflightVoucherTemplateMaster(bytes); + + assert.equal(result.pdfVersion, "1.5"); + assert.match(result.errors.join(" "), /minimálně 1\.6/); + assert.equal(result.pdfXVerification, "DECLARED"); +}); + +test("preflight odmítne neplatné PDF", async () => { + const result = await preflightVoucherTemplateMaster(Buffer.from("not a PDF")); + + assert.equal(result.geometryValid, false); + assert.match(result.errors.join(" "), /nelze načíst/); +}); diff --git a/src/features/vouchers/lib/voucher-template-preflight.ts b/src/features/vouchers/lib/voucher-template-preflight.ts index 59e204fc..cdac881f 100644 --- a/src/features/vouchers/lib/voucher-template-preflight.ts +++ b/src/features/vouchers/lib/voucher-template-preflight.ts @@ -1,66 +1,179 @@ import { PDFDocument } from "pdf-lib"; +import { inspectPdfPrepressMetadata } from "./voucher-pdf-prepress"; import { VOUCHER_PRINT_GEOMETRY } from "./voucher-template-layout"; const MM_TO_PT = 72 / 25.4; const tolerancePt = MM_TO_PT * 0.35; type PdfBox = { x: number; y: number; width: number; height: number }; +type PdfPreflightMode = "MASTER" | "FINAL_PRINT"; + +export type PdfXVerification = "DECLARED" | "STRUCTURALLY_VALIDATED" | "EXTERNALLY_VERIFIED"; export type VoucherTemplatePreflight = { + pdfVersion: string | null; pageCount: number; mediaBox: PdfBox | null; + cropBox: PdfBox | null; trimBox: PdfBox | null; bleedBox: PdfBox | null; rotation: number | null; encrypted: boolean; geometryValid: boolean; + pageGeometryValid: boolean; outputIntentPresent: boolean; + outputIntentValid: boolean; + iccProfilePresent: boolean; + iccProfileValid: boolean; + outputConditionIdentifier: string | null; pdfXClaim: string | null; - pdfXVerification: "NOT VERIFIED"; + pdfXMetadataPresent: boolean; + pdfXMetadataValid: boolean; + pdfXVerification: PdfXVerification; warnings: string[]; errors: string[]; }; -function close(a: number, b: number) { return Math.abs(a - b) <= tolerancePt; } -function isExpected(box: PdfBox, expected: PdfBox) { return close(box.x, expected.x) && close(box.y, expected.y) && close(box.width, expected.width) && close(box.height, expected.height); } -function getBox(page: { getMediaBox(): PdfBox; getTrimBox(): PdfBox; getBleedBox(): PdfBox }, kind: "media" | "trim" | "bleed") { return kind === "media" ? page.getMediaBox() : kind === "trim" ? page.getTrimBox() : page.getBleedBox(); } +function close(a: number, b: number) { + return Math.abs(a - b) <= tolerancePt; +} -export async function preflightVoucherTemplateMaster(bytes: Buffer): Promise { - const warnings: string[] = []; - const errors: string[] = []; +function isExpected(box: PdfBox, expected: PdfBox) { + return close(box.x, expected.x) && close(box.y, expected.y) && close(box.width, expected.width) && close(box.height, expected.height); +} + +function normalizeRotation(angle: number) { + return ((angle % 360) + 360) % 360; +} + +function emptyPreflight(overrides: Partial = {}): VoucherTemplatePreflight { + return { + pdfVersion: null, + pageCount: 0, + mediaBox: null, + cropBox: null, + trimBox: null, + bleedBox: null, + rotation: null, + encrypted: false, + geometryValid: false, + pageGeometryValid: false, + outputIntentPresent: false, + outputIntentValid: false, + iccProfilePresent: false, + iccProfileValid: false, + outputConditionIdentifier: null, + pdfXClaim: null, + pdfXMetadataPresent: false, + pdfXMetadataValid: false, + pdfXVerification: "DECLARED", + warnings: [], + errors: [], + ...overrides, + }; +} + +function formatBox(name: string, box: PdfBox | null) { + return box + ? `${name} ${[box.x, box.y, box.width, box.height].map((value) => (value / MM_TO_PT).toFixed(2)).join(" × ")} mm` + : `${name} chybí`; +} + +async function inspectVoucherPdf(bytes: Buffer, mode: PdfPreflightMode, expectedPageCount?: number): Promise { + const pdfVersion = /^%PDF-(\d+\.\d+)/.exec(Buffer.from(bytes.subarray(0, 16)).toString("latin1"))?.[1] ?? null; let pdf: PDFDocument; - try { pdf = await PDFDocument.load(bytes, { ignoreEncryption: false }); } catch { return { pageCount: 0, mediaBox: null, trimBox: null, bleedBox: null, rotation: null, encrypted: false, geometryValid: false, outputIntentPresent: false, pdfXClaim: null, pdfXVerification: "NOT VERIFIED", warnings, errors: ["PDF nelze načíst nebo je chráněné heslem."] }; } + try { + // ignoreEncryption lets us report encryption explicitly instead of + // collapsing it into the generic invalid-PDF error. + pdf = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false }); + } catch { + return emptyPreflight({ pdfVersion, errors: ["PDF nelze načíst nebo je neplatné."] }); + } + + if (pdf.isEncrypted) return emptyPreflight({ pdfVersion, encrypted: true, errors: ["PDF je zašifrované; tiskové PDF nesmí být šifrované."] }); + const pageCount = pdf.getPageCount(); - if (pageCount !== 1) errors.push("Master musí mít právě jednu stránku."); - const page = pageCount ? pdf.getPage(0) : null; - const mediaBox = page ? getBox(page, "media") : null; - const trimBox = page ? getBox(page, "trim") : null; - const bleedBox = page ? getBox(page, "bleed") : null; - const rotation = page ? normalizeRotation(page.getRotation().angle) : null; - if (rotation !== null && rotation !== 0) errors.push(`Master PDF má nepodporovanou rotaci ${rotation}°. Použijte rotaci 0°.`); + const errors: string[] = []; + const warnings: string[] = []; + const [pdfMajor, pdfMinor] = pdfVersion?.split(".").map(Number) ?? []; + const pdfVersionValid = Number.isInteger(pdfMajor) && Number.isInteger(pdfMinor) && pdfMajor * 10 + pdfMinor >= 16; + if (!pdfVersionValid) errors.push(`PDF verze ${pdfVersion ?? "neznámá"} je pro PDF/X-4 nedostatečná; vyžadována je minimálně 1.6.`); + const expectedPageCountForMode = mode === "MASTER" ? 1 : expectedPageCount; + if (expectedPageCountForMode !== undefined && pageCount !== expectedPageCountForMode) { + errors.push(`${mode === "MASTER" ? "Master" : "Finální tiskové PDF"} má mít ${expectedPageCountForMode} ${expectedPageCountForMode === 1 ? "stránku" : "stránky"}, zjištěno ${pageCount}.`); + } + if (pageCount === 0) errors.push("PDF nemá žádnou stránku."); + const expectedMedia = { x: 0, y: 0, width: VOUCHER_PRINT_GEOMETRY.widthMm * MM_TO_PT, height: VOUCHER_PRINT_GEOMETRY.heightMm * MM_TO_PT }; const expectedTrim = { x: VOUCHER_PRINT_GEOMETRY.trimXmm * MM_TO_PT, y: VOUCHER_PRINT_GEOMETRY.trimYmm * MM_TO_PT, width: VOUCHER_PRINT_GEOMETRY.trimWidthMm * MM_TO_PT, height: VOUCHER_PRINT_GEOMETRY.trimHeightMm * MM_TO_PT }; - // PDF default values for CropBox and BleedBox are inherited from MediaBox. - // pdf-lib returns that effective value even when the box is not explicitly present. - const geometryValid = Boolean(mediaBox && trimBox && bleedBox && isExpected(mediaBox, expectedMedia) && isExpected(trimBox, expectedTrim) && isExpected(bleedBox, expectedMedia)); + const pages = Array.from({ length: pageCount }, (_, index) => pdf.getPage(index)); + const firstPage = pages[0]; + const mediaBox = firstPage?.getMediaBox() ?? null; + const cropBox = firstPage?.getCropBox() ?? null; + const trimBox = firstPage?.getTrimBox() ?? null; + const bleedBox = firstPage?.getBleedBox() ?? null; + const rotation = firstPage ? normalizeRotation(firstPage.getRotation().angle) : null; + const pageGeometryValid = pages.length > 0 && pages.every((page) => { + const pageRotation = normalizeRotation(page.getRotation().angle); + return pageRotation === 0 + && isExpected(page.getMediaBox(), expectedMedia) + && isExpected(page.getCropBox(), expectedMedia) + && isExpected(page.getBleedBox(), expectedMedia) + && isExpected(page.getTrimBox(), expectedTrim); + }); + const geometryValid = pageGeometryValid; + + if (pages.some((page) => normalizeRotation(page.getRotation().angle) !== 0)) { + errors.push(`PDF má nepodporovanou rotaci ${rotation ?? "?"}°. Použijte rotaci 0°.`); + } if (!geometryValid) { - const boxDescription = (name: string, box: PdfBox | null) => box - ? `${name} ${[box.x, box.y, box.width, box.height].map((value) => (value / MM_TO_PT).toFixed(2)).join(" × ")} mm` - : `${name} chybí`; - errors.push(`PDF nemá požadovanou geometrii (MediaBox 216 × 105 mm, TrimBox 210 × 99 mm na offsetu 3 mm, BleedBox stránky). Zjištěno: ${boxDescription("MediaBox", mediaBox)}, ${boxDescription("TrimBox", trimBox)}, ${boxDescription("BleedBox", bleedBox)}.`); + errors.push(`PDF nemá požadovanou geometrii (MediaBox/CropBox/BleedBox 216 × 105 mm, TrimBox 210 × 99 mm na offsetu 3 mm). Zjištěno: ${formatBox("MediaBox", mediaBox)}, ${formatBox("CropBox", cropBox)}, ${formatBox("TrimBox", trimBox)}, ${formatBox("BleedBox", bleedBox)}.`); } - const raw = bytes.toString("latin1"); - const outputIntentPresent = raw.includes("/OutputIntent"); - const pdfXClaim = /\/GTS_PDFXVersion\s*\(([^)]+)\)/.exec(raw)?.[1] ?? null; - if (pdfXClaim) warnings.push(`Master deklaruje ${pdfXClaim}; finální PDF/X-4 conformity se dostupným rendererem neověřuje.`); - if (!outputIntentPresent) warnings.push("Master neobsahuje rozpoznatelný OutputIntent/ICC profil."); - return { pageCount, mediaBox, trimBox, bleedBox, rotation, encrypted: false, geometryValid, outputIntentPresent, pdfXClaim, pdfXVerification: "NOT VERIFIED", warnings, errors }; + + const metadata = inspectPdfPrepressMetadata(pdf); + if (!metadata.outputIntent.present) errors.push("PDF nemá dokumentový OutputIntent."); + else if (!metadata.outputIntent.valid) errors.push("PDF má neúplný OutputIntent; musí obsahovat /S /GTS_PDFX a DestOutputProfile s ICC streamem."); + if (!metadata.outputIntent.profilePresent) errors.push("PDF nemá DestOutputProfile/ICC profil."); + else if (!metadata.outputIntent.profileValid) errors.push("DestOutputProfile není platný ICC stream."); + if (!metadata.xmp.present) errors.push("PDF nemá katalogová PDF/X XMP metadata."); + else if (!metadata.xmp.valid) errors.push("PDF/X XMP metadata nedeklarují PDF/X-4."); + if (metadata.pdfXConformanceInfoPresent) errors.push("PDF Info dictionary nesmí obsahovat GTS_PDFXConformance pro PDF/X-4."); + + if (metadata.xmp.claim && !metadata.xmp.valid) warnings.push(`PDF obsahuje deklaraci ${metadata.xmp.claim}, ale neprošla interní kontrolou.`); + if (metadata.outputIntent.outputConditionIdentifier) warnings.push(`Output condition: ${metadata.outputIntent.outputConditionIdentifier}.`); + + const structurallyValid = errors.length === 0; + const pdfXVerification: PdfXVerification = structurallyValid ? "STRUCTURALLY_VALIDATED" : "DECLARED"; + + return { + pdfVersion, + pageCount, + mediaBox, + cropBox, + trimBox, + bleedBox, + rotation, + encrypted: false, + geometryValid, + pageGeometryValid, + outputIntentPresent: metadata.outputIntent.present, + outputIntentValid: metadata.outputIntent.valid, + iccProfilePresent: metadata.outputIntent.profilePresent, + iccProfileValid: metadata.outputIntent.profileValid, + outputConditionIdentifier: metadata.outputIntent.outputConditionIdentifier, + pdfXClaim: metadata.xmp.claim, + pdfXMetadataPresent: metadata.xmp.present, + pdfXMetadataValid: metadata.xmp.valid, + pdfXVerification, + warnings, + errors, + }; } -function normalizeRotation(angle: number) { - return ((angle % 360) + 360) % 360; +export async function preflightVoucherTemplateMaster(bytes: Buffer): Promise { + return inspectVoucherPdf(bytes, "MASTER"); } -export async function preflightFinalVoucherPrint(bytes: Uint8Array) { - return preflightVoucherTemplateMaster(Buffer.from(bytes)); +export async function preflightFinalVoucherPrint(bytes: Uint8Array, expectedPageCount?: number) { + return inspectVoucherPdf(Buffer.from(bytes), "FINAL_PRINT", expectedPageCount); } diff --git a/src/features/vouchers/lib/voucher-template-publish-preflight.ts b/src/features/vouchers/lib/voucher-template-publish-preflight.ts index a709b3f7..398a7b5c 100644 --- a/src/features/vouchers/lib/voucher-template-publish-preflight.ts +++ b/src/features/vouchers/lib/voucher-template-publish-preflight.ts @@ -26,7 +26,7 @@ export async function preflightVoucherTemplateForPublish(template: ResolvedVouch const voucher = { templateId: template.id, templateKey: template.key, code: "TEST-2026-ABCDEF", type: scenario.type, originalValueCzk: valueCzk, remainingValueCzk: scenario.type === VoucherType.VALUE ? valueCzk : null, serviceNameSnapshot: scenario.serviceName ?? null, servicePriceSnapshotCzk: scenario.type === VoucherType.SERVICE ? 1500 : null, validUntil: new Date("2027-12-31T22:59:59.999Z") } as Parameters[0]; try { - const print = await preflightFinalVoucherPrint(await generateResolvedVoucherPrintPdf(voucher, template, { failOnTextOverflow: true })); + const print = await preflightFinalVoucherPrint(await generateResolvedVoucherPrintPdf(voucher, template, { failOnTextOverflow: true }), 1); const digitalPdf = await PDFDocument.load(await generateResolvedVoucherDigitalPdf(voucher, template, { failOnTextOverflow: true })); const digitalPage = digitalPdf.getPage(0); if (print.errors.length || !print.geometryValid) errors.push(...print.errors, "Finální PRINT nemá očekávanou geometrii."); @@ -42,7 +42,7 @@ export async function preflightVoucherTemplateForPublish(template: ResolvedVouch } try { - const stock = await preflightFinalVoucherPrint(await generateResolvedVoucherBatchPrintPdf({ batchNumber: "TEST-001", items: [{ code: "TEST-2026-ABCDEF" }] }, template, { failOnTextOverflow: true })); + const stock = await preflightFinalVoucherPrint(await generateResolvedVoucherBatchPrintPdf({ batchNumber: "TEST-001", items: [{ code: "TEST-2026-ABCDEF" }] }, template, { failOnTextOverflow: true }), 1); if (stock.errors.length || !stock.geometryValid) errors.push(...stock.errors, "Finální STOCK nemá očekávanou geometrii."); } catch (error) { if (error instanceof VoucherTemplateError && error.code === "text_overflow") errors.push(error.message); diff --git a/src/features/vouchers/lib/voucher-template-repository.ts b/src/features/vouchers/lib/voucher-template-repository.ts index a381346c..a2215333 100644 --- a/src/features/vouchers/lib/voucher-template-repository.ts +++ b/src/features/vouchers/lib/voucher-template-repository.ts @@ -16,6 +16,7 @@ export type ResolvedVoucherTemplate = { layout: VoucherTemplateLayoutV1; masterSha256: string; masterBytes: Buffer; + validationPolicy?: string | null; }; export async function getVoucherTemplateById(id: string) { @@ -81,10 +82,10 @@ export async function loadVoucherTemplateMaster(template: { masterStoragePath: s return master; } -export async function resolveVoucherTemplate(template: { id: string; key: string; status: VoucherTemplateStatus; allowedTypes: VoucherType[]; label: string; layout: unknown; masterStoragePath: string | null; masterSha256: string | null }): Promise { +export async function resolveVoucherTemplate(template: { id: string; key: string; status: VoucherTemplateStatus; allowedTypes: VoucherType[]; label: string; layout: unknown; masterStoragePath: string | null; masterSha256: string | null; validationPolicy?: string | null }): Promise { const masterBytes = await loadVoucherTemplateMaster(template); if (!template.masterSha256) throw new VoucherTemplateDomainError("MASTER_INVALID", "Master šablony není dostupný."); - return { id: template.id, key: template.key, status: template.status, allowedTypes: template.allowedTypes, label: template.label, layout: voucherTemplateStoredLayoutSchema.parse(template.layout), masterSha256: template.masterSha256, masterBytes }; + return { id: template.id, key: template.key, status: template.status, allowedTypes: template.allowedTypes, label: template.label, layout: voucherTemplateStoredLayoutSchema.parse(template.layout), masterSha256: template.masterSha256, masterBytes, validationPolicy: template.validationPolicy }; } export async function resolveVoucherTemplateForVoucher(voucherId: string) {