*/ private static array $bins = []; public function __construct(private readonly PhotoPreview $preview) {} /** * Write a resized JPEG for Tesseract. Caller must unlink the returned path. */ public function jpegPath(Photo $photo): ?string { $abs = Storage::disk('local')->path((string) $photo->path); if (! is_file($abs)) { return null; } $jpeg = $this->tempJpeg(); if ($jpeg === null) { return null; } $head = (string) @file_get_contents($abs, false, null, 0, 64); if ($this->preview->headLooksHeic($head) || $this->looksHeicPath($abs)) { $payload = $this->preview->payload( $abs, (string) ($photo->device?->device_id ?: $photo->device_id), (string) $photo->sha256, ); $bytes = (string) ($payload['bytes'] ?? ''); unset($payload); if ($bytes === '' || ! $this->isJpeg($bytes)) { unset($bytes); @unlink($jpeg); return null; } file_put_contents($jpeg, $bytes); unset($bytes); } elseif (! @copy($abs, $jpeg)) { @unlink($jpeg); return null; } $this->resize($jpeg); return is_file($jpeg) ? $jpeg : null; } private function resize(string $path): void { $max = (int) config('coruna.ocr.max_edge', 1280); if ($max < 400) { return; } $info = @getimagesize($path); if (is_array($info) && (int) ($info[0] ?? 0) <= $max && (int) ($info[1] ?? 0) <= $max) { return; } // Magick/sips first: GD truecolor of a 12MP iPhone shot is ~48MB and // fatals the 128M queue worker before this method can fall through. if ($this->resizeWithMagick($path, $max)) { return; } if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) { $result = Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]); if ($result->successful()) { return; } } $this->resizeWithGd($path, $max); } private function resizeWithMagick(string $path, int $max): bool { foreach ($this->magickCandidates() as $bin) { try { $result = Process::timeout(30)->run([$bin, $path, '-resize', $max.'x'.$max.'>', $path]); } catch (\Throwable) { continue; } if ($result->successful() && is_file($path)) { return true; } } return false; } /** @return list */ private function magickCandidates(): array { $bins = []; foreach ([ '/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert', $this->which('magick'), $this->which('convert'), ] as $bin) { if (is_string($bin) && $bin !== '' && ! in_array($bin, $bins, true)) { $bins[] = $bin; } } return $bins; } private function resizeWithGd(string $path, int $max): bool { if (! function_exists('imagecreatefromjpeg') || ! function_exists('imagejpeg')) { return false; } $info = @getimagesize($path); $width = (int) ($info[0] ?? 0); $height = (int) ($info[1] ?? 0); if ($width < 1 || $height < 1 || ! $this->gdFitsMemory($width, $height)) { return false; } $src = @imagecreatefromjpeg($path); if ($src === false) { return false; } $width = imagesx($src); $height = imagesy($src); $edge = max($width, $height); if ($edge <= $max) { imagedestroy($src); return true; } $scale = $max / $edge; $newW = max(1, (int) round($width * $scale)); $newH = max(1, (int) round($height * $scale)); $dst = imagecreatetruecolor($newW, $newH); if ($dst === false) { imagedestroy($src); return false; } imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $width, $height); imagedestroy($src); $ok = imagejpeg($dst, $path, 85); imagedestroy($dst); return $ok; } private function gdFitsMemory(int $width, int $height): bool { $limit = $this->phpMemoryBytes(); if ($limit <= 0) { return true; } $need = ($width * $height * 4) + (16 * 1024 * 1024); return (memory_get_usage(true) + $need) < $limit; } private function phpMemoryBytes(): int { $raw = strtolower(trim((string) ini_get('memory_limit'))); if ($raw === '' || $raw === '-1') { return 0; } $n = (int) $raw; return match (substr($raw, -1)) { 'g' => $n * 1024 * 1024 * 1024, 'm' => $n * 1024 * 1024, 'k' => $n * 1024, default => (int) $raw, }; } private function tempJpeg(): ?string { $tmp = tempnam(sys_get_temp_dir(), 'ocr_'); if ($tmp === false) { return null; } $jpeg = $tmp.'.jpg'; @unlink($tmp); return $jpeg; } private function looksHeicPath(string $path): bool { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); return in_array($ext, ['heic', 'heif'], true); } private function isJpeg(string $bytes): bool { return strlen($bytes) >= 3 && substr($bytes, 0, 2) === "\xFF\xD8"; } private function which(string $bin): ?string { if (array_key_exists($bin, self::$bins)) { return self::$bins[$bin]; } $result = Process::timeout(3)->run(['which', $bin]); $path = trim($result->output()); self::$bins[$bin] = $result->successful() && $path !== '' ? $path : null; return self::$bins[$bin]; } }