Files
coruna-lab/app/Services/Ocr/PhotoOcrPreparer.php
T
2026-09-14 03:32:56 +08:00

155 lines
4.3 KiB
PHP

<?php
namespace App\Services\Ocr;
use App\Models\Photo;
use App\Services\PhotoPreview;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoOcrPreparer
{
/** @var array<string, ?string> */
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'] ?? '');
if ($bytes === '' || ! $this->isJpeg($bytes)) {
@unlink($jpeg);
return null;
}
file_put_contents($jpeg, $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;
}
if ($this->resizeWithGd($path, $max)) {
return;
}
$magick = $this->which('magick') ?? $this->which('convert');
if ($magick !== null) {
Process::timeout(30)->run([$magick, $path, '-resize', $max.'x'.$max.'>', $path]);
return;
}
if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) {
Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]);
}
}
private function resizeWithGd(string $path, int $max): bool
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagejpeg')) {
return false;
}
$bytes = @file_get_contents($path);
if (! is_string($bytes) || $bytes === '') {
return false;
}
$src = @imagecreatefromstring($bytes);
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 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];
}
}