2026-05-20 16:53:23 -04:00
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
pdf . js — single - page vector PDF writer for the print shop .
Native PDF drawing ops ( no embedded raster ) . Uses :
• DeviceCMYK colour with a controllable rich black , so a
print shop gets proper separations rather than RGB ;
• ExtGState soft - mask alpha ( / c a , / C A ) f o r t r u e o p a c i t y o n
strokes and fills ;
• a base - 14 Helvetica archival header ( no font embedding ) .
Geometry comes from the same scene model as every renderer .
=== === === === === === === === === === === === === === === === === === === === * /
import { makeRng } from '../rng.js' ;
import { sampleBubbles , trackInkWeight } from '../scene/bubbles.js' ;
const MARGIN = 0.02 ;
export function buildPDF ( scene , params , pageSize = 1728 ) {
const scale = ( pageSize / 2 ) * ( 1 - MARGIN ) ;
const cx = pageSize / 2 , cy = pageSize / 2 ;
const tx = ( x ) => ( cx + x * scale ) ;
const ty = ( y ) => ( cy - y * scale ) ; // PDF y-up
const u = pageSize / 1000 ;
const inv = params . invert ;
// CMYK colours: warm cream paper, warm rich black ink (or swapped)
const paperCMYK = inv ? '0.04 0.06 0.16 0.00' : '0.30 0.30 0.32 1.00' ;
const inkCMYK = inv ? '0.30 0.30 0.32 0.98' : '0.03 0.05 0.14 0.00' ;
// ExtGState alpha registry
const gsSet = new Map ( ) ;
const gs = ( alpha ) => {
const k = Math . max ( 0 , Math . min ( 100 , Math . round ( alpha * 100 ) ) ) ;
if ( ! gsSet . has ( k ) ) gsSet . set ( k , ` GS ${ k } ` ) ;
return ` /GS ${ k } gs \n ` ;
} ;
let c = '' ;
c += ` ${ paperCMYK } k \n 0 0 ${ pageSize } ${ pageSize } re f \n ` ;
// chamber boundary
if ( params . showBoundary ) {
c += ` q \n ${ gs ( 0.4 ) } ${ inkCMYK } K \n ${ ( 2 * u ) . toFixed ( 2 ) } w \n ` ;
const bcx = cx , bcy = cy - pageSize * 0.35 , br = pageSize * 0.45 ;
const a1 = Math . PI * 0.15 , a2 = Math . PI - Math . PI * 0.15 , steps = 90 ;
for ( let i = 0 ; i <= steps ; i ++ ) {
const a = Math . PI + a1 + ( a2 - a1 ) * ( i / steps ) ;
const px = bcx + Math . cos ( a ) * br , py = bcy - Math . sin ( a ) * br ;
c += ` ${ px . toFixed ( 1 ) } ${ py . toFixed ( 1 ) } ${ i === 0 ? 'm' : 'l' } \n ` ;
}
c += ` S \n Q \n ` ;
}
// instrument geometry
if ( scene . instrument ) {
c += ` q \n ${ inkCMYK } K \n 1 J \n ` ;
for ( const l of scene . instrument . lines )
c += ` ${ gs ( l . opacity ) } ${ ( l . width * u ) . toFixed ( 2 ) } w \n ${ tx ( l . x1 ) . toFixed ( 1 ) } ${ ty ( l . y1 ) . toFixed ( 1 ) } m ${ tx ( l . x2 ) . toFixed ( 1 ) } ${ ty ( l . y2 ) . toFixed ( 1 ) } l S \n ` ;
for ( const a of scene . instrument . arcs ) {
c += ` ${ gs ( a . opacity ) } ${ ( a . width * u ) . toFixed ( 2 ) } w \n ${ tx ( a . pts [ 0 ] . x ) . toFixed ( 1 ) } ${ ty ( a . pts [ 0 ] . y ) . toFixed ( 1 ) } m \n ` ;
for ( let i = 1 ; i < a . pts . length ; i ++ ) c += ` ${ tx ( a . pts [ i ] . x ) . toFixed ( 1 ) } ${ ty ( a . pts [ i ] . y ) . toFixed ( 1 ) } l \n ` ;
c += ` S \n ` ;
}
c += ` Q \n ` ;
}
// continuity under-strokes (bucketed by alpha)
c += ` q \n ${ inkCMYK } K \n 1 J \n 1 j \n ` ;
for ( const t of scene . tracks ) {
if ( t . pts . length < 2 ) continue ;
const iw = trackInkWeight ( t ) ;
const lw = Math . min ( 2.6 , 0.25 + Math . sqrt ( iw ) * 0.12 ) * u * params . size * t . weight ;
if ( lw < 0.2 * u ) continue ;
c += ` ${ gs ( 0.14 * t . weight ) } ${ lw . toFixed ( 2 ) } w \n ` ;
c += ` ${ tx ( t . pts [ 0 ] . x ) . toFixed ( 2 ) } ${ ty ( t . pts [ 0 ] . y ) . toFixed ( 2 ) } m \n ` ;
for ( let i = 1 ; i < t . pts . length ; i ++ ) c += ` ${ tx ( t . pts [ i ] . x ) . toFixed ( 2 ) } ${ ty ( t . pts [ i ] . y ) . toFixed ( 2 ) } l \n ` ;
c += ` S \n ` ;
}
c += ` Q \n ` ;
// shock
if ( scene . shock ) {
const sh = scene . shock ;
const px = tx ( sh . x ) , py = ty ( sh . y ) ;
2026-05-20 17:10:32 -04:00
if ( params . diskBubbles !== false && sh . bubbleStrokes ) {
// disk line work as bubbles (same method as tracks)
const dRng = makeRng ( params . seed , 'diskbubbles' ) ;
const dbk = new Map ( ) ;
for ( const stroke of sh . bubbleStrokes ) {
const key = Math . round ( Math . min ( 1 , 0.45 + stroke . weight * 0.5 ) * 20 ) / 20 ;
if ( ! dbk . has ( key ) ) dbk . set ( key , [ ] ) ;
dbk . get ( key ) . push ( ... sampleBubbles ( stroke , params , dRng ) ) ;
}
c += ` q \n ${ inkCMYK } k \n ` ;
for ( const [ alpha , bubs ] of dbk ) {
c += gs ( alpha ) ;
for ( const b of bubs ) c += circlePath ( tx ( b . x ) , ty ( b . y ) , Math . max ( b . r * scale , 0.5 ) ) + 'f\n' ;
}
c += ` Q \n ` ;
} else {
c += ` q \n ${ inkCMYK } K \n 1 J \n ` ;
for ( const st of sh . striations ) {
const ix = px + Math . cos ( st . a ) * st . inner * scale , iy = py - Math . sin ( st . a ) * st . inner * scale ;
const ox = px + Math . cos ( st . a ) * st . outer * scale , oy = py - Math . sin ( st . a ) * st . outer * scale ;
c += ` ${ gs ( st . opacity ) } ${ ( st . width * u ) . toFixed ( 2 ) } w \n ${ ix . toFixed ( 1 ) } ${ iy . toFixed ( 1 ) } m ${ ox . toFixed ( 1 ) } ${ oy . toFixed ( 1 ) } l S \n ` ;
}
for ( const k of sh . core ) {
c += ` ${ gs ( k . opacity ) } ${ ( k . width * u ) . toFixed ( 2 ) } w \n ${ tx ( k . x1 ) . toFixed ( 1 ) } ${ ty ( k . y1 ) . toFixed ( 1 ) } m ${ tx ( k . x2 ) . toFixed ( 1 ) } ${ ty ( k . y2 ) . toFixed ( 1 ) } l S \n ` ;
}
for ( const ring of sh . rings ) c += ` ${ gs ( ring . opacity ) } ${ ( ring . width * u ) . toFixed ( 2 ) } w \n ` + strokeCircle ( px , py , ring . rr * scale ) ;
c += ` Q \n ` ;
2026-05-20 16:53:23 -04:00
}
}
// bubbles (bucketed by alpha)
const buckets = new Map ( ) ;
const bRng = makeRng ( params . seed , 'bubbles' ) ;
for ( const t of scene . tracks ) {
const key = Math . round ( Math . min ( 1 , 0.45 + t . weight * 0.5 ) * 20 ) / 20 ;
if ( ! buckets . has ( key ) ) buckets . set ( key , [ ] ) ;
buckets . get ( key ) . push ( ... sampleBubbles ( t , params , bRng ) ) ;
}
c += ` q \n ${ inkCMYK } k \n ` ;
for ( const [ alpha , bubs ] of buckets ) {
c += gs ( alpha ) ;
for ( const b of bubs ) c += circlePath ( tx ( b . x ) , ty ( b . y ) , Math . max ( b . r * scale , 0.5 ) ) + 'f\n' ;
}
c += ` Q \n ` ;
// plate damage
const A = scene . artifacts ;
if ( A ) {
c += ` q \n ${ inkCMYK } K \n 1 J \n ` ;
for ( const sc of A . scratches ) c += ` ${ gs ( sc . opacity ) } ${ ( sc . width * u ) . toFixed ( 2 ) } w \n ${ tx ( sc . x1 ) . toFixed ( 1 ) } ${ ty ( sc . y1 ) . toFixed ( 1 ) } m ${ tx ( sc . x2 ) . toFixed ( 1 ) } ${ ty ( sc . y2 ) . toFixed ( 1 ) } l S \n ` ;
for ( const hair of A . hairs ) {
c += ` ${ gs ( hair . opacity ) } ${ ( hair . width * u ) . toFixed ( 2 ) } w \n ${ tx ( hair . pts [ 0 ] . x ) . toFixed ( 1 ) } ${ ty ( hair . pts [ 0 ] . y ) . toFixed ( 1 ) } m \n ` ;
for ( let i = 1 ; i < hair . pts . length ; i ++ ) c += ` ${ tx ( hair . pts [ i ] . x ) . toFixed ( 1 ) } ${ ty ( hair . pts [ i ] . y ) . toFixed ( 1 ) } l \n ` ;
c += ` S \n ` ;
}
for ( const ring of A . rings ) c += ` ${ gs ( ring . opacity ) } ${ ( ring . width * u ) . toFixed ( 2 ) } w \n ` + strokeCircle ( tx ( ring . x ) , ty ( ring . y ) , ring . r * scale ) ;
c += ` Q \n q \n ${ inkCMYK } k \n ` ;
for ( const sp of A . specks ) c += gs ( sp . opacity ) + circlePath ( tx ( sp . x ) , ty ( sp . y ) , Math . max ( sp . r * scale , 0.5 ) ) + 'f\n' ;
c += ` Q \n ` ;
}
// fiducials
if ( params . showFiducials ) {
c += ` q \n ${ gs ( 0.55 ) } ${ inkCMYK } K \n ${ ( 1.2 * u ) . toFixed ( 2 ) } w \n ` ;
const fids = [ [ - 0.85 , - 0.85 ] , [ 0.85 , - 0.85 ] , [ - 0.85 , 0.85 ] , [ 0.85 , 0.85 ] , [ 0 , - 0.85 ] , [ - 0.85 , 0 ] , [ 0.85 , 0 ] ] ;
const s = 8 * u ;
for ( const [ fx , fy ] of fids ) {
const px = tx ( fx ) , py = ty ( fy ) ;
c += ` ${ px - s } ${ py } m ${ px + s } ${ py } l S \n ${ px } ${ py - s } m ${ px } ${ py + s } l S \n ` ;
}
c += ` Q \n ` ;
}
// archival header (Helvetica)
if ( params . showHeader ) {
const pad = 26 * u ;
c += ` q \n ${ gs ( 0.6 ) } ${ inkCMYK } k \n BT \n /F0 ${ ( 11 * u ) . toFixed ( 1 ) } Tf \n 1 0 0 1 ${ pad . toFixed ( 1 ) } ${ ( pageSize - pad - 11 * u ) . toFixed ( 1 ) } Tm ( ${ pdfStr ( scene . lab . toUpperCase ( ) ) } ) Tj \n ET \n ` ;
c += ` BT \n /F0 ${ ( 9 * u ) . toFixed ( 1 ) } Tf \n 1 0 0 1 ${ pad . toFixed ( 1 ) } ${ ( pageSize - pad - 27 * u ) . toFixed ( 1 ) } Tm (SEED ${ pdfStr ( params . seed ) } ) Tj \n ET \n ` ;
const fs = 10 * u ;
const rt = ( txt , yoff ) => {
const wEst = txt . length * fs * 0.5 ;
c += ` BT \n /F0 ${ fs . toFixed ( 1 ) } Tf \n 1 0 0 1 ${ ( pageSize - pad - wEst ) . toFixed ( 1 ) } ${ yoff . toFixed ( 1 ) } Tm ( ${ pdfStr ( txt ) } ) Tj \n ET \n ` ;
} ;
rt ( ` PLATE ${ scene . plate } ` , pad + 13 * u ) ;
rt ( ` EXPOSED ${ scene . exposure } ` , pad ) ;
c += ` Q \n ` ;
}
// ExtGState dict
let extg = '' ;
for ( const [ k , name ] of gsSet ) extg += ` / ${ name } << /ca ${ ( k / 100 ) . toFixed ( 2 ) } /CA ${ ( k / 100 ) . toFixed ( 2 ) } >> ` ;
return assemblePDF ( c , pageSize , extg ) ;
}
function circlePath ( px , py , r ) {
const k = 0.5522847498 * r ;
return ` ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } m \n ` +
` ${ ( px - r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px - k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } c \n ` +
` ${ ( px + k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c \n ` +
` ${ ( px + r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px + k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } c \n ` +
` ${ ( px - k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c \n ` ;
}
function strokeCircle ( px , py , r ) {
const k = 0.5522847498 * r ;
return ` ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } m ` +
` ${ ( px - r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px - k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } c ` +
` ${ ( px + k ) . toFixed ( 2 ) } ${ ( py + r ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ ( py + k ) . toFixed ( 2 ) } ${ ( px + r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c ` +
` ${ ( px + r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px + k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ px . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } c ` +
` ${ ( px - k ) . toFixed ( 2 ) } ${ ( py - r ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ ( py - k ) . toFixed ( 2 ) } ${ ( px - r ) . toFixed ( 2 ) } ${ py . toFixed ( 2 ) } c S \n ` ;
}
function pdfStr ( s ) { return String ( s ) . replace ( /([()\\])/g , '\\$1' ) ; }
function assemblePDF ( content , pageSize , extg ) {
const enc = new TextEncoder ( ) ;
const contentBytes = enc . encode ( content ) ;
let body = ` %PDF-1.4 \n % \x C3 \x A0 \x C3 \x A1 \x C3 \x A2 \x C3 \x A3 \n ` ;
const offsets = [ ] ;
const addObj = ( s ) => { offsets . push ( body . length ) ; body += s ; } ;
const resources = ` << /ExtGState << ${ extg } >> /Font << /F0 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> ` ;
addObj ( ` 1 0 obj \n << /Type /Catalog /Pages 2 0 R >> \n endobj \n ` ) ;
addObj ( ` 2 0 obj \n << /Type /Pages /Kids [3 0 R] /Count 1 >> \n endobj \n ` ) ;
addObj ( ` 3 0 obj \n << /Type /Page /Parent 2 0 R /MediaBox [0 0 ${ pageSize } ${ pageSize } ] /Contents 4 0 R /Resources ${ resources } >> \n endobj \n ` ) ;
addObj ( ` 4 0 obj \n << /Length ${ contentBytes . length } >> \n stream \n ${ content } \n endstream \n endobj \n ` ) ;
const xref = body . length ;
body += ` xref \n 0 5 \n 0000000000 65535 f \n ` ;
for ( const o of offsets ) body += String ( o ) . padStart ( 10 , '0' ) + ' 00000 n \n' ;
body += ` trailer \n << /Size 5 /Root 1 0 R >> \n startxref \n ${ xref } \n %%EOF \n ` ;
return new Uint8Array ( enc . encode ( body ) ) ;
}