149 lines
8.3 KiB
Python
149 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Author one CMYK ICCBased PDF with a known four-dimensional Lab CLUT.
|
|
|
|
The ICC v2 input profile is encoded directly from the ICC specification;
|
|
no renderer/CMM output or borrowed press profile defines the expected values.
|
|
"""
|
|
import argparse
|
|
from io import BytesIO
|
|
import hashlib
|
|
import itertools
|
|
import json
|
|
from pathlib import Path
|
|
import struct
|
|
|
|
from pypdf import PdfReader, PdfWriter
|
|
from pypdf.generic import ArrayObject, DecodedStreamObject, DictionaryObject, NameObject, NumberObject
|
|
from reportlab.pdfgen import canvas
|
|
|
|
WIDTH, HEIGHT = 720, 420
|
|
SAMPLES = [(0, 0, 0, 0), (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0),
|
|
(0, 0, 0, 1), (.25, .5, .75, .2), (.6, .2, .4, .1), (1, 1, 1, 1)]
|
|
|
|
|
|
def model(cmyk):
|
|
c, m, y, k = cmyk
|
|
return 100 - 12*c - 15*m - 10*y - 35*k, 18*m - 15*c, 20*y - 12*c
|
|
|
|
|
|
def expected_rgb(cmyk):
|
|
# CIELAB inverse, D50 -> D65 Bradford adaptation, then standard sRGB.
|
|
lightness, a, b = model(cmyk)
|
|
fy = (lightness + 16) / 116
|
|
f = (fy + a/500, fy, fy - b/200)
|
|
xyz = [white * (v**3 if v > 6/29 else 3*(6/29)**2*(v-4/29))
|
|
for white, v in zip((.9642, 1, .8249), f)]
|
|
def multiply(matrix, vector):
|
|
return [sum(a*b for a, b in zip(row, vector)) for row in matrix]
|
|
d65 = multiply(((.9555766, -.0230393, .0631636), (-.0282895, 1.0099416, .0210077),
|
|
(.0122982, -.0204830, 1.3299098)), xyz)
|
|
linear = multiply(((3.2404542, -1.5371385, -.4985314), (-.969266, 1.8760108, .041556),
|
|
(.0556434, -.2040259, 1.0572252)), d65)
|
|
return [round(255*max(0, min(1, 12.92*v if v <= .0031308 else 1.055*v**(1/2.4)-.055))) for v in linear]
|
|
|
|
|
|
def profile_bytes():
|
|
def fixed(values):
|
|
return struct.pack('>' + 'i'*len(values), *(round(v*65536) for v in values))
|
|
header = bytearray(128)
|
|
header[8:12] = bytes.fromhex('02100000')
|
|
header[12:24] = b'scnrCMYKLab '
|
|
header[24:36] = struct.pack('>6H', 2026, 9, 20, 0, 0, 0)
|
|
header[36:40] = b'acsp'
|
|
header[68:80] = fixed((.9642, 1, .8249))
|
|
header[80:84] = b'DCVW'
|
|
description = b'DocView synthetic CMYK Lab CLUT\0'
|
|
desc = b'desc' + bytes(4) + struct.pack('>I', len(description)) + description + bytes(78)
|
|
copyright_tag = b'text' + bytes(4) + b'No copyright, use freely. Synthetic test profile; not a press characterization.\0'
|
|
table = bytearray(b'mft2' + bytes(4) + bytes((4, 3, 2, 0)))
|
|
table += fixed((1, 0, 0, 0, 1, 0, 0, 0, 1)) + struct.pack('>HH', 2, 2)
|
|
table += struct.pack('>HH', 0, 65535) * 4
|
|
for cmyk in itertools.product((0, 1), repeat=4):
|
|
lightness, a, b = model(cmyk)
|
|
table += struct.pack('>3H', round(lightness/100*65280), round((a+128)*256), round((b+128)*256))
|
|
table += struct.pack('>HH', 0, 65535) * 3
|
|
assert len(table) == 176
|
|
tags = [(b'desc', desc), (b'cprt', copyright_tag),
|
|
(b'wtpt', b'XYZ ' + bytes(4) + fixed((.9642, 1, .8249))), (b'A2B0', bytes(table))]
|
|
body = bytearray(); entries = bytearray(); offset = 132 + 12*len(tags)
|
|
for signature, data in tags:
|
|
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)
|
|
profile = profile_bytes()
|
|
(output / 'synthetic-cmyk-lab.icc').write_bytes(profile)
|
|
buffer = BytesIO()
|
|
page = canvas.Canvas(buffer, pagesize=(WIDTH, HEIGHT), invariant=1, pageCompression=0)
|
|
page.setTitle('ICC CMYK - four-dimensional lookup table')
|
|
page.setAuthor('DocView test corpus')
|
|
page.setFont('Helvetica-Bold', 18)
|
|
page.drawString(36, 385, 'ICC CMYK - FOUR-DIMENSIONAL LOOKUP TABLE')
|
|
page.setFont('Helvetica', 10)
|
|
page.drawString(36, 365, 'Synthetic CMYK to Lab mapping. Fixed D50 white point. No press calibration.')
|
|
for y, label in [(336, 'A ICCBased vector'), (238, 'B ICCBased image (8-bit components)'),
|
|
(140, 'C DeviceCMYK control (profile not applied)')]:
|
|
page.setFont('Helvetica-Bold', 10); page.drawString(36, y, label)
|
|
page.setFont('Helvetica', 9)
|
|
for i, label in enumerate(['White', 'Cyan', 'Magenta', 'Yellow', 'Black', 'Mix A', 'Mix B', 'All inks']):
|
|
page.drawCentredString(36+i*81+35, 48, label)
|
|
page.setFont('Helvetica', 9)
|
|
page.drawString(36, 25, 'A/B must agree with the analytic Lab mapping. C deliberately uses a different color space.')
|
|
page.save()
|
|
writer = PdfWriter(); writer.append(PdfReader(BytesIO(buffer.getvalue())))
|
|
target = writer.pages[0]
|
|
icc = DecodedStreamObject(); icc.set_data(profile)
|
|
icc.update({NameObject('/N'): NumberObject(4), NameObject('/Alternate'): NameObject('/DeviceCMYK')})
|
|
icc_ref = writer._add_object(icc)
|
|
space = ArrayObject([NameObject('/ICCBased'), icc_ref])
|
|
target['/Resources'][NameObject('/ColorSpace')] = DictionaryObject({NameObject('/TestCMYK'): space})
|
|
image = DecodedStreamObject(); image.set_data(bytes(round(v*255) for cmyk in SAMPLES for v in cmyk))
|
|
image.update({NameObject('/Type'): NameObject('/XObject'), NameObject('/Subtype'): NameObject('/Image'),
|
|
NameObject('/Width'): NumberObject(8), NameObject('/Height'): NumberObject(1),
|
|
NameObject('/BitsPerComponent'): NumberObject(8), NameObject('/ColorSpace'): space,
|
|
NameObject('/Intent'): NameObject('/RelativeColorimetric')})
|
|
target['/Resources'][NameObject('/XObject')] = DictionaryObject({NameObject('/Sample'): writer._add_object(image)})
|
|
probes, commands = [], ['/RelativeColorimetric ri']
|
|
for i, components in enumerate(SAMPLES):
|
|
x = 36+i*81
|
|
operands = ' '.join(f'{v:g}' for v in components)
|
|
commands += [f'/TestCMYK cs {operands} scn {x} 260 70 64 re f',
|
|
f'{operands} k {x} 64 70 64 re f']
|
|
probes.append({'kind': 'icc-vector', 'sample': i, 'pointPt': [x+35, 292], 'components': list(components),
|
|
'lab': list(model(components)), 'expectedRgb': expected_rgb(components)})
|
|
quantized = [round(v*255)/255 for v in components]
|
|
probes.append({'kind': 'icc-image', 'sample': i, 'pointPt': [36+(i+.5)*81, 194], 'components': quantized,
|
|
'lab': list(model(quantized)), 'expectedRgb': expected_rgb(quantized)})
|
|
probes.append({'kind': 'device-control', 'sample': i, 'pointPt': [x+35, 96], 'components': list(components)})
|
|
commands += ['q 648 0 0 64 36 162 cm /Sample Do Q']
|
|
content = DecodedStreamObject(); content.set_data(('\n'.join(commands)+'\n').encode())
|
|
target[NameObject('/Contents')] = ArrayObject([target.raw_get('/Contents'), writer._add_object(content)])
|
|
pdf = output / 'icc-cmyk-lut.pdf'
|
|
with pdf.open('wb') as stream: writer.write(stream)
|
|
def digest(data): return hashlib.sha256(data).hexdigest()
|
|
record = {'file': pdf.name, 'sha256': digest(pdf.read_bytes()), 'profileFile': 'synthetic-cmyk-lab.icc',
|
|
'profileSha256': digest(profile), 'profileBytes': len(profile), 'pageSizePt': [WIDTH, HEIGHT],
|
|
'profile': {'version': '2.1', 'class': 'scnr', 'input': 'CMYK', 'pcs': 'Lab ',
|
|
'tag': 'A2B0', 'type': 'mft2', 'gridPointsPerChannel': 2,
|
|
'mapping': ['L=100-12*C-15*M-10*Y-35*K', 'a=18*M-15*C', 'b=20*Y-12*C'],
|
|
'labEncoding': 'ICC v2 L: 0..65280 for 0..100; a/b: (value+128)*256',
|
|
'source': 'Self-authored binary ICC input profile; no borrowed ICC profile'},
|
|
'probes': probes, 'tolerance8Bit': 4,
|
|
'references': ['https://archive.color.org/files/icc32.pdf', 'https://www.color.org/chardata/rgb/srgb.xalter'],
|
|
'limits': ['Synthetic CMYK input CLUT; not a measured print profile or calibrated press proof.',
|
|
'No human-approved golden; no physical display or Windows validation.'],
|
|
'generatorSha256': digest(Path(__file__).read_bytes())}
|
|
(output / 'manifest.json').write_text(json.dumps(record, indent=2) + '\n')
|
|
print(json.dumps({'fixtureSha256': record['sha256'], 'profileSha256': record['profileSha256'], 'probes': len(probes)}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
generate(parser.parse_args().output)
|