#!/usr/bin/env python3 """Self-authored linear-sRGB ICCBased vector/image fixture; no borrowed ICC data. Uses public LittleCMS APIs to encode D65 + sRGB primaries + gamma 1. The color expectation is the analytic sRGB transfer function, independent of PDF renderer output. Only the ICC creation timestamp is normalized for determinism. """ from pathlib import Path import ctypes as C import ctypes.util import hashlib import json import shutil import struct class xyY(C.Structure): _fields_ = [("x", C.c_double), ("y", C.c_double), ("Y", C.c_double)] class Primaries(C.Structure): _fields_ = [("Red", xyY), ("Green", xyY), ("Blue", xyY)] def linear_profile(): cms = C.CDLL(ctypes.util.find_library("lcms2") or "liblcms2.so") cms.cmsGetEncodedCMMversion.restype = C.c_uint32 cms.cmsBuildGamma.argtypes = [C.c_void_p, C.c_double]; cms.cmsBuildGamma.restype = C.c_void_p cms.cmsFreeToneCurve.argtypes = [C.c_void_p] cms.cmsCreateRGBProfile.argtypes = [C.POINTER(xyY), C.POINTER(Primaries), C.POINTER(C.c_void_p)] cms.cmsCreateRGBProfile.restype = C.c_void_p cms.cmsSaveProfileToMem.argtypes = [C.c_void_p, C.c_void_p, C.POINTER(C.c_uint32)] cms.cmsSaveProfileToMem.restype = C.c_int cms.cmsCloseProfile.argtypes = [C.c_void_p] curve = cms.cmsBuildGamma(None, 1.0) if not curve: raise RuntimeError("LittleCMS gamma creation failed") white = xyY(.3127, .3290, 1.) primaries = Primaries(xyY(.64, .33, 1.), xyY(.30, .60, 1.), xyY(.15, .06, 1.)) curves = (C.c_void_p * 3)(curve, curve, curve) profile = cms.cmsCreateRGBProfile(C.byref(white), C.byref(primaries), curves) cms.cmsFreeToneCurve(curve) if not profile: raise RuntimeError("LittleCMS profile creation failed") try: length = C.c_uint32() if not cms.cmsSaveProfileToMem(profile, None, C.byref(length)): raise RuntimeError("ICC size failed") buffer = C.create_string_buffer(length.value) if not cms.cmsSaveProfileToMem(profile, buffer, C.byref(length)): raise RuntimeError("ICC save failed") data = bytearray(buffer.raw[:length.value]) finally: cms.cmsCloseProfile(profile) data[24:36] = struct.pack(">6H", 2026, 9, 19, 0, 0, 0) data[84:100] = bytes(16) # Zero profile ID is permitted; no stale timestamp-derived ID. assert data[36:40] == b"acsp" and data[16:20] == b"RGB " return bytes(data), cms.cmsGetEncodedCMMversion() def srgb(value): encoded = 12.92 * value if value <= .0031308 else 1.055 * value ** (1 / 2.4) - .055 return round(255 * encoded) def pdf_stream(data, extra=b""): return b"<< /Length " + str(len(data)).encode() + b" " + extra + b" >>\nstream\n" + data + b"\nendstream" def generate(out): out = Path(out); out.mkdir(parents=True, exist_ok=True) profile, version = linear_profile() (out / "linear-srgb.icc").write_bytes(profile) license_path = Path("/usr/share/licenses/lcms2/LICENSE") if license_path.is_file(): shutil.copyfile(license_path, out / "LCMS2-LICENSE.txt") values = [(.0625, .0625, .0625), (.25, .25, .25), (.5, .5, .5), (.75, .75, .75), (.5, .125, .0625), (.0625, .25, .5)] content = ["0 0 0 rg BT /F1 16 Tf 30 325 Td (EMBEDDED ICC: LINEAR RGB TO sRGB) Tj ET", "BT /F1 10 Tf 30 298 Td (Top: ICCBased vector / Middle: DeviceRGB control / Bottom: ICCBased image) Tj ET", "/RelativeColorimetric ri"] probes = [] for i, value in enumerate(values): x = 30 + i * 88 operands = " ".join(str(v) for v in value) content += [f"/Linear cs {operands} scn {x} 216 76 64 re f", f"{operands} rg {x} 132 76 64 re f"] probes.append({"kind": "icc-vector", "point_pt": [x+38, 248], "components": value, "expected_rgb": [srgb(v) for v in value]}) probes.append({"kind": "device-control", "point_pt": [x+38, 164], "components": value, "expected_rgb": [round(v*255) for v in value]}) quantized = [round(v*255) for v in value] probes.append({"kind": "icc-image", "point_pt": [30+i*88+44, 68], "components": quantized, "expected_rgb": [srgb(v/255) for v in quantized]}) content += ["q 528 0 0 48 30 44 cm /Sample Do Q", "0 0 0 rg BT /F1 10 Tf 30 22 Td (ICC gamma 1 / D65 / sRGB primaries / original data and coordinates) Tj ET"] image = bytes(round(v*255) for rgb in values for v in rgb) objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 350] /Resources << /Font << /F1 6 0 R >> /ColorSpace << /Linear [/ICCBased 5 0 R] >> /XObject << /Sample 7 0 R >> >> /Contents 4 0 R >>", pdf_stream("\n".join(content).encode()), pdf_stream(profile,b"/N 3 /Alternate /DeviceRGB /Range [0 1 0 1 0 1]"), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", pdf_stream(image,b"/Type /XObject /Subtype /Image /Width 6 /Height 1 /BitsPerComponent 8 /Interpolate false /ColorSpace [/ICCBased 5 0 R] /Intent /RelativeColorimetric")] data = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"); offsets=[0] for i, obj in enumerate(objects,1): offsets.append(len(data)); data += f"{i} 0 obj\n".encode()+obj+b"\nendobj\n" xref=len(data); data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode() for offset in offsets[1:]: data += f"{offset:010d} 00000 n \n".encode() data += f"trailer << /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() name="icc-linear-rgb.pdf"; (out/name).write_bytes(data) record={"file":name,"sha256":hashlib.sha256(data).hexdigest(),"page_count":1, "expected":{"page_sizes_pt":[[600,350]],"icc_probes":probes,"probe_tolerance_rgb":3, "profile_effect":"ICCBased 0.5 linear gray becomes approximately 188, distinct from DeviceRGB approximately 128."}, "limits":["One RGB matrix/TRC profile, not arbitrary ICC LUT/CMYK/printing conformance.","Analytic sRGB color expectation; independent renderer comparison still requires visual review."]} origin={"file":"linear-srgb.icc","sha256":hashlib.sha256(profile).hexdigest(),"bytes":len(profile), "source":"Self-created with LittleCMS cmsCreateRGBProfile using the stated numeric white point, primaries and gamma; no third-party ICC profile is copied.", "lcms_encoded_version":version,"lcms_license":"MIT; LCMS2-LICENSE.txt", "upstream":"https://github.com/mm2/Little-CMS","profile_copyright_tag":"No copyright, use freely", "white_xy":[.3127,.3290],"primaries_xy":[[.64,.33],[.30,.60],[.15,.06]],"gamma":1, "timestamp_normalization":"Header creation time is fixed to 2026-09-19; optional profile ID is zero."} return record,origin if __name__ == "__main__": out=Path(__file__).resolve().parent/"extended"; record,origin=generate(out) manifest_path=out/"manifest.json"; manifest=json.loads(manifest_path.read_text()) manifest["documents"]=[d for d in manifest["documents"] if d["file"]!=record["file"]]+[record] manifest["color_profile"]=origin manifest_path.write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+"\n") print(json.dumps({"fixture":record["sha256"],"profile":origin["sha256"]}))