118 lines
7.0 KiB
Python
118 lines
7.0 KiB
Python
#!/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"]}))
|