<?php

function parseJpeg(string $file) : array{
    
$fp fopen($file'rb');
    if (!
$fp){
        throw new 
\RuntimeException('Unable to open file');
    }

    
//First two bytes should be \xFFD8.
    
if (fread($fp2) !== "\xFF\xD8"){
        throw new 
\RuntimeException('Invalid image file.');
    }

    
$output = [];
    
//Find each segment by looking for the marker values \xFF??
    
while (!feof($fp) && ($marker findNextMarker($fp))){
        
//If the marker is not the end of image marker.
        
$blockData = [
            
'marker' => $marker
            
'offset' => ftell($fp) - 2
        
];
        if (
$marker !== 0xD9){
            
//Parse the segment data for this marker.
            
$blockData['segmentData'] = parseSegmentData($fp);

            if (
$marker === 0xDA){ //If the marker is a start of scan marker.
                //Parse the image data that follows.
                
$blockData['imageData'] = parseImageData($fp);
            } else if (
$marker === 0xE0){ //If the marker is the app0 header.
                
$blockData['headerData'] = parseApp0Header($blockData['segmentData']);
            } else if (
$marker === 0xE2 && str_starts_with($blockData['segmentData'], 'ICC_PROFILE')){
                
$blockData['iccData'] = parseIccData(substr($blockData['segmentData'], strlen('ICC_PROFILE') + 3));
            }

            
$output[] = $blockData;
        } else {
            
$output[] = $blockData;
            break;
        }
    }

    
fclose($fp);

    return 
$output;
}

function 
parseSegmentData($fp) : string{
    
//Markers indicate the start of a segment which is composed of <length><data> sections.
    //The length is two bytes and is the length of the entire segment including the two bytes
    //used to define the length.

    //Extract the length from the next two bytes.
    
$dataLength unpack('nlength'fread($fp2))['length'];

    
//Read the remaining data using that length value.  Subtract 2 because
    //$dataLength includes the two bytes we just read to obtain the length
    
$data fread($fp$dataLength 2);

    return 
$data;
}

function 
parseImageData($fp) : string{
    
$imageData = [];
    
//Read data until we find another marker or hit end of file.
    
while (!feof($fp)){
        
$c fread($fp1);
        
//We might have found another marker.
        
if ($c === "\xFF"){
            
//Save our position in the file
            //If we found a marker, we need to rewind to just before it.
            
$pos ftell($fp);

            
//We only found a marker if the next byte is not \x00 or \xD0-\xD7
            
$next ord(fread($fp1));
            if (
$next !== 0x00 && ($next 0xD0 || $next 0xD7)){
                
//Rewind the file to just before the marker we just found and exit the loop.
                
fseek($fp$pos 1);
                break;
            } else {
                
$imageData[] = $c;
                
$imageData[] = chr($next);
            }
        } else {
            
$imageData[] = $c;
        }
    }

    
$imageData implode(''$imageData);

    return 
$imageData;
}

function 
parseApp0Header(string $data) : ?array{
    
$unpacked unpack('Z5id/c2version/cunits/n2dpi/c2thumb'$data);
    if (!
$unpacked){
        return 
null;
    }

    return [
        
'id' => $unpacked['id']
        , 
'version' => $unpacked['version1'] . '.' $unpacked['version2']
        , 
'units' => $unpacked['units']
        , 
'density' => $unpacked['dpi1'] . 'x' $unpacked['dpi2']
        , 
'thumbnail' => $unpacked['thumb1'] . 'x' $unpacked['thumb2']
    ];
}

function 
hexdump(string $dataint $offset) : string{
    
$lines = [];
    foreach (
str_split($data16) as $chunk){
        
$hexColumn $printColumn = [];
        foreach (
str_split($chunk) as $byte){
            
$hexColumn[] = sprintf('%02X'ord($byte));
            
$printColumn[] = ctype_print($byte) ? $byte '.';
        }
        
$hex implode(' '$hexColumn);
        
$print implode(''$printColumn);
        
$lines[] = sprintf('0x%08X | %-47s | %-16s'$offset$hex$print);
        
$offset += strlen($chunk);
    }

    return 
PHP_EOL implode(PHP_EOL$lines) . PHP_EOL;
}

function 
findNextMarker($fp) : int{
    
//Scan the file content for the next \xFF?? marker.
    //This scans one byte at a time which is terrible for
    //performance but easy.  Loading more data into memory
    //and using strpos would be better, but since you like
    //low-memory...
    
do {
        
$markerIndicator fread($fp1);
        if (
$markerIndicator === "\xFF"){
            
$marker fread($fp1);
            if (
$marker !== "\x00"){
                return 
ord($marker);
            }
        }
    } while (!
feof($fp));

    throw new 
\RuntimeException('Marker not found');
}

function 
parseIccData(string $iccBytes) : array{
    if (
strlen($iccBytes) < 128){
        return [];
    }

    
$createByteString = function(array $bytes){
        return 
implode(''array_map('chr'$bytes));
    };

    
$headerBytes substr($iccBytes0128);
    
$fields unpackStructFields($headerBytes, [
        
'profileSize' => 'N',
        
'cmmType' => ['c4'$createByteString],
        
'versionAndSubversion' => ['c4', function(array $values){
            
$majorVersion $values[0];
            
$minor = ($values[1] & 0xF0) >> 4;
            
$patch $values[1] & 0x0F;

            return 
sprintf('%d.%d.%d'$majorVersion$minor$patch);
        }],
        
'class' => ['c4'$createByteString],
        
'colorSpace' => ['c4'$createByteString],
        
'PCS' => ['c4'$createByteString],
        
'dateTime' => ['c12', function(array $values) use ($createByteString){
            return 
unpackStructFields($createByteString($values),
                
array_fill_keys(['year''month''day''hour''minute''second'], 'n')
            );
        }],
        
'acsp' => ['c4'$createByteString],
        
'primaryPlatform' => ['c4'$createByteString],
        
'profileFlags' => ['N', function(int $value){
            return [
                
'embedded' => isBitSet($value0) ? 'Yes' 'No',
                
'independent' => isBitSet($value1) ? 'No' 'Yes'
            
];
        }],
        
'deviceManufacturer' => ['c4'$createByteString],
        
'deviceModel' => ['c4'$createByteString],
        
'deviceAttributes' => ['J', function(int $value){
            return [
                
=> isBitSet($value0) ? 'Transparency' 'Reflective',
                
=> isBitSet($value1) ? 'Matte' 'Glossy',
                
=> isBitSet($value2) ? 'negative' 'positive',
                
=> isBitSet($value3) ? 'Black & white media' 'Colour media',
            ];
        }],
        
'renderingIntent' => ['N', function(int $value){
            return match (
$value 0xFF) {
                
=> 'Perceptual',
                
=> 'Media-relative colorimetric',
                
=> 'Saturation',
                
=> 'ICC-absolute colorimetric'
            
};
        }],
        
'nCIEXYZ' => ['c12', function(array $values) use ($createByteString){
            return 
parseXYZNumber($createByteString($values));
        }],
        
'creatorSignature' => ['c4'$createByteString],
        
'id' => ['c16', function(array $bytes){
            return 
implode(''array_map('dechex'$bytes));
        }]
    ]);

    return 
$fields;
}

function 
unpackStructFields(string $bytes, array $definition) : array{
    
$formatList = [];
    
$formatMethodList = [];
    foreach (
$definition as $fieldName => $fieldDefinition){
        if (
is_array($fieldDefinition)){
            
$fieldFormat $fieldDefinition[0];
            
$formatMethodList[$fieldName] = $fieldDefinition[1];
        } else {
            
$fieldFormat $fieldDefinition;
        }

        
$formatList[] = $fieldFormat $fieldName;
    }

    
$rawUnpackedData unpack(implode('/'$formatList), $bytes);
    
//Multibyte fields are returned a $fieldName1, $fieldName2, $fieldName3, ...
    //A better format would be an array, $fieldName=>[1,2,3].
    
$fixedUnpackedData = [];
    foreach (
array_keys($definition) as $fieldName){
        if (isset(
$rawUnpackedData[$fieldName])){
            
$fixedUnpackedData[$fieldName] = $rawUnpackedData[$fieldName];
        } else {
            
$counter 1;
            do {
                
$fixedUnpackedData[$fieldName][] = $rawUnpackedData[$fieldName $counter++];
            } while (isset(
$rawUnpackedData[$fieldName $counter]));
        }
    }

    foreach (
$formatMethodList as $fieldName => $callback){
        
$fixedUnpackedData[$fieldName] = call_user_func($callback$fixedUnpackedData[$fieldName]);
    }

    return 
$fixedUnpackedData;
}

function 
parseXYZNumber(string $bytes) : array{
    
$byteGroups str_split($bytes4);

    return [
        
'x' => parses15Fixed16Number($byteGroups[0]),
        
'y' => parses15Fixed16Number($byteGroups[1]),
        
'z' => parses15Fixed16Number($byteGroups[2]),
    ];
}

function 
parses15Fixed16Number(string $bytes){
    
$parts unpack('nw/nf'$bytes);

    
$fraction $parts['f'];
    
$whole $parts['w'];
    if (
$whole >= 0x8000){
        if (
$whole 0x8000){
            
$whole = ((~$whole) + 1) & 0x7fff;
        }
        
$whole = -$whole;
    }
    
$final $whole + ($fraction 0x10000);

    return 
$final;
}

function 
isBitSet($value$bitNumber) : bool{
    
$mask pow(2$bitNumber);

    return (bool)(
$value $mask);
}