#!/usr/bin/env python3 """Generate self-authored ICC intent/state/cache probes without renderer goldens.""" import argparse import hashlib import itertools import json from pathlib import Path import struct from pypdf import PdfWriter from pypdf.generic import (ArrayObject, ByteStringObject, DecodedStreamObject, DictionaryObject, FloatObject, NameObject, NumberObject) ROOT = Path(__file__).resolve().parents[3] BASE = ROOT / 'tests/fixtures/pdf/cmyk-lut' INTENTS = ('Perceptual', 'RelativeColorimetric', 'Saturation', 'AbsoluteColorimetric') SAMPLE_BYTES = (51, 128, 204, 77) SAMPLE = tuple(x/255 for x in SAMPLE_BYTES) WIDTH, HEIGHT = 820, 800 CASES = ('default-relative', 'ri-vector', 'extgstate-ri', 'nested-q-inner', 'nested-Q-restore', 'form-inherit-shared', 'form-inherit-shared-gs', 'form-ri-override', 'form-gs-override', 'form-return-restore', 'image-inherit-shared-forward', 'image-inherit-shared-reverse', 'image-intent-override', 'image-inherit-shared-gs', 'image-override-return', 'indexed-vector', 'indexed-image', 'stroke', 'text', 'axial-shading', 'shading-pattern-inherit', 'shading-pattern-RI-override', 'type3-inherit-rectangle', 'type3-inherit-image', 'type3-ri-override') def sha(data): return hashlib.sha256(data).hexdigest() def array(values): return ArrayObject([FloatObject(x) for x in values]) def name(value): return NameObject('/'+value) def dictionary(**values): return DictionaryObject({name(k):v for k,v in values.items()}) def fixed(values): return struct.pack('>'+'i'*len(values), *(round(v*65536) for v in values)) def operands(values): return ' '.join(f'{v:.10f}' for v in values) def derived_model(tag, values): c,m,y,k = values if tag == 'A2B0': return (100-12*c-15*m-10*y-35*k,18*m-15*c,20*y-12*c) if tag == 'A2B1': return (100-20*c-20*m-20*y-40*k,40*m-20*c-20*k,40*y-20*c-20*k) if tag == 'A2B2': return (100-25*c-25*m-20*y-30*k,-40*m+20*c+20*k,35*y-20*m-15*k) raise ValueError(tag) def derived_profile(base): # Reuse the existing input profile's v2 header, CLUT layout and attribution; # add explicitly distinct self-authored intent tables and a non-D50 medium. tags = {} for i in range(struct.unpack_from('>I',base,128)[0]): signature,offset,size=struct.unpack_from('>4sII',base,132+12*i) tags[signature]=base[offset:offset+size] tags[b'wtpt']=b'XYZ '+bytes(4)+fixed((.82,.86,.72)) for signature in ('A2B0','A2B1','A2B2'): lut=bytearray(tags[b'A2B0']) for i,values in enumerate(itertools.product((0,1),repeat=4)): lightness,a,b=derived_model(signature,values) struct.pack_into('>3H',lut,68+6*i,round(lightness/100*65280),round((a+128)*256),round((b+128)*256)) tags[signature.encode()]=bytes(lut) description=b'DocView synthetic distinct rendering intent tables\0' tags[b'desc']=b'desc'+bytes(4)+struct.pack('>I',len(description))+description+bytes(78) header=bytearray(base[:128]);entries=bytearray();body=bytearray();offset=132+12*len(tags) for signature,data in sorted(tags.items()): entries+=signature+struct.pack('>II',offset+len(body),len(data)) body+=data+bytes((-len(data))%4) result=header+struct.pack('>I',len(tags))+entries+body result[:4]=struct.pack('>I',len(result)) return bytes(result) def generate(output): output.mkdir(parents=True,exist_ok=False) base_spec=json.loads((BASE/'manifest.json').read_text()) base=(BASE/base_spec['profileFile']).read_bytes() assert sha(base)==base_spec['profileSha256'] profiles={'original':base,'distinct':derived_profile(base)} writer=PdfWriter();writer.pdf_header='%PDF-1.7' writer.add_metadata({'/Title':'Rendering intent: explicit state, inheritance and cache', '/Author':'DocView synthetic regression corpus'}) rows=[];profile_records={} for profile_id,profile in profiles.items(): filename=profile_id+'-cmyk-lab.icc';(output/filename).write_bytes(profile) profile_records[profile_id]={'file':filename,'sha256':sha(profile),'size':len(profile), 'origin':'byte-identical existing synthetic fixture' if profile_id=='original' else 'self-authored extension of the existing CLUT', 'mediaWhitePoint':[.9642,1,.8249] if profile_id=='original' else [.82,.86,.72], 'tables':['A2B0'] if profile_id=='original' else ['A2B0','A2B1','A2B2']} stream=DecodedStreamObject();stream.set_data(profile) stream.update(dictionary(N=NumberObject(4),Alternate=name('DeviceCMYK'))) space=ArrayObject([name('ICCBased'),writer._add_object(stream)]) indexed=ArrayObject([name('Indexed'),space,NumberObject(0),ByteStringObject(bytes(SAMPLE_BYTES))]) font=writer._add_object(dictionary(Type=name('Font'),Subtype=name('Type1'),BaseFont=name('Helvetica-Bold'))) gs=dictionary(**{'I'+str(i):dictionary(Type=name('ExtGState'),RI=name(intent)) for i,intent in enumerate(INTENTS)}) local_resources=dictionary(ColorSpace=dictionary(CS=space,IX=indexed),ExtGState=gs,Font=dictionary(F=font)) image=DecodedStreamObject();image.set_data(bytes(SAMPLE_BYTES)) image.update(dictionary(Type=name('XObject'),Subtype=name('Image'),Width=NumberObject(1),Height=NumberObject(1), BitsPerComponent=NumberObject(8),ColorSpace=space)) shared_image=writer._add_object(image) fonts=dictionary(F=font) glyph_resources=dictionary(ColorSpace=dictionary(CS=space),XObject=dictionary(Shared=shared_image)) def type3_font(prefix='',image=False): glyph=DecodedStreamObject() painting='q 1000 0 0 1000 0 0 cm /Shared Do Q' if image else '/CS cs '+operands(SAMPLE)+' scn 0 0 1000 1000 re f' glyph.set_data(('1000 0 d0\n'+prefix+'\n'+painting+'\n').encode()) encoding=dictionary(Type=name('Encoding'),Differences=ArrayObject([NumberObject(65),name('A')])) return writer._add_object(dictionary(Type=name('Font'),Subtype=name('Type3'),FontBBox=array((0,0,1000,1000)), FontMatrix=array((.001,0,0,.001,0,0)),CharProcs=dictionary(A=writer._add_object(glyph)), Encoding=encoding,FirstChar=NumberObject(65),LastChar=NumberObject(65),Widths=array((1000,)),Resources=glyph_resources)) fonts[name('T3Rect')]=type3_font() fonts[name('T3Image')]=type3_font(image=True) for i,intent in enumerate(INTENTS):fonts[name('T3Override'+str(i))]=type3_font('/'+intent+' ri') iximage=DecodedStreamObject();iximage.set_data(b'\0') iximage.update(dictionary(Type=name('XObject'),Subtype=name('Image'),Width=NumberObject(1),Height=NumberObject(1), BitsPerComponent=NumberObject(8),ColorSpace=indexed)) xobjects=dictionary(Shared=shared_image,Indexed=writer._add_object(iximage)) def form(prefix): stream=DecodedStreamObject() stream.set_data((prefix+'\n/CS cs '+operands(SAMPLE)+' scn 0 0 90 44 re f\n').encode()) stream.update(dictionary(Type=name('XObject'),Subtype=name('Form'),BBox=array((0,0,90,44)),Resources=local_resources)) return writer._add_object(stream) xobjects[name('Inherited')]=form('') for i,intent in enumerate(INTENTS): copy=DecodedStreamObject();copy.set_data(bytes(SAMPLE_BYTES));copy.update(image) copy[name('Intent')]=name(intent) xobjects[name('Image'+str(i))]=writer._add_object(copy) xobjects[name('Form'+str(i))]=form('/'+intent+' ri') xobjects[name('FormGs'+str(i))]=form('/I'+str(i)+' gs') shading_function=dictionary(FunctionType=NumberObject(2),Domain=array((0,1)), C0=array((.1,.3,.6,.2)),C1=array((.5,.7,.9,.4)),N=NumberObject(1)) shading=dictionary(ShadingType=NumberObject(2),ColorSpace=space,Coords=array((0,0,90,0)), Function=writer._add_object(shading_function)) from pypdf.generic import BooleanObject shading[name('Extend')]=ArrayObject([BooleanObject(False),BooleanObject(False)]) wide_shading=DictionaryObject(shading);wide_shading[name('Coords')]=array((0,0,WIDTH,0)) wide_ref=writer._add_object(wide_shading) patterns=dictionary(Inherited=dictionary(Type=name('Pattern'),PatternType=NumberObject(2),Shading=wide_ref)) for i,intent in enumerate(INTENTS): patterns[name('P'+str(i))]=dictionary(Type=name('Pattern'),PatternType=NumberObject(2),Shading=wide_ref, ExtGState=dictionary(RI=name(intent))) resources=dictionary(ColorSpace=dictionary(CS=space,IX=indexed),ExtGState=gs,Font=fonts, XObject=xobjects,Shading=dictionary(A=writer._add_object(shading)),Pattern=patterns) for group in range((len(CASES)+7)//8): page=writer.add_blank_page(WIDTH,HEIGHT);page[name('Resources')]=resources commands=['0 g BT /F 17 Tf 28 762 Td (ICC rendering intent - '+profile_id+f' - {group+1}/{(len(CASES)+7)//8}) Tj ET'] commands+=['0 g BT /F 8 Tf 28 742 Td (Synthetic input profile; reference colors are computed independently by CMM, not copied from a renderer.) Tj ET'] for column,intent in enumerate(INTENTS): commands.append(f'0 g BT /F 10 Tf {310+column*125} 716 Td ({intent}) Tj ET') for local_row,case in enumerate(CASES[group*8:group*8+8]): y=625-local_row*80 commands.append(f'0 g BT /F 10 Tf 28 {y+22} Td ({case}) Tj ET') for column in range(4): intent_index=3-column if case=='image-inherit-shared-reverse' else column if case=='image-inherit-shared-gs': intent_index=(0,1,0,3)[column] intent=INTENTS[intent_index];other=(intent_index+1)%4 x=310+column*125 start=['q'];body=[];expected=intent_index;point=(x+45,y+22);components=SAMPLE if case=='default-relative': # Start each cell at the page's untouched default. Other # commands are isolated by q/Q, including all labels. expected=1 elif case in ('extgstate-ri','image-inherit-shared-gs','form-inherit-shared-gs'): start+=['/I'+str(intent_index)+' gs'] else: start+=['/'+intent+' ri'] paint=f'/CS cs {operands(SAMPLE)} scn {x} {y} 90 44 re f' if case in ('default-relative','ri-vector','extgstate-ri'): body=[paint] elif case=='nested-q-inner': body=['q /'+INTENTS[other]+' ri',paint,'Q'];expected=other elif case=='nested-Q-restore': body=['q /'+INTENTS[other]+' ri Q',paint] elif case.startswith('form-'): form_name='Inherited' if case=='form-ri-override': form_name='Form'+str(other);expected=other if case=='form-gs-override': form_name='FormGs'+str(other);expected=other if case=='form-return-restore': # Invoke the override at the origin, then # paint with the caller's restored graphics state. body=[f'/Form{other} Do',paint] else: body=[f'q 1 0 0 1 {x} {y} cm /{form_name} Do Q'] elif case.startswith('image-') or case=='indexed-image': image_name='Indexed' if case=='indexed-image' else 'Shared' if case=='image-intent-override': image_name='Image'+str(other);expected=other if case=='image-override-return': body=[f'/Image{other} Do',paint] else: body=[f'q 90 0 0 44 {x} {y} cm /{image_name} Do Q'] elif case=='indexed-vector': body=[f'/IX cs 0 scn {x} {y} 90 44 re f'] elif case=='stroke': body=[f'/CS CS {operands(SAMPLE)} SCN 24 w {x} {y+22} m {x+90} {y+22} l S'] elif case=='text': body=[f'/CS cs {operands(SAMPLE)} scn BT /F 64 Tf 1 0 0 1 {x} {y} Tm (H) Tj ET'] point=(x+8,y+29) elif case=='axial-shading': body=[f'q {x} {y} 90 44 re W n 1 0 0 1 {x} {y} cm /A sh Q'] components=(.3,.5,.75,.3) elif case.startswith('shading-pattern-'): pattern='Inherited' if case=='shading-pattern-RI-override':pattern='P'+str(other);expected=other body=[f'/Pattern cs /{pattern} scn {x} {y} 90 44 re f'] t=(x+45)/WIDTH;components=tuple(a+(b-a)*t for a,b in zip((.1,.3,.6,.2),(.5,.7,.9,.4))) elif case.startswith('type3-'): glyph_font='T3Image' if case=='type3-inherit-image' else 'T3Rect' if case=='type3-ri-override':glyph_font='T3Override'+str(other);expected=other body=[f'BT /{glyph_font} 44 Tf 1 0 0 1 {x} {y} Tm (A) Tj ET'] point=(x+22,y+22) commands+=start+body+['Q'] rows.append({'page':len(writer.pages),'profile':profile_id,'case':case,'column':column, 'graphicsStateIntent':INTENTS[intent_index] if case!='default-relative' else 'default', 'expectedIntent':INTENTS[expected],'expectedIntentIndex':expected,'pointPt':list(point), 'components':list(components),'commandSequence':start+body+['Q'], **({'shading':{'xStart':x if case=='axial-shading' else 0,'xEnd':x+90 if case=='axial-shading' else WIDTH, 'c0':[.1,.3,.6,.2],'c1':[.5,.7,.9,.4]}} if case=='axial-shading' or case.startswith('shading-pattern-') else {})}) content=DecodedStreamObject();content.set_data(('\n'.join(commands)+'\n').encode()) page[name('Contents')]=writer._add_object(content) pdf=output/'rendering-intents.pdf' with pdf.open('wb') as target:writer.write(target) manifest={'schemaVersion':1,'file':pdf.name,'sha256':sha(pdf.read_bytes()),'pageSizePt':[WIDTH,HEIGHT], 'pageCount':len(writer.pages),'profiles':profile_records,'baseProfileSha256':sha(base),'sampleBytes':list(SAMPLE_BYTES), 'intentOrder':list(INTENTS),'cases':list(CASES),'probes':rows,'tolerance8Bit':4, 'generatorSha256':sha(Path(__file__).read_bytes()), 'derivedProfileModels':{'A2B0':['100-12*C-15*M-10*Y-35*K','18*M-15*C','20*Y-12*C'], 'A2B1':['100-20*C-20*M-20*Y-40*K','40*M-20*C-20*K','40*Y-20*C-20*K'], 'A2B2':['100-25*C-25*M-20*Y-30*K','-40*M+20*C+20*K','35*Y-20*M-15*K']}, 'references':['https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf', 'https://archive.color.org/files/icc32.pdf','https://www.littlecms.com/'], 'limits':['No measured press profile, human-approved golden, display calibration or broad ICC conformance claim.', 'Independent direct-CMM oracle can share LittleCMS code with PDF renderers; not a separate CMM implementation.', 'Text uses an interior point of the standard Helvetica-Bold H; axial samples account for raster pixel centers.']} (output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') print(json.dumps({'fileSha256':manifest['sha256'],'pages':len(writer.pages),'probes':len(rows)})) if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',required=True,type=Path) generate(parser.parse_args().output)