From ecd525d78205a8e89f24204a1c7e3f0f4e7412a0 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 12 Jun 2026 15:35:19 +0200 Subject: [PATCH] feat(pdf): build stream-hacking pipeline for 5-color pantone 289 c export --- scripts/generate-business-cards.ts | 6 +- scripts/inject-pantone.ts | 106 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 scripts/inject-pantone.ts diff --git a/scripts/generate-business-cards.ts b/scripts/generate-business-cards.ts index 0d15f933..135eae1c 100644 --- a/scripts/generate-business-cards.ts +++ b/scripts/generate-business-cards.ts @@ -121,7 +121,11 @@ async function generateBusinessCards() { try { execSync(cmykCommand, { stdio: 'inherit' }); fs.unlinkSync(tempViaprintoPath); - console.log(`✓ CMYK Print File generated: ${finalFileNameCMYK}`); + console.log(`[INFO] Injecting PANTONE 289 C Spot Color...`); + const injectPantoneCommand = `npx tsx scripts/inject-pantone.ts "${finalPathCMYK}"`; + execSync(injectPantoneCommand, { stdio: 'inherit' }); + + console.log(`✓ 5-Color Print File generated: ${finalFileNameCMYK}`); } catch (e) { console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`); } diff --git a/scripts/inject-pantone.ts b/scripts/inject-pantone.ts new file mode 100644 index 00000000..6f4fb166 --- /dev/null +++ b/scripts/inject-pantone.ts @@ -0,0 +1,106 @@ +import { PDFDocument, PDFName, decodePDFRawStream, PDFRawStream } from 'pdf-lib'; +import fs from 'fs'; +import path from 'path'; + +async function run() { + const inputPath = process.argv[2]; + const outputPath = process.argv[3] || inputPath; + + if (!inputPath || !fs.existsSync(inputPath)) { + console.error(`[ERROR] Input file not found: ${inputPath}`); + process.exit(1); + } + + console.log(`[INFO] Injecting PANTONE 289 C into: ${path.basename(inputPath)}`); + const pdfBytes = fs.readFileSync(inputPath); + const pdfDoc = await PDFDocument.load(pdfBytes); + + // CMYK tint transform for PANTONE 289 C. + // Approximation: 100C, 64M, 0Y, 60K (1.0 0.64 0.0 0.60) + // The function maps an input x (0 to 1) to CMYK: x*1.0, x*0.64, x*0.0, x*0.60 + const tintTransform = pdfDoc.context.stream( + '{ dup 1.0 mul exch dup 0.64 mul exch dup 0.0 mul exch 0.60 mul }', + { + FunctionType: 4, + Domain: [0, 1], + Range: [0, 1, 0, 1, 0, 1, 0, 1], + }, + ); + + // Create the /Separation color space array + const pantoneCS = pdfDoc.context.obj([ + PDFName.of('Separation'), + PDFName.of('PANTONE#20289#20C'), + PDFName.of('DeviceCMYK'), + tintTransform, + ]); + const csRef = pdfDoc.context.register(pantoneCS); + + const pages = pdfDoc.getPages(); + for (const page of pages) { + let resources = page.node.Resources(); + if (!resources) { + resources = pdfDoc.context.obj({}); + page.node.set(PDFName.of('Resources'), resources); + } + + // Ensure ColorSpace dict exists + let colorSpaces = resources.get(PDFName.of('ColorSpace')) as any; + if (!colorSpaces) { + colorSpaces = pdfDoc.context.obj({}); + resources.set(PDFName.of('ColorSpace'), colorSpaces); + } + // Register the PANTONE color space as /CSPantone in the page's resources + colorSpaces.set(PDFName.of('CSPantone'), csRef); + + // Read all content streams + const contents = page.node.Contents(); + let streams: any[] = []; + if (contents) { + const resolvedContents = pdfDoc.context.lookup(contents); + if (resolvedContents instanceof PDFRawStream) { + streams = [resolvedContents]; + } else if (resolvedContents.constructor.name === 'PDFArray') { + const arr = resolvedContents as any; + for (let j = 0; j < arr.size(); j++) { + streams.push(pdfDoc.context.lookup(arr.get(j))); + } + } + } + + let newStr = ''; + for (let i = 0; i < streams.length; i++) { + const stream = streams[i]; + if (stream instanceof PDFRawStream) { + const decoded = decodePDFRawStream(stream).decode(); + const str = Buffer.from(decoded).toString('utf-8'); + newStr += str + '\n'; + } + } + + // The CMYK values for the KLZ Blue created by Ghostscript from #000a66 + // is '1 0.98 0.224 0.298'. We use a slightly dynamic regex to catch small precision diffs. + const dynamicRegexK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* k/g; + const dynamicRegexStrokeK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* K/g; + + let modified = false; + if (dynamicRegexK.test(newStr) || dynamicRegexStrokeK.test(newStr)) { + newStr = newStr.replace(dynamicRegexK, '/CSPantone cs 1 scn'); + newStr = newStr.replace(dynamicRegexStrokeK, '/CSPantone CS 1 SCN'); + modified = true; + } + + if (modified) { + const newStream = pdfDoc.context.flateStream(newStr); + const newRef = pdfDoc.context.register(newStream); + page.node.set(PDFName.of('Contents'), newRef); + console.log(`[INFO] Spot color injected into page ${pages.indexOf(page) + 1}.`); + } + } + + const finalPdfBytes = await pdfDoc.save(); + fs.writeFileSync(outputPath, finalPdfBytes); + console.log(`[SUCCESS] Saved 5-color PDF to ${outputPath}`); +} + +run().catch(console.error);