#!/usr/bin/env python3 """Self-authored RI probes for luminosity masks and pattern initial state.""" import argparse import hashlib import json from pathlib import Path from pypdf import PdfWriter from pypdf.generic import (ArrayObject, BooleanObject, DecodedStreamObject, DictionaryObject, FloatObject, NameObject, NumberObject) ROOT = Path(__file__).resolve().parents[3] SOURCE = ROOT / 'tests/fixtures/pdf/rendering-intents/distinct-cmyk-lab.icc' PROFILE_SHA = 'b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315' INTENTS = ('Perceptual', 'RelativeColorimetric', 'Saturation', 'AbsoluteColorimetric') SAMPLE = (0, .75, .25, 0) WIDTH, HEIGHT = 880, 850 CASES = ( 'direct-icc-control', 'mask-caller-ri', 'mask-internal-ri', 'mask-internal-gs', 'mask-group-qQ', 'mask-q-inner-none', 'mask-Q-restores', 'mask-none', 'mask-BC-only', 'mask-opaque-over-BC', 'tiling-page-initial', 'tiling-colored-ri', 'tiling-colored-gs', 'tiling-colored-qQ', 'tiling-form-initial', 'tiling-form-initial-reuse', 'tiling-form-ri', 'tiling-uncolored-fill', 'tiling-uncolored-fill-gs', 'tiling-uncolored-stroke-small', 'tiling-uncolored-stroke-large', 'shading-page-initial', 'shading-pattern-ri', 'shading-form-initial', 'shading-form-initial-reuse', 'shading-form-ri', 'direct-shading-control', ) def digest(data): return hashlib.sha256(data).hexdigest() def n(value): return NameObject('/' + value) def a(values): return ArrayObject([FloatObject(v) for v in values]) def d(**values): return DictionaryObject({n(k): v for k, v in values.items()}) def numbers(values): return ' '.join(format(v, '.10g') for v in values) def generate(output): output.mkdir(parents=True, exist_ok=False) profile = SOURCE.read_bytes() assert digest(profile) == PROFILE_SHA (output / SOURCE.name).write_bytes(profile) writer = PdfWriter(); writer.pdf_header = '%PDF-1.7' writer.add_metadata({'/Title': 'Rendering intent: soft masks and pattern initial state', '/Author': 'DocView synthetic regression corpus'}) def stream(data, **entries): obj = DecodedStreamObject(); obj.set_data(data.encode() if isinstance(data, str) else data) obj.update(d(**entries)); return writer._add_object(obj) cs = ArrayObject([n('ICCBased'), stream(profile, N=NumberObject(4), Alternate=n('DeviceCMYK'))]) colors = d(CS=cs, PCS=ArrayObject([n('Pattern'), cs])) gs = d(**{'I'+str(i): d(Type=n('ExtGState'), RI=n(intent)) for i, intent in enumerate(INTENTS)}) gs[n('NoMask')] = d(Type=n('ExtGState'), SMask=n('None')) font = writer._add_object(d(Type=n('Font'), Subtype=n('Type1'), BaseFont=n('Helvetica'))) base = d(ColorSpace=colors, ExtGState=gs) paint = '/CS cs ' + numbers(SAMPLE) + ' scn ' group_attributes = d(S=n('Transparency'), CS=n('DeviceRGB'), I=BooleanObject(True), K=BooleanObject(False)) def group(prefix='', empty=False): return stream(prefix + '\n' + ('' if empty else paint + f'0 0 {WIDTH} {HEIGHT} re f\n'), Type=n('XObject'), Subtype=n('Form'), BBox=a((0,0,WIDTH,HEIGHT)), Group=group_attributes, Resources=base) shared_group = group() empty_group = group(empty=True) def mask(name, group_ref, bc=(0,0,0)): gs[n(name)] = d(Type=n('ExtGState'), SMask=d(S=n('Luminosity'), G=group_ref, BC=a(bc), TR=n('Identity'))) mask('Mask', shared_group) mask('OpaqueBC', shared_group, (0,.5,1)) backgrounds = ((1,0,0), (0,1,0), (0,0,1), (0,.5,1)) for i, intent in enumerate(INTENTS): mask('MaskRi'+str(i), group('/'+intent+' ri')) mask('MaskGs'+str(i), group('/I'+str(i)+' gs')) mask('MaskQ'+str(i), group('q /'+intent+' ri Q')) mask('BC'+str(i), empty_group, backgrounds[i]) patterns = d() def tiling(prefix='', colored=True, size=16): content = prefix+'\n'+(paint if colored else '')+f'0 0 {size} {size} re f\n' return stream(content, Type=n('Pattern'), PatternType=NumberObject(1), PaintType=NumberObject(1 if colored else 2), TilingType=NumberObject(2), BBox=a((0,0,size,size)), XStep=NumberObject(size), YStep=NumberObject(size), Resources=base if colored else d()) patterns[n('Tile')] = tiling() patterns[n('U')] = tiling(colored=False, size=8) patterns[n('ULarge')] = tiling(colored=False, size=1024) for i, intent in enumerate(INTENTS): patterns[n('TileRi'+str(i))] = tiling('/'+intent+' ri') patterns[n('TileGs'+str(i))] = tiling('/I'+str(i)+' gs') patterns[n('TileQ'+str(i))] = tiling('q /'+intent+' ri Q') # Constant ICC axial function eliminates interpolation-position uncertainty. function = writer._add_object(d(FunctionType=NumberObject(2), Domain=a((0,1)), C0=a(SAMPLE), C1=a(SAMPLE), N=NumberObject(1))) shading = writer._add_object(d(ShadingType=NumberObject(2), ColorSpace=cs, Coords=a((0,0,WIDTH,0)), Function=function, Extend=ArrayObject([BooleanObject(True),BooleanObject(True)]))) patterns[n('Shade')] = d(Type=n('Pattern'), PatternType=NumberObject(2), Shading=shading) for i, intent in enumerate(INTENTS): patterns[n('ShadeRi'+str(i))] = d(Type=n('Pattern'), PatternType=NumberObject(2), Shading=shading, ExtGState=d(RI=n(intent))) # A shared Form is invoked under distinct initial RI values. Its painting RI # is always Absolute, which must not replace the pattern's stream-start RI. form_resources = d(ColorSpace=colors, ExtGState=gs, Pattern=patterns) xobjects = d() for label, pattern_name in (('Tile','Tile'),('Shade','Shade')): xobjects[n(label+'Form')] = stream('/AbsoluteColorimetric ri\n/Pattern cs /'+pattern_name+' scn 0 0 90 42 re f\n', Type=n('XObject'), Subtype=n('Form'), BBox=a((0,0,90,42)), Resources=form_resources) for i in range(4): xobjects[n(label+'FormRi'+str(i))] = stream('/AbsoluteColorimetric ri\n/Pattern cs /'+pattern_name+'Ri'+str(i)+' scn 0 0 90 42 re f\n', Type=n('XObject'), Subtype=n('Form'), BBox=a((0,0,90,42)), Resources=form_resources) resources = d(ColorSpace=colors, ExtGState=gs, Pattern=patterns, XObject=xobjects, Shading=d(A=shading), Font=d(F=font)) probes=[] for group_index in range((len(CASES)+6)//7): page=writer.add_blank_page(WIDTH,HEIGHT);page[n('Resources')]=resources commands=[f'1 g 0 0 {WIDTH} {HEIGHT} re f', f'0 g BT /F 18 Tf 24 818 Td (Soft masks and pattern state - {group_index+1}) Tj ET', '0 g BT /F 9 Tf 24 799 Td (Synthetic ICC; expected intent comes from PDF state rules, colors from a direct CMM.) Tj ET'] for i,intent in enumerate(INTENTS): commands.append(f'0 g BT /F 9 Tf {320+i*132} 771 Td ({intent}) Tj ET') for row,case in enumerate(CASES[group_index*7:group_index*7+7]): y=684-row*96 commands.append(f'0 g BT /F 10 Tf 24 {y+22} Td ({case}) Tj ET') for column in range(4): current=(2,0,2,1)[column] if case.endswith('-reuse') else column other=(current+1)%4;expected=current;kind='icc';bc=None x=320+column*132;rect=f'{x} {y} 90 42 re f' start=['q','/'+INTENTS[current]+' ri'];body=[] if case=='direct-icc-control': body=[paint+rect] elif case.startswith('mask-'): kind='luminosity-icc';mask_name='Mask' if case=='mask-internal-ri':mask_name='MaskRi'+str(other);expected=other elif case=='mask-internal-gs':mask_name='MaskGs'+str(other);expected=other elif case=='mask-group-qQ':mask_name='MaskQ'+str(other) elif case=='mask-BC-only':mask_name='BC'+str(column);bc=backgrounds[column];kind='luminosity-bc' elif case=='mask-opaque-over-BC':mask_name='OpaqueBC' body=['/'+mask_name+' gs'] if case=='mask-q-inner-none':body+=['q /NoMask gs','0 g '+rect,'Q'];kind='black' elif case=='mask-Q-restores':body+=['q /NoMask gs Q','0 g '+rect] elif case=='mask-none':body+=['/NoMask gs','0 g '+rect];kind='black' else:body+=['0 g '+rect] elif case.startswith('tiling-uncolored'): if case.endswith('-gs'):start=['q','/I'+str(current)+' gs'] if 'stroke' in case: pattern='ULarge' if case.endswith('-large') else 'U' body=['1 0 0 rg','/PCS CS '+numbers(SAMPLE)+' /'+pattern+' SCN', f'30 w {x} {y+21} m {x+90} {y+21} l S'] else:body=['/PCS cs '+numbers(SAMPLE)+' /U scn '+rect] elif case.startswith('tiling-') or case.startswith('shading-'): label='Tile' if case.startswith('tiling-') else 'Shade';pattern=label if '-form-' in case: form_name=label+'Form' if case.endswith('-ri'):form_name+='Ri'+str(other);expected=other body=[f'1 0 0 1 {x} {y} cm /'+form_name+' Do'] else: expected=1 if case.endswith('-ri'):pattern+='Ri'+str(other);expected=other elif case.endswith('-gs'):pattern+='Gs'+str(other);expected=other elif case.endswith('-qQ'):pattern+='Q'+str(other) body=['/Pattern cs /'+pattern+' scn '+rect] elif case=='direct-shading-control':body=[f'{x} {y} 90 42 re W n','/A sh'] else:raise AssertionError(case) sequence=start+body+['Q'];commands+=sequence probes.append({'id':case+'-'+str(column),'case':case,'column':column,'page':group_index+1, 'callerIntentIndex':current,'callerIntent':INTENTS[current], 'expectedIntentIndex':expected,'expectedIntent':INTENTS[expected], 'oracle':kind,'components':list(SAMPLE),'backgroundRgb':list(bc) if bc else None, 'pointPt':[x+45,y+21],'commandSequence':sequence}) page[n('Contents')]=stream('\n'.join(commands)+'\n') target=output/'intent-transparency.pdf';writer.write(target) manifest={'schemaVersion':1,'file':target.name,'sha256':digest(target.read_bytes()), 'generatorSha256':digest(Path(__file__).read_bytes()),'pageCount':len(writer.pages),'pageSizePt':[WIDTH,HEIGHT], 'profile':{'file':SOURCE.name,'sha256':PROFILE_SHA,'size':len(profile),'origin':str(SOURCE.relative_to(ROOT))}, 'sample':list(SAMPLE),'tolerance8Bit':4,'cases':list(CASES),'probes':probes, 'specification':{'url':'https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf', 'printedPages':{'uncoloredForbiddenOperators':218,'tilingInitialState':223,'shadingInitialState':231, 'deviceRgbGray':377,'luminosityMask':440,'softMaskDictionary':446,'transparentPatterns':453}}, 'luminosityOracle':{'weights':[.30,.59,.11],'foregroundRgb':[0,0,0],'pageBackgroundRgb':[255,255,255], 'formula':'round(255 - dot(weights, directCmmRgb8))', 'qualification':'DeviceRGB gray conversion is device-dependent; these are the PDF recommended weights. Intent dispatch is tested separately from that numerical convention.'}} (output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') (output/'README.md').write_text('''# Soft-mask and pattern rendering-intent probes This self-authored 4-page PDF contains 108 declared pixel probes. The existing 984-byte distinct CMYK/Lab ICC profile is copied without alteration. Its four intent outputs for CMYK (0, .75, .25, 0) have separated luminances; no PDF renderer supplies the expected colors. [Adobe PDF Reference 1.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf) pp. 218, 223, 231 and 453 define pattern state. Colored tiling and shading patterns start with their resource-owning page/Form stream's initial state. A Form is reused under different initial intents, then changes its painting intent to Absolute. Explicit pattern overrides and q/Q are separate controls. Uncolored stencils contain no color operators or `ri`; their ICC base color is supplied by the caller. Small/large uncolored strokes distinguish stroke from fill color. Pages 440–446 define luminosity soft masks and their backdrop. The mask's RGB group contains one opaque ICC rectangle, or no objects for the BC-only controls. The direct CMM RGB result is reduced with the recommended .30/.59/.11 weights; black over white has value 255 minus that luminosity. This device-dependent formula is a declared test convention, not universal device calibration. Run `tests/cmyk_lut/transparency.py --self-check --output NEW_DIRECTORY`, or pass `--build-dir build --pdfium-library PATH/libpdfium.so --output NEW_DIRECTORY`. The runner records all failures, validates the sandbox helper and library hash, and judges PDFium and Poppler separately against the direct CMM. LittleCMS may be shared with the renderers; this is not an independent CMM implementation or general ICC conformance acceptance. The older 8-page intent fixture is unchanged. ''') print(json.dumps({'file':str(target),'sha256':manifest['sha256'],'pages':len(writer.pages),'probes':len(probes)})) if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--output',type=Path,default=Path(__file__).parent/'intent-transparency') generate(parser.parse_args().output)