//Я создал изображение с помощью GD, поэтому png-шку получаю таким способом:
ob_start();
imagepng($img);
imagedestroy($img);
$img = ob_get_contents();
ob_end_clean();
//Но вы можете загрузить готовую вот таким:
$img = file_get_contents(`path/fileName.png`);
$dpi = 600; //В пикселях на дюйм
$incPos = strpos($img, `IDAT`) - 4; //Определяем позицию куда будем вставлять наш chunk
$chunk = `pHYs`.pack(`NNc`, round($dpi/0.0254), round($dpi/0.0254), 1); //Собираем chunk type + chunk data
$incData = pack(`N`, 9).$chunk.pack(`N`, crc32($chunk)); //Добавляем в начало размер chunk-а, а в конец его crc
file_put_contents(`path/fileName.png`, substr_replace($img, $incData, $incPos, 0 )); //Вставляем и сохраняем
Обращаю внимание, что я исходил из того, что GD просто не создает pHYs chunk-а. Поэтому я просто вставляю свой перед первым IDAT chunk-ом. Но если вы хотите работать с произвольной png-шкой, то вам придется предусмотреть случай, когда pHYs chunk уже существует, находить и заменять его.
О составе chunk-ов написано тут: Chunk layout.
В начале идет 4-х байтовое беззнаковое целочисленное содержащие количество байт отводимое на содержимое чанка (для нашего нужно 9 байт). Затем следуют 4 ASCII байта названия чанка (нас интересует pHYs). Далее содержимое чанка, 4 байта отводится под ширину и 4 под высоту, которые также представляют из себя беззнаковые целые. А затем один байт надо установить в единичку, иначе мы будем задавать лишь относительные пропорции. После этого нужно вычислить и записать 4 байта контрольной суммы CRC32 от названия и содержимого чанка. Важно! Обратите внимание, все числовые переменные (включая crc) должны быть с тупоконечным порядком (от старшего к младшему). Высота и ширина у нас задается в точках на метр. Поэтому для пересчета точки/дюйм надо делить на 0,0254.
Надеюсь кому-нибудь мой опыт окажется полезным.