215 lines
14 KiB
Python
215 lines
14 KiB
Python
#!/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)
|