initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Check RI masks/patterns against declared PDF state rules and a direct CMM.
This runner never treats agreement between renderers as the expected value.
The numerical DeviceRGB mask convention is distinct from intent selection.
"""
import argparse
from io import BytesIO
import itertools
import json
import math
import os
from pathlib import Path
import platform
import shutil
import subprocess
from PIL import Image, ImageChops, ImageCms, ImageDraw
from pypdf import PdfReader
from intents import Cmm, INTENTS, loaded_pdfium, sha
ROOT=Path(__file__).resolve().parents[2]
CORPUS=ROOT/'tests/fixtures/pdf/intent-transparency'
PROFILE_SHA='b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315'
def preserved_inputs():
paths=[ROOT/'tests/fixtures/pdf/generate_rendering_intents.py',ROOT/'tests/cmyk_lut/intents.py']
paths += sorted((ROOT/'tests/fixtures/pdf/rendering-intents').glob('*'))
return {str(p.relative_to(ROOT)):sha(p) for p in paths if p.is_file()}
def verify(corpus,spec):
assert spec['schemaVersion']==1 and spec['pageCount']==4
assert spec['pageSizePt']==[880,850] and spec['sample']==[0,.75,.25,0]
assert spec['tolerance8Bit']==4 and len(spec['cases'])==27
assert len(spec['probes'])==108
assert sha(corpus/spec['file'])==spec['sha256']
assert sha(ROOT/'tests/fixtures/pdf/generate_intent_transparency.py')==spec['generatorSha256']
profile=(corpus/spec['profile']['file']).read_bytes()
assert len(profile)==984 and sha(corpus/spec['profile']['file'])==PROFILE_SHA==spec['profile']['sha256']
assert profile==(ROOT/spec['profile']['origin']).read_bytes()
pdf=PdfReader(corpus/spec['file']);assert len(pdf.pages)==4
identities={};probe_ids=set()
for page_index,page in enumerate(pdf.pages,1):
assert list(page.mediabox)==[0,0,880,850]
resources=page['/Resources'];patterns=resources['/Pattern'];gs=resources['/ExtGState']
assert resources['/ColorSpace']['/CS'][1].get_object().get_data()==profile
assert resources['/ColorSpace']['/PCS'][0]=='/Pattern'
for key in ('U','ULarge'):
stencil=patterns['/'+key]
assert stencil['/PaintType']==2
assert stencil.get_data().split()==[b'0',b'0',str(stencil['/XStep']).encode(),str(stencil['/YStep']).encode(),b're',b'f']
for key in ('Tile','TileForm','ShadeForm'):
obj=patterns.raw_get('/'+key) if key=='Tile' else resources['/XObject'].raw_get('/'+key)
identities.setdefault(key,obj.idnum);assert identities[key]==obj.idnum
assert b' ri' not in patterns['/Tile'].get_data()
assert '/ExtGState' not in patterns['/Shade']
for label in ('Tile','Shade'):
form=resources['/XObject']['/'+label+'Form']
assert form.get_data().startswith(b'/AbsoluteColorimetric ri\n')
assert form['/Resources']['/Pattern'].raw_get('/Tile').idnum==identities['Tile']
for i,intent in enumerate(INTENTS):
assert ('/'+intent+' ri').encode() in patterns['/TileRi'+str(i)].get_data()
assert patterns['/ShadeRi'+str(i)]['/ExtGState']['/RI']=='/'+intent
for key in ('Mask','OpaqueBC','BC0','BC1','BC2','BC3',*[f'MaskRi{i}' for i in range(4)],
*[f'MaskGs{i}' for i in range(4)],*[f'MaskQ{i}' for i in range(4)]):
mask=gs['/'+key]['/SMask'];group=mask['/G']
assert mask['/S']=='/Luminosity' and mask['/TR']=='/Identity'
assert group['/Group']['/CS']=='/DeviceRGB' and bool(group['/Group']['/I'])
assert group['/Resources']['/ColorSpace']['/CS'][1].get_object().get_data()==profile
if key.startswith('BC'):assert group.get_data().strip()==b''
assert gs['/NoMask']['/SMask']=='/None'
assert gs['/Mask']['/SMask'].raw_get('/G').idnum==gs['/OpaqueBC']['/SMask'].raw_get('/G').idnum
content=page.get_contents().get_data()
assert content.startswith(b'1 g 0 0 880 850 re f\n')
for p in (p for p in spec['probes'] if p['page']==page_index):
assert p['id'] not in probe_ids;probe_ids.add(p['id'])
assert ('\n'.join(p['commandSequence'])+'\n').encode() in content
case=p['case'];column=p['column'];current=(2,0,2,1)[column] if case.endswith('-reuse') else column
assert p['callerIntentIndex']==current and p['callerIntent']==INTENTS[current]
expected=current;oracle='icc'
if case in ('tiling-page-initial','tiling-colored-qQ','shading-page-initial'):expected=1
elif case in ('tiling-colored-ri','tiling-colored-gs','tiling-form-ri','shading-pattern-ri','shading-form-ri',
'mask-internal-ri','mask-internal-gs'):expected=(current+1)%4
if case.startswith('mask-'):
oracle='luminosity-icc'
if case in ('mask-q-inner-none','mask-none'):oracle='black'
elif case=='mask-BC-only':oracle='luminosity-bc'
assert p['expectedIntentIndex']==expected and p['expectedIntent']==INTENTS[expected]
assert p['oracle']==oracle and p['components']==[0,.75,.25,0]
if oracle=='luminosity-bc':assert p['backgroundRgb']==[[1,0,0],[0,1,0],[0,0,1],[0,.5,1]][column]
return profile,{'pages':4,'probes':len(probe_ids),'reusedObjectIds':identities,
'uncoloredStencilsContainOnlyGeometry':True,'profileByteIdentical':True,
'expectedIntentIndependentlyChecked':True}
def oracle(probe,cmm):
kind=probe['oracle']
if kind=='black':return [0,0,0],{'kind':kind}
if kind=='luminosity-bc':rgb=[255*c for c in probe['backgroundRgb']]
else:rgb=cmm.color('distinct',probe['expectedIntentIndex'],probe['components'])
if kind=='icc':return rgb,{'kind':kind,'directCmmRgb8':rgb}
luminance=sum(w*c for w,c in zip((.30,.59,.11),rgb))
return [round(255-luminance)]*3,{'kind':kind,'groupRgb8':rgb,'luminosity8Bit':luminance,
'recommendedDeviceRgbWeights':[.30,.59,.11]}
def cmm_checks(profile,cmm,spec):
colors=[cmm.color('distinct',i,spec['sample']) for i in range(4)]
lumas=[sum(w*c for w,c in zip((.30,.59,.11),rgb)) for rgb in colors]
separation=min(abs(a-b) for a,b in itertools.combinations(lumas,2))
assert separation>2*spec['tolerance8Bit']
source=ImageCms.ImageCmsProfile(BytesIO(profile));sample=tuple(round(v*255) for v in spec['sample'])
checks=[]
for i in range(4):
transform=ImageCms.buildTransformFromOpenProfiles(source,ImageCms.createProfile('sRGB'),'CMYK','RGB',renderingIntent=i)
quantized=list(ImageCms.applyTransform(Image.new('CMYK',(1,1),sample),transform).getpixel((0,0)))
assert max(abs(a-b) for a,b in zip(quantized,colors[i]))<=1
checks.append({'intent':INTENTS[i],'directCmmRgb8':colors[i],'pillowQuantizedRgb8':quantized,
'luminosity8Bit':lumas[i],'blackOnWhiteGray8Bit':round(255-lumas[i])})
return {'values':checks,'minimumLuminositySeparation':separation,
'noRenderedPixelsUsedForExpectedValues':True}
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build-dir',type=Path)
parser.add_argument('--pdfium-library',type=Path)
parser.add_argument('--corpus',type=Path,default=CORPUS)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--self-check',action='store_true')
parser.add_argument('--scales',type=float,nargs='+',default=[.5,1.,2.])
args=parser.parse_args()
if not args.self_check and not args.build_dir:parser.error('--build-dir required for rendering')
if not 1<=len(args.scales)<=4 or len(set(args.scales))!=len(args.scales) or any(not math.isfinite(v) or not .25<=v<=4 for v in args.scales):
parser.error('scales must be 1..4 unique finite values in .25..4')
output=args.output.resolve();output.mkdir(parents=True,exist_ok=False)
old=preserved_inputs();spec=json.loads((args.corpus/'manifest.json').read_text());source=(args.corpus/spec['file']).resolve()
report={'schemaVersion':1,'success':False,'renderingAcceptance':False,'fixtureValidationSuccess':False,
'mode':'fixture-self-check' if args.self_check else 'renderer-comparison',
'runnerSha256':sha(Path(__file__)),'sharedCmmAdapterSha256':sha(Path(__file__).with_name('intents.py')),
'fixtureManifestSha256':sha(args.corpus/'manifest.json'),'fixtureSha256':sha(source),
'pythonVersion':platform.python_version(),'pillowLcmsVersion':ImageCms.core.littlecms_version,
'tolerance8Bit':4,'commands':[],'scales':[],'preservedLegacyInputs':old,
'scope':'Synthetic ICC RI dispatch and state probes. Direct public LittleCMS calls supply colors, not renderer pixels; '
'the renderers may use the same CMM. DeviceRGB luminosity uses the PDF recommended weights, which are device-dependent. '
'No physical-display, press, general ICC, or all-PDF acceptance claim.'}
cmm=None;environment=os.environ.copy();binaries={}
try:
profile,verification=verify(args.corpus,spec);report['fixtureVerification']=verification
cmm=Cmm({'distinct':profile})
report['directCmm']={'encodedVersion':cmm.version,'libraries':cmm.libraryPaths,
'input':'CMYK double percent','output':'8-bit sRGB','flags':0}
report['cmmChecks']=cmm_checks(profile,cmm,spec);report['fixtureValidationSuccess']=True
if not args.self_check:
build=args.build_dir.resolve()
binaries={name:sha(build/name) for name in ('pdf-worker-evidence','docview-pdf-worker')};report['binaries']=binaries
if args.pdfium_library:
environment['LD_LIBRARY_PATH']=str(args.pdfium_library.resolve().parent)+(':'+environment['LD_LIBRARY_PATH'] if environment.get('LD_LIBRARY_PATH') else '')
assert not environment.get('LD_PRELOAD') and not environment.get('LD_AUDIT')
report['pdfiumLibrary']=loaded_pdfium(build/'docview-pdf-worker',environment,args.pdfium_library)
for program in ('qpdf','pdftoppm'):assert shutil.which(program)
report['popplerVersion']=subprocess.run(['pdftoppm','-v'],capture_output=True,text=True,check=True).stderr.splitlines()[0]
def run(label,command,env=None):
log=output/(label+'.log')
with log.open('w') as f:result=subprocess.run(command,stdout=f,stderr=subprocess.STDOUT,env=env,timeout=300)
report['commands'].append({'name':label,'command':command,'exitCode':result.returncode,'logSha256':sha(log)})
assert result.returncode==0,label+' failed'
run('qpdf-check',['qpdf','--check',str(source)])
for scale in args.scales:
label='scale-'+str(scale).replace('.','_');folder=output/label;(folder/'pdfium').mkdir(parents=True)
run(label+'-pdfium',[str(build/'pdf-worker-evidence'),str(source),str(folder/'pdfium'),str(scale)],environment)
run(label+'-poppler',['pdftoppm','-cropbox','-r',str(72*scale),'-png',str(source),str(folder/'poppler')])
metadata=json.loads((folder/'pdfium/metadata.json').read_text())
assert metadata['pageCount']==spec['pageCount'] and metadata['renderScale']==scale and 'sandbox enabled' in metadata['transport']
row={'scale':scale,'probes':[],'pdfiumSuccess':True,'popplerSuccess':True,'pages':[]}
for page in range(1,5):
paths={'pdfium':folder/'pdfium'/f'page-{page}.png','poppler':folder/f'poppler-{page}.png'}
images={key:Image.open(path).convert('RGB') for key,path in paths.items()}
assert all(list(im.size)==[math.ceil(v*scale) for v in spec['pageSizePt']] for im in images.values())
for probe in (p for p in spec['probes'] if p['page']==page):
x,y=probe['pointPt'];pixel=(round(x*scale),round((850-y)*scale))
expected,details=oracle(probe,cmm)
# Interior 3x3 minimum/maximum detects seams/unstable edge
# samples instead of silently selecting a favorable pixel.
samples={key:[list(im.getpixel((pixel[0]+dx,pixel[1]+dy))) for dy in (-1,0,1) for dx in (-1,0,1)] for key,im in images.items()}
actual={key:values[4] for key,values in samples.items()}
errors={key:max(abs(a-b) for color in values for a,b in zip(color,expected)) for key,values in samples.items()}
matches={key:err<=4 for key,err in errors.items()}
row['probes'].append({**probe,'pointPx':pixel,'oracleDetails':details,'expectedRgb':expected,
'actualRgb':actual,'neighborhoodMinRgb':{k:[min(c[i] for c in v) for i in range(3)] for k,v in samples.items()},
'neighborhoodMaxRgb':{k:[max(c[i] for c in v) for i in range(3)] for k,v in samples.items()},
'maxChannelError':errors,'matches':matches})
for key,match in matches.items():row[key+'Success'] &= match
diff=ImageChops.difference(images['pdfium'],images['poppler']);diff.save(folder/f'difference-{page}.png')
sheet=Image.new('RGB',(1260,440),'#eeeeee');draw=ImageDraw.Draw(sheet)
for col,(key,im) in enumerate([*images.items(),('difference',diff)]):
draw.text((col*420+8,7),f'{scale:g}x page {page}: {key}',fill='black')
thumb=im.copy();thumb.thumbnail((410,400));sheet.paste(thumb,(col*420+5,30))
sheet.save(folder/f'comparison-{page}.png')
row['pages'].append({'page':page,'images':{key:sha(path) for key,path in paths.items()}})
row['failedProbes']={engine:sum(not p['matches'][engine] for p in row['probes']) for engine in ('pdfium','poppler')}
row['success']=row['pdfiumSuccess'] and row['popplerSuccess'];report['scales'].append(row)
report['pdfiumSuccess']=all(r['pdfiumSuccess'] for r in report['scales'])
report['popplerSuccess']=all(r['popplerSuccess'] for r in report['scales'])
report['binariesUnchanged']=all(sha(build/n)==value for n,value in binaries.items()) and sha(Path(report['pdfiumLibrary']['path']))==report['pdfiumLibrary']['sha256']
report['originalUnchanged']=sha(source)==spec['sha256'] and sha(args.corpus/spec['profile']['file'])==PROFILE_SHA
report['legacyInputsUnchanged']=preserved_inputs()==old
if not args.self_check:
report['success']=report['renderingAcceptance']=all(report[k] for k in ('pdfiumSuccess','popplerSuccess','binariesUnchanged','originalUnchanged','legacyInputsUnchanged'))
except Exception as error:
report['error']=type(error).__name__+': '+str(error)
raise
finally:
if cmm:cmm.close()
report['evidenceSha256']={str(p.relative_to(output)):sha(p) for p in sorted(output.rglob('*')) if p.is_file()}
(output/'report.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps({key:report.get(key) for key in ('mode','fixtureValidationSuccess','success','pdfiumSuccess','popplerSuccess','legacyInputsUnchanged')}))
if not args.self_check and not report['success']:raise SystemExit('Pixel criteria failed; every failing probe remains in report.json')
if __name__=='__main__':main()