227 lines
16 KiB
Python
227 lines
16 KiB
Python
#!/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)
|