Files
docview/tests/fixtures/pdf/generate_intent_transparency.py
2026-09-21 13:41:40 +09:00

215 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)