Files
2026-09-21 13:41:40 +09:00

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)