#!/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)