mirror of https://github.com/nextcloud/photos
168 lines
5.3 KiB
PHP
168 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/**
|
|
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
|
|
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
|
|
* @license AGPL-3.0-or-later
|
|
*
|
|
* This code is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License, version 3,
|
|
* as published by the Free Software Foundation.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License, version 3,
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
|
*
|
|
*/
|
|
|
|
namespace OCA\Photos\Listener;
|
|
|
|
use OCA\Photos\AppInfo\Application;
|
|
use OCP\EventDispatcher\Event;
|
|
use OCP\EventDispatcher\IEventListener;
|
|
use OCP\Files\File;
|
|
use OCP\FilesMetadata\Event\MetadataLiveEvent;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Extract EXIF, IFD0, and GPS data from a picture file.
|
|
* EXIF data reference: https://web.archive.org/web/20220428165430/exif.org/Exif2-2.PDF
|
|
*
|
|
* @template-implements IEventListener<MetadataLiveEvent>
|
|
*/
|
|
class ExifMetadataProvider implements IEventListener {
|
|
public function __construct(
|
|
private LoggerInterface $logger
|
|
) {
|
|
}
|
|
|
|
public function handle(Event $event): void {
|
|
if (!($event instanceof MetadataLiveEvent)) {
|
|
return;
|
|
}
|
|
|
|
$node = $event->getNode();
|
|
|
|
if (!$node instanceof File || $node->getSize() === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) {
|
|
return;
|
|
}
|
|
|
|
if (!extension_loaded('exif')) {
|
|
return;
|
|
}
|
|
|
|
$fileDescriptor = $node->fopen('rb');
|
|
if ($fileDescriptor === false) {
|
|
return;
|
|
}
|
|
|
|
$rawExifData = null;
|
|
|
|
try {
|
|
// HACK: The stream_set_chunk_size call is needed to make reading exif data reliable.
|
|
// This is to trigger this condition: https://github.com/php/php-src/blob/d64aa6f646a7b5e58359dc79479860164239580a/main/streams/streams.c#L710
|
|
// But I don't understand yet why 1 as a special meaning.
|
|
$oldBufferSize = stream_set_chunk_size($fileDescriptor, 1);
|
|
$rawExifData = @exif_read_data($fileDescriptor, 'EXIF, GPS', true);
|
|
// We then revert the change after having read the exif data.
|
|
stream_set_chunk_size($fileDescriptor, $oldBufferSize);
|
|
} catch (\Exception $ex) {
|
|
$this->logger->info("Failed to extract metadata for " . $node->getId(), ['exception' => $ex]);
|
|
}
|
|
|
|
if ($rawExifData && array_key_exists('EXIF', $rawExifData)) {
|
|
$event->getMetadata()->setArray('photos-exif', $this->sanitizeEntries($rawExifData['EXIF']));
|
|
}
|
|
|
|
if ($rawExifData && array_key_exists('IFD0', $rawExifData)) {
|
|
$event->getMetadata()->setArray('photos-ifd0', $this->sanitizeEntries($rawExifData['IFD0']));
|
|
}
|
|
|
|
if (
|
|
$rawExifData &&
|
|
array_key_exists('GPS', $rawExifData)
|
|
) {
|
|
$gps = [];
|
|
|
|
if (
|
|
array_key_exists('GPSLatitude', $rawExifData['GPS']) && array_key_exists('GPSLatitudeRef', $rawExifData['GPS']) &&
|
|
array_key_exists('GPSLongitude', $rawExifData['GPS']) && array_key_exists('GPSLongitudeRef', $rawExifData['GPS'])
|
|
) {
|
|
$gps['latitude'] = $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLatitude'], $rawExifData['GPS']['GPSLatitudeRef']);
|
|
$gps['longitude'] = $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLongitude'], $rawExifData['GPS']['GPSLongitudeRef']);
|
|
}
|
|
|
|
if (array_key_exists('GPSAltitude', $rawExifData['GPS']) && array_key_exists('GPSAltitudeRef', $rawExifData['GPS'])) {
|
|
$gps['altitude'] = ($rawExifData['GPS']['GPSAltitudeRef'] === "\u{0000}" ? 1 : -1) * $this->parseGPSData($rawExifData['GPS']['GPSAltitude']);
|
|
}
|
|
|
|
if (!empty($gps)) {
|
|
$event->getMetadata()->setArray('photos-gps', $gps);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array|string $coordinates
|
|
*/
|
|
private function gpsDegreesToDecimal($coordinates, ?string $hemisphere): float {
|
|
if (is_string($coordinates)) {
|
|
$coordinates = array_map("trim", explode(",", $coordinates));
|
|
}
|
|
|
|
if (count($coordinates) !== 3) {
|
|
throw new \Exception('Invalid coordinate format: ' . json_encode($coordinates));
|
|
}
|
|
|
|
[$degrees, $minutes, $seconds] = array_map(fn ($rawDegree) => $this->parseGPSData($rawDegree), $coordinates);
|
|
|
|
$sign = ($hemisphere === 'W' || $hemisphere === 'S') ? -1 : 1;
|
|
return $sign * ($degrees + $minutes / 60 + $seconds / 3600);
|
|
}
|
|
|
|
private function parseGPSData(string $rawData): float {
|
|
$parts = explode('/', $rawData);
|
|
|
|
if ($parts[1] === '0') {
|
|
return 0;
|
|
}
|
|
|
|
return floatval($parts[0]) / floatval($parts[1] ?? 1);
|
|
}
|
|
|
|
/**
|
|
* Exif data can contain anything.
|
|
* This method will base 64 encode any non UTF-8 string in an array.
|
|
* This will also remove control characters from UTF-8 strings.
|
|
*/
|
|
private function sanitizeEntries(array $data): array {
|
|
$cleanData = [];
|
|
|
|
foreach ($data as $key => $value) {
|
|
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
|
|
$value = 'base64:'.base64_encode($value);
|
|
} elseif (is_string($value)) {
|
|
// TODO: Can be remove when the Sidebar use the @nextcloud/files to fetch and parse the DAV response.
|
|
$value = preg_replace('/[[:cntrl:]]/u', '', $value);
|
|
}
|
|
|
|
if (preg_match('/[^a-zA-Z]/', $key) !== 0) {
|
|
$key = preg_replace('/[^a-zA-Z]/', '_', $key);
|
|
}
|
|
|
|
$cleanData[$key] = $value;
|
|
}
|
|
|
|
return $cleanData;
|
|
}
|
|
}
|