From b06b7908199217f69b3a3294ae919bb337e367a6 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 23:12:21 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8F=AD=20ci(conventions):=20rendre=20?= =?UTF-8?q?m=C3=A9caniques=20les=20r=C3=A8gles=20de=20CONTRIBUTING.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les règles du guide ne valaient que par ma vigilance : aucune n'était vérifiée. Six contrôles les rendent non contournables. - Symfony hors de src/Bridge/ : la librairie doit rester utilisable sans Symfony, c'est la raison d'être d'un type: library ; - GD dans src/ : il fabrique et valide dans les tests, jamais n'extrait — le package ne dépend d'aucune extension ; - declare(strict_types=1) partout ; - unpack('S'/'L') : ils suivent l'endianness du CPU et rendraient le parsing dépendant de la machine ; - commentaires français dans src/ : le package est public, son PHPDoc est sa documentation d'API ; - RAW commité : les EXIF (numéro de série, GPS) resteraient dans l'historique git à vie. Chaque contrôle a été exécuté localement avant d'être ajouté : le sixième détectait bien les 20 fichiers à traduire. --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 232e068..b422b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,73 @@ jobs: - name: 🧪 Run PHPUnit run: vendor/bin/phpunit --colors=always + # Les règles de CONTRIBUTING.md ne valent que si une machine les vérifie. + conventions: + name: 📏 Conventions + runs-on: ubuntu-latest + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v5 + + - name: 🚧 No Symfony outside the bridge + run: | + # La librairie doit rester utilisable hors Symfony : c'est la raison + # d'être d'un type: library plutôt qu'un symfony-bundle. + found=$(grep -rl 'Symfony\\' src/ --include='*.php' | grep -v '^src/Bridge/' || true) + if [ -n "$found" ]; then + echo "::error::Symfony importé hors de src/Bridge/ :"; echo "$found"; exit 1 + fi + echo "✅ Symfony confiné à src/Bridge/" + + - name: 🚧 No GD in src/ + run: | + # GD sert à fabriquer et valider dans les tests, jamais à extraire : + # le package ne doit dépendre d'aucune extension. + found=$(grep -rlE 'imagecreate|imagejpeg|getimagesize|imagedestroy' src/ --include='*.php' || true) + if [ -n "$found" ]; then + echo "::error::GD utilisé dans src/ :"; echo "$found"; exit 1 + fi + echo "✅ aucun appel GD dans src/" + + - name: 🚧 declare(strict_types=1) everywhere + run: | + found=$(find src -name '*.php' ! -exec grep -lq 'declare(strict_types=1);' {} \; -print) + if [ -n "$found" ]; then + echo "::error::declare(strict_types=1) manquant :"; echo "$found"; exit 1 + fi + echo "✅ strict_types dans tous les fichiers de src/" + + - name: 🚧 No CPU-dependent unpack + run: | + # S et L suivent l'endianness de la machine : le parsing deviendrait + # dépendant du CPU. Seuls v/V (little) et n/N/J (big) sont admis. + found=$(grep -rnE "unpack\('[SL]" src/ --include='*.php' || true) + if [ -n "$found" ]; then + echo "::error::unpack() dépendant du CPU :"; echo "$found"; exit 1 + fi + echo "✅ endianness explicite partout" + + - name: 🚧 Public API documented in English + run: | + # Le package est public : son PHPDoc est sa documentation d'API. + found=$(grep -rlP '^\s*\*.*\b(le|la|les|une|des|du|qui|que|dans|pour|avec|sur|est|sont|cette|ce)\b' \ + src/ --include='*.php' || true) + if [ -n "$found" ]; then + echo "::error::commentaires en français dans src/ :"; echo "$found"; exit 1 + fi + echo "✅ commentaires en anglais" + + - name: 🚧 No RAW committed + run: | + # Un RAW porte des EXIF (numéro de série, GPS) et resterait dans + # l'historique git à vie. + found=$(git ls-files | grep -iE '\.(cr2|cr3|nef|arw|dng|raf|orf|rw2)$' || true) + if [ -n "$found" ]; then + echo "::error::fichier RAW commité :"; echo "$found"; exit 1 + fi + echo "✅ aucun RAW dans le repo" + coverage: name: 📊 Couverture (≥ 95 %) runs-on: ubuntu-latest From be8cc6bf6ff483186aa910072fa3f1e7a4abe4c6 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 23:12:21 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=96=20docs(src):=20passer=20tout?= =?UTF-8?q?=20le=20code=20en=20anglais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le package est public : son PHPDoc est sa documentation d'API, et ses messages d'exception sont ce que voit un utilisateur non francophone quand la librairie échoue. Des commentaires français y excluent les contributeurs. Traduits : les 728 lignes de commentaires des 20 fichiers de src/, et les 12 messages d'exception avec les 7 assertions de test correspondantes. Le sens et la précision sont préservés — ces commentaires portent des découvertes coûteuses issues de la rétro-ingénierie de fichiers réels (les 8 octets propriétaires avant PRVW dans un CR3, le JPEG lossless que Canon marque Compression = 6, le MakerNote de 75 Ko d'un 5D de 2005). Aucune n'a été résumée. « façade » devient « facade » : c'est le nom du pattern, et la graphie usuelle en contexte technique. CLAUDE.md, les skills et .claude/ restent en français : ils sont locaux et git-ignorés, ils ne concernent pas les utilisateurs du package. --- .../Symfony/RawPreviewExtractorBundle.php | 14 +- .../Symfony/Resources/config/services.php | 20 +-- src/Exception/CorruptedFileException.php | 10 +- src/Exception/PreviewNotFoundException.php | 10 +- .../RawPreviewExtractorException.php | 10 +- src/Exception/UnsupportedFormatException.php | 14 +- src/ExtractedPreview.php | 16 +- src/Format/Format.php | 20 +-- src/Format/FormatDetector.php | 71 +++++---- src/Format/FormatDetectorInterface.php | 14 +- src/Parser/Cr3/Box.php | 16 +- src/Parser/Cr3/Cr3PreviewParser.php | 92 ++++++------ src/Parser/Cr3/IsoBmffBoxReader.php | 124 ++++++++-------- src/Parser/PreviewParserInterface.php | 18 +-- src/Parser/Tiff/IfdEntry.php | 32 ++-- src/Parser/Tiff/TiffPreviewParser.php | 86 +++++------ src/Parser/Tiff/TiffReader.php | 137 +++++++++--------- src/Parser/Tiff/TiffTag.php | 32 ++-- src/RawPreviewExtractor.php | 40 ++--- src/RawPreviewExtractorInterface.php | 32 ++-- .../Unit/Parser/Cr3/IsoBmffBoxReaderTest.php | 8 +- tests/Unit/Parser/Tiff/TiffReaderTest.php | 10 +- tests/Unit/RawPreviewExtractorTest.php | 2 +- 23 files changed, 413 insertions(+), 415 deletions(-) diff --git a/src/Bridge/Symfony/RawPreviewExtractorBundle.php b/src/Bridge/Symfony/RawPreviewExtractorBundle.php index 65627b8..c9dea9d 100644 --- a/src/Bridge/Symfony/RawPreviewExtractorBundle.php +++ b/src/Bridge/Symfony/RawPreviewExtractorBundle.php @@ -11,16 +11,16 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle; /** - * Bundle optionnel : enregistre l'extracteur comme service auto-wirable. + * Optional bundle: registers the extractor as an autowirable service. * - * Il se limite au **câblage** — aucune logique métier, aucun parsing. La - * librairie fonctionne à l'identique sans lui, en Laravel comme en PHP nu : + * It limits itself to **wiring** — no business logic, no parsing. The library + * works identically without it, in Laravel as in plain PHP: * * ```php * $extractor = RawPreviewExtractor::createDefault(); * ``` * - * Activation (Flex le fait automatiquement) : + * Activation (Flex does it automatically): * * ```php * // config/bundles.php @@ -29,7 +29,7 @@ * ]; * ``` * - * `RawPreviewExtractorInterface` devient alors injectable par autowiring. + * `RawPreviewExtractorInterface` then becomes injectable through autowiring. */ final class RawPreviewExtractorBundle extends AbstractBundle { @@ -41,8 +41,8 @@ public function loadExtension( ContainerConfigurator $container, ContainerBuilder $builder, ): void { - // Chemin physique : la notation @Bundle est dépréciée pour les bundles - // réutilisables. + // Physical path: the @Bundle notation is deprecated for reusable + // bundles. $loader = new PhpFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config')); $loader->load('services.php'); } diff --git a/src/Bridge/Symfony/Resources/config/services.php b/src/Bridge/Symfony/Resources/config/services.php index a14d442..22a8a9b 100644 --- a/src/Bridge/Symfony/Resources/config/services.php +++ b/src/Bridge/Symfony/Resources/config/services.php @@ -13,11 +13,11 @@ use function Symfony\Component\DependencyInjection\Loader\Configurator\service; /* - * Définitions explicites, sans autowiring ni autoconfiguration. + * Explicit definitions, without autowiring or autoconfiguration. * - * C'est la règle bundle la plus souvent violée : un bundle réutilisable câble - * ses services à la main. Activer l'autowiring ici polluerait la configuration - * de l'application consommatrice. + * This is the most often violated bundle rule: a reusable bundle wires its + * services by hand. Enabling autowiring here would pollute the configuration of + * the consuming application. */ return static function (ContainerConfigurator $container): void { $services = $container->services(); @@ -26,10 +26,10 @@ $services->set('raw_preview_extractor.parser.tiff', TiffPreviewParser::class); $services->set('raw_preview_extractor.parser.cr3', Cr3PreviewParser::class); - // La map qui porte l'OCP : ajouter RAF ou ORF en v2 se fera par une ligne - // ici, sans toucher à la façade. Les clés sont les valeurs de l'enum Format, - // construites explicitement — un tag auto-collecté exigerait de - // l'autoconfiguration, interdite dans un bundle. + // The map that carries the OCP: adding RAF or ORF in v2 will be done with a + // line here, without touching the facade. The keys are the values of the + // Format enum, built explicitly — an auto-collected tag would require + // autoconfiguration, which is forbidden in a bundle. $services->set('raw_preview_extractor.extractor', RawPreviewExtractor::class) ->args([ service('raw_preview_extractor.format_detector'), @@ -42,8 +42,8 @@ ], ]); - // Seule l'interface est exposée : les parseurs et le détecteur restent - // privés, ce sont des détails d'implémentation. + // Only the interface is exposed: the parsers and the detector stay private, + // they are implementation details. $services->alias(RawPreviewExtractorInterface::class, 'raw_preview_extractor.extractor') ->public(); }; diff --git a/src/Exception/CorruptedFileException.php b/src/Exception/CorruptedFileException.php index 5c5ba30..10a04c6 100644 --- a/src/Exception/CorruptedFileException.php +++ b/src/Exception/CorruptedFileException.php @@ -5,13 +5,13 @@ namespace RonanLenouvel\RawPreviewExtractor\Exception; /** - * Le fichier est illisible ou structurellement invalide. + * The file is unreadable or structurally invalid. * - * Couvre : fichier absent ou non lisible, en-tête absent ou tronqué, offset - * pointant hors du fichier, taille absurde, structure incohérente. + * Covers: missing or unreadable file, missing or truncated header, offset + * pointing outside the file, absurd size, inconsistent structure. * - * À distinguer de {@see PreviewNotFoundException} : un fichier **tronqué** est - * corrompu, pas dépourvu de preview. La distinction est testée — ne la brouille pas. + * To be distinguished from {@see PreviewNotFoundException}: a **truncated** file + * is corrupted, not devoid of a preview. The distinction is tested — do not blur it. */ final class CorruptedFileException extends \RuntimeException implements RawPreviewExtractorException { diff --git a/src/Exception/PreviewNotFoundException.php b/src/Exception/PreviewNotFoundException.php index eff9bcb..4c3831d 100644 --- a/src/Exception/PreviewNotFoundException.php +++ b/src/Exception/PreviewNotFoundException.php @@ -5,13 +5,13 @@ namespace RonanLenouvel\RawPreviewExtractor\Exception; /** - * Le fichier est valide, mais ne contient aucune preview JPEG exploitable. + * The file is valid, but contains no usable JPEG preview. * - * Cas typiques : aucun tag ne désigne de JPEG, ou la taille annoncée est nulle. + * Typical cases: no tag points to a JPEG, or the announced size is zero. * - * À distinguer de {@see CorruptedFileException} : un fichier **tronqué** ou dont - * un offset ment est corrompu, pas dépourvu de preview. Cette frontière est - * testée — ne la brouille pas. + * To be distinguished from {@see CorruptedFileException}: a **truncated** file, or + * one whose offset lies, is corrupted, not devoid of a preview. This boundary is + * tested — do not blur it. */ final class PreviewNotFoundException extends \RuntimeException implements RawPreviewExtractorException { diff --git a/src/Exception/RawPreviewExtractorException.php b/src/Exception/RawPreviewExtractorException.php index 49783e7..bccb2fb 100644 --- a/src/Exception/RawPreviewExtractorException.php +++ b/src/Exception/RawPreviewExtractorException.php @@ -5,20 +5,20 @@ namespace RonanLenouvel\RawPreviewExtractor\Exception; /** - * Interface marqueur commune à toutes les exceptions du package. + * Marker interface common to every exception of the package. * - * Elle permet à l'appelant de dégrader proprement avec un seul `catch`, sans - * connaître le détail des cas d'échec : + * It lets the caller degrade gracefully with a single `catch`, without knowing + * the detail of the failure cases: * * ```php * try { * $preview = $extractor->extract($path); * } catch (RawPreviewExtractorException) { - * // pas de vignette : on continue sans + * // no thumbnail: carry on without one * } * ``` * - * Toute exception levée par ce package l'implémente — c'est un invariant testé. + * Every exception thrown by this package implements it — a tested invariant. */ interface RawPreviewExtractorException extends \Throwable { diff --git a/src/Exception/UnsupportedFormatException.php b/src/Exception/UnsupportedFormatException.php index baeffc9..6d47f5f 100644 --- a/src/Exception/UnsupportedFormatException.php +++ b/src/Exception/UnsupportedFormatException.php @@ -5,15 +5,15 @@ namespace RonanLenouvel\RawPreviewExtractor\Exception; /** - * Le fichier n'est pas un RAW pris en charge par ce package. + * The file is not a RAW supported by this package. * - * Deux cas mènent ici : la signature n'est reconnue comme aucun format connu, - * ou le format est reconnu mais aucun parseur ne lui est associé. Du point de - * vue de l'appelant, les deux reviennent au même — le fichier ne peut pas être - * traité. + * Two cases lead here: the signature is not recognised as any known format, or + * the format is recognised but no parser is associated with it. From the + * caller's point of view, both amount to the same thing — the file cannot be + * processed. * - * Utilisez {@see \RonanLenouvel\RawPreviewExtractor\RawPreviewExtractorInterface::supports()} - * pour l'éviter sans passer par un `try`/`catch`. + * Use {@see \RonanLenouvel\RawPreviewExtractor\RawPreviewExtractorInterface::supports()} + * to avoid it without going through a `try`/`catch`. */ final class UnsupportedFormatException extends \RuntimeException implements RawPreviewExtractorException { diff --git a/src/ExtractedPreview.php b/src/ExtractedPreview.php index 9df9a18..9d3ab6b 100644 --- a/src/ExtractedPreview.php +++ b/src/ExtractedPreview.php @@ -7,24 +7,24 @@ use RonanLenouvel\RawPreviewExtractor\Format\Format; /** - * Une preview JPEG extraite d'un fichier RAW. + * A JPEG preview extracted from a RAW file. * * ```php * $preview = $extractor->extract('/photos/IMG_0042.CR2'); * file_put_contents('/cache/thumb.jpg', $preview->jpegData); * ``` * - * Les dimensions proviennent du **JPEG lui-même**, pas des tags du conteneur : - * ceux-ci décrivent parfois l'image RAW pleine résolution et non la preview. + * Dimensions come from the **JPEG itself**, not from container tags: those often + * describe the full-resolution RAW image rather than the preview. */ final readonly class ExtractedPreview { /** - * @param string $jpegData JPEG binaire brut, prêt à écrire sur disque ; - * son magic `FFD8` est validé à l'extraction - * @param int $width largeur de la preview, en pixels - * @param int $height hauteur de la preview, en pixels - * @param Format $sourceFormat format du RAW dont elle provient + * @param string $jpegData raw JPEG bytes, ready to write to disk; the + * `FFD8` magic is validated during extraction + * @param int $width preview width in pixels + * @param int $height preview height in pixels + * @param Format $sourceFormat format of the RAW file it came from */ public function __construct( public string $jpegData, diff --git a/src/Format/Format.php b/src/Format/Format.php index 6416620..f1e66c2 100644 --- a/src/Format/Format.php +++ b/src/Format/Format.php @@ -5,27 +5,27 @@ namespace RonanLenouvel\RawPreviewExtractor\Format; /** - * Formats RAW supportés par le package. + * RAW formats supported by the package. * - * La valeur de chaque cas est l'extension usuelle du format, en minuscules. - * Elle sert d'identifiant stable — notamment comme clé de la map - * Format → PreviewParserInterface — et non de critère de détection : celle-ci - * repose exclusivement sur la signature binaire du fichier. + * The value of each case is the format's usual extension, in lowercase. It acts + * as a stable identifier — notably as the key of the + * Format → PreviewParserInterface map — and not as a detection criterion: + * detection relies exclusively on the file's binary signature. */ enum Format: string { - /** Canon RAW v2 — conteneur TIFF. */ + /** Canon RAW v2 — TIFF container. */ case CR2 = 'cr2'; - /** Canon RAW v3 — conteneur ISO-BMFF. */ + /** Canon RAW v3 — ISO-BMFF container. */ case CR3 = 'cr3'; - /** Nikon Electronic Format — conteneur TIFF. */ + /** Nikon Electronic Format — TIFF container. */ case NEF = 'nef'; - /** Sony Alpha RAW — conteneur TIFF. */ + /** Sony Alpha RAW — TIFF container. */ case ARW = 'arw'; - /** Adobe Digital Negative — conteneur TIFF. */ + /** Adobe Digital Negative — TIFF container. */ case DNG = 'dng'; } diff --git a/src/Format/FormatDetector.php b/src/Format/FormatDetector.php index 14d541b..05c3904 100644 --- a/src/Format/FormatDetector.php +++ b/src/Format/FormatDetector.php @@ -5,25 +5,25 @@ namespace RonanLenouvel\RawPreviewExtractor\Format; /** - * Détecte le format RAW par lecture de la signature binaire du fichier. + * Detects the RAW format by reading the file's binary signature. * - * L'extension du fichier n'est jamais consultée : un CR2 renommé en `.jpg` - * reste détecté comme un CR2, et un `.cr2` qui n'en est pas un est rejeté. + * The file extension is never consulted: a CR2 renamed to `.jpg` is still + * detected as a CR2, and a `.cr2` that is not one is rejected. * - * Deux familles de conteneurs sont reconnues : - * - TIFF 6.0 (CR2, NEF, ARW, DNG), discriminé par la signature Canon `CR` - * ou par les tags DNGVersion / Make de l'IFD0 ; - * - ISO-BMFF (CR3), discriminé par la boîte `ftyp` et le brand `crx `. + * Two container families are recognised: + * - TIFF 6.0 (CR2, NEF, ARW, DNG), discriminated by the Canon `CR` signature + * or by the DNGVersion / Make tags of the IFD0; + * - ISO-BMFF (CR3), discriminated by the `ftyp` box and the `crx ` brand. */ final class FormatDetector implements FormatDetectorInterface { - /** Octets suffisants pour couvrir l'en-tête TIFF, la signature CR2 et le ftyp d'un CR3. */ + /** Bytes enough to cover the TIFF header, the CR2 signature and a CR3's ftyp. */ private const HEADER_BYTES = 16; - /** Nombre magique du format TIFF, lu selon l'endianness du fichier. */ + /** Magic number of the TIFF format, read according to the file's endianness. */ private const TIFF_MAGIC = 42; - /** Au-delà, un IFD0 aussi long trahit un fichier corrompu ou hostile. */ + /** Beyond this, an IFD0 that long betrays a corrupted or hostile file. */ private const MAX_IFD0_ENTRIES = 512; private const TAG_MAKE = 0x010F; @@ -31,20 +31,20 @@ final class FormatDetector implements FormatDetectorInterface public function detect(string $path): ?Format { - // fopen() réussit sur un répertoire : c'est fread() qui échouerait ensuite, - // en émettant un notice. On écarte le cas ici plutôt que de le museler. + // fopen() succeeds on a directory: it is fread() that would fail next, + // emitting a notice. We rule the case out here rather than muzzling it. if (!is_file($path)) { return null; } - // is_file() a déjà écarté l'absent et le répertoire. + // is_file() has already ruled out the missing file and the directory. $handle = fopen($path, 'rb'); try { $header = fread($handle, self::HEADER_BYTES); - // fread renvoie moins que demandé en fin de fichier : sans ce contrôle, - // unpack() lirait des octets qui n'existent pas. + // fread returns less than requested at end of file: without this check, + // unpack() would read bytes that do not exist. if (!is_string($header) || strlen($header) < 8) { return null; } @@ -57,7 +57,7 @@ public function detect(string $path): ?Format } /** - * CR3 : boîte `ftyp` en tête, brand majeur `crx ` aux octets 8-11. + * CR3: `ftyp` box up front, major brand `crx ` at bytes 8-11. */ private function detectIsoBmff(string $header): ?Format { @@ -85,7 +85,7 @@ private function detectTiff($handle, string $header): ?Format return null; } - // CR2 : signature « CR » suivie de la version, juste après l'en-tête TIFF. + // CR2: "CR" signature followed by the version, right after the TIFF header. if (strlen($header) >= 10 && 'CR' === substr($header, 8, 2)) { return Format::CR2; } @@ -100,26 +100,26 @@ private function detectTiff($handle, string $header): ?Format } /** - * @return array{string, string}|null couple de formats unpack() {court, long} + * @return array{string, string}|null pair of unpack() formats {short, long} */ private function readEndianness(string $header): ?array { return match (substr($header, 0, 2)) { 'II' => ['v', 'V'], // little-endian 'MM' => ['n', 'N'], // big-endian - default => null, // ni l'un ni l'autre : pas un TIFF + default => null, // neither one: not a TIFF }; } /** - * Parcourt les entrées de l'IFD0 à la recherche d'un tag discriminant. + * Walks the IFD0 entries looking for a discriminating tag. * * @param resource $handle */ private function detectFromIfd0($handle, string $shortFormat, string $longFormat, int $ifdOffset): ?Format { - // fseek au-delà de la fin réussit sur un fichier : c'est la lecture - // suivante qui rend une chaîne vide, ce qui est traité juste après. + // fseek past the end succeeds on a file: it is the next read that + // returns an empty string, which is handled right after. fseek($handle, $ifdOffset); $countBytes = fread($handle, 2); @@ -145,7 +145,7 @@ private function detectFromIfd0($handle, string $shortFormat, string $longFormat $tag = $this->unpackInt($shortFormat, substr($entry, 0, 2)); - // DNGVersion suffit : le format est normalisé par Adobe. + // DNGVersion is enough: the format is standardised by Adobe. if (self::TAG_DNG_VERSION === $tag) { return Format::DNG; } @@ -159,8 +159,8 @@ private function detectFromIfd0($handle, string $shortFormat, string $longFormat } /** - * Lit la valeur du tag Make, stockée hors de l'entrée dès qu'elle dépasse - * 4 octets — ce qui est toujours le cas d'un nom de fabricant. + * Reads the value of the Make tag, stored outside the entry as soon as it + * exceeds 4 bytes — which is always the case for a manufacturer name. * * @param resource $handle */ @@ -173,17 +173,17 @@ private function readMake($handle, string $longFormat, string $entry): ?string return null; } - // La position courante doit être restaurée : la boucle appelante - // continue de lire les entrées séquentiellement. Sur un flux fichier - // local, ftell/fseek n'échouent pas — inutile de s'en garder. + // The current position must be restored: the calling loop keeps reading + // the entries sequentially. On a local file stream, ftell/fseek do not + // fail — no need to guard against it. $position = (int) ftell($handle); fseek($handle, $offset); $value = (string) fread($handle, $count); fseek($handle, $position); - // Un offset hors bornes ne fait pas échouer fseek : c'est la lecture - // qui rend une chaîne vide. + // An out-of-bounds offset does not make fseek fail: it is the read that + // returns an empty string. return '' === $value ? null : rtrim($value, "\x00"); } @@ -204,15 +204,14 @@ private function formatFromMake(?string $make): ?Format } /** - * Vérifie la longueur avant unpack() : un fread en fin de fichier rend moins - * d'octets que demandé, silencieusement. Une fois la longueur garantie, - * unpack() ne peut plus échouer. + * Checks the length before unpack(): a fread at end of file returns fewer + * bytes than requested, silently. Once the length is guaranteed, unpack() + * can no longer fail. */ private function unpackInt(string $format, string $bytes): ?int { - // Les octets viennent soit de l'en-tête (8 octets garantis), soit d'un - // fread dont la longueur est vérifiée par l'appelant : unpack ne peut - // pas travailler à court. + // The bytes come either from the header (8 bytes guaranteed), or from a + // fread whose length is checked by the caller: unpack cannot work short. return unpack($format, $bytes)[1]; } } diff --git a/src/Format/FormatDetectorInterface.php b/src/Format/FormatDetectorInterface.php index 81bcffe..718f968 100644 --- a/src/Format/FormatDetectorInterface.php +++ b/src/Format/FormatDetectorInterface.php @@ -5,19 +5,19 @@ namespace RonanLenouvel\RawPreviewExtractor\Format; /** - * Identifie le format RAW d'un fichier à partir de sa signature binaire. + * Identifies the RAW format of a file from its binary signature. * - * Un détecteur détecte : il ne juge pas. Un fichier illisible, absent ou - * non-RAW donne `null`, jamais une exception — c'est à l'appelant de décider - * si l'absence de format est une erreur. + * A detector detects: it does not judge. An unreadable, missing or non-RAW file + * yields `null`, never an exception — it is up to the caller to decide whether + * the absence of a format is an error. */ interface FormatDetectorInterface { /** - * @param string $path chemin absolu du fichier à identifier + * @param string $path absolute path of the file to identify * - * @return Format|null le format détecté, ou null si le fichier n'est pas - * un RAW supporté, est illisible ou n'existe pas + * @return Format|null the detected format, or null if the file is not a + * supported RAW, is unreadable or does not exist */ public function detect(string $path): ?Format; } diff --git a/src/Parser/Cr3/Box.php b/src/Parser/Cr3/Box.php index 8c5872f..9b3d58e 100644 --- a/src/Parser/Cr3/Box.php +++ b/src/Parser/Cr3/Box.php @@ -5,19 +5,19 @@ namespace RonanLenouvel\RawPreviewExtractor\Parser\Cr3; /** - * Une boîte ISO-BMFF, localisée dans le fichier. + * An ISO-BMFF box, located in the file. * - * La boîte porte sa position et sa taille, jamais son contenu : un `mdat` pèse - * plusieurs dizaines de mégaoctets, on ne le charge pas pour l'inventorier. - * C'est {@see IsoBmffBoxReader::readPayload()} qui lit les octets à la demande. + * The box carries its position and its size, never its content: an `mdat` + * weighs several tens of megabytes, we do not load it just to inventory it. + * It is {@see IsoBmffBoxReader::readPayload()} that reads the bytes on demand. */ final readonly class Box { /** - * @param string $type type sur 4 caractères ASCII (`ftyp`, `moov`, `uuid`…) - * @param int $offset position de la boîte dans le fichier, en-tête compris - * @param int $payloadOffset position du contenu, après l'en-tête - * @param int $payloadLength taille du contenu, en octets + * @param string $type 4-ASCII-character type (`ftyp`, `moov`, `uuid`…) + * @param int $offset position of the box in the file, header included + * @param int $payloadOffset position of the content, after the header + * @param int $payloadLength size of the content, in bytes */ public function __construct( public string $type, diff --git a/src/Parser/Cr3/Cr3PreviewParser.php b/src/Parser/Cr3/Cr3PreviewParser.php index 8c506e0..2d22358 100644 --- a/src/Parser/Cr3/Cr3PreviewParser.php +++ b/src/Parser/Cr3/Cr3PreviewParser.php @@ -11,51 +11,51 @@ use RonanLenouvel\RawPreviewExtractor\Parser\PreviewParserInterface; /** - * Extrait la preview JPEG d'un CR3 (Canon RAW v3, conteneur ISO-BMFF). + * Extracts the JPEG preview of a CR3 (Canon RAW v3, ISO-BMFF container). * - * La preview vit dans la boîte `PRVW`, sous une boîte `uuid` dédiée **à la racine** - * du fichier. `THMB` porte une vignette bien plus petite, sous l'UUID Canon, et - * sert de repli. + * The preview lives in the `PRVW` box, under a dedicated `uuid` box **at the + * root** of the file. `THMB` carries a much smaller thumbnail, under the Canon + * UUID, and serves as a fallback. * - * **Le CR3 n'a pas de spécification publique** : sa structure vient de - * rétro-ingénierie communautaire et peut varier selon les modèles. Le code est - * donc écrit pour être tolérant — il localise le JPEG par son magic plutôt que - * de s'appuyer sur des décalages fixes. + * **CR3 has no public specification**: its structure comes from community + * reverse engineering and may vary depending on the model. The code is + * therefore written to be tolerant — it locates the JPEG by its magic rather + * than relying on fixed offsets. * - * Ce parseur orchestre : il ne lit pas d'octets lui-même, il délègue à + * This parser orchestrates: it does not read bytes itself, it delegates to * {@see IsoBmffBoxReader}. */ final class Cr3PreviewParser implements PreviewParserInterface { /** - * Emplacements des previews, par ordre de préférence. + * Preview locations, in order of preference. * - * `PRVW` et `THMB` ne vivent **ni sous le même UUID, ni au même niveau** — - * structure vérifiée sur Canon EOS R et EOS RP : + * `PRVW` and `THMB` live **neither under the same UUID, nor at the same + * level** — structure verified on Canon EOS R and EOS RP: * * ``` * ftyp * moov - * └── uuid 85c0b687… → CMT1, CMT2, THMB (vignette ~15 Ko) - * uuid eaf42b5e… → PRVW (preview ~250 Ko) + * └── uuid 85c0b687… → CMT1, CMT2, THMB (thumbnail ~15 KB) + * uuid eaf42b5e… → PRVW (preview ~250 KB) * mdat * ``` * - * Chercher les deux sous l'UUID Canon ne trouve que la vignette. + * Looking for both under the Canon UUID only finds the thumbnail. * * @var list */ private const PREVIEW_LOCATIONS = [ - // La vraie preview, dans sa propre boîte uuid à la racine. + // The real preview, in its own uuid box at the root. ['uuid' => 'eaf42b5e1c984b88b9fbb7dc406e4d16', 'box' => 'PRVW'], - // Repli : la vignette de l'UUID Canon, sous moov. + // Fallback: the thumbnail of the Canon UUID, under moov. ['uuid' => '85c0b687820f11e08111f4ce462b6a48', 'box' => 'THMB'], ]; - /** Marqueur de début de tout JPEG (SOI). */ + /** Start marker of every JPEG (SOI). */ private const JPEG_MAGIC = "\xFF\xD8"; - /** Longueur de l'UUID qui suit le type d'une boîte `uuid`. */ + /** Length of the UUID that follows the type of a `uuid` box. */ private const UUID_LENGTH = 16; public function extract(string $path, Format $format): ExtractedPreview @@ -79,20 +79,20 @@ public function extract(string $path, Format $format): ExtractedPreview } throw new PreviewNotFoundException(sprintf( - 'Aucune preview JPEG dans %s : ni PRVW ni THMB exploitable.', + 'No JPEG preview in %s: neither PRVW nor THMB usable.', basename($path), )); } /** - * Extrait le JPEG d'une boîte de preview, s'il s'y trouve. + * Extracts the JPEG from a preview box, if it is there. * - * @throws CorruptedFileException si la structure est invalide + * @throws CorruptedFileException if the structure is invalid */ private function jpegFromBox(IsoBmffBoxReader $reader, Box $container, string $type): ?string { - // Certains conteneurs uuid commencent par des octets propriétaires : - // le parcours normal échoue alors, on retombe sur une recherche par type. + // Some uuid containers start with proprietary bytes: the normal walk + // then fails, and we fall back on a search by type. $box = $this->findWithin($reader, $container, $type) ?? $this->findByScanning($reader, $container, $type); @@ -102,23 +102,23 @@ private function jpegFromBox(IsoBmffBoxReader $reader, Box $container, string $t $payload = $reader->readPayload($box); - // PRVW précède son JPEG d'un en-tête propriétaire dont la taille varie - // selon les modèles et n'est pas documentée. Chercher le magic est plus - // robuste que de coder un décalage en dur — et le magic est à valider - // de toute façon. + // PRVW precedes its JPEG with a proprietary header whose size varies + // between models and is not documented. Looking for the magic is more + // robust than hard-coding an offset — and the magic has to be validated + // anyway. $start = strpos($payload, self::JPEG_MAGIC); return false === $start ? null : substr($payload, $start); } /** - * Cherche une boîte parmi les filles directes d'un conteneur uuid. + * Looks for a box among the direct children of a uuid container. * - * On ne cherche pas dans tout le fichier : une boîte `PRVW` ailleurs - * n'est pas la preview du CR3, et s'y fier reviendrait à faire confiance - * à n'importe quelle boîte portant le bon nom. + * We do not search the whole file: a `PRVW` box elsewhere is not the CR3's + * preview, and relying on it would amount to trusting any box carrying the + * right name. * - * @throws CorruptedFileException si la structure est invalide + * @throws CorruptedFileException if the structure is invalid */ private function findWithin(IsoBmffBoxReader $reader, Box $container, string $type): ?Box { @@ -132,23 +132,23 @@ private function findWithin(IsoBmffBoxReader $reader, Box $container, string $ty } /** - * Cherche une boîte dans le contenu brut d'un conteneur, sans supposer que - * celui-ci commence par une boîte. + * Looks for a box in the raw content of a container, without assuming that + * the latter starts with a box. * - * La boîte `uuid` qui porte `PRVW` insère **8 octets propriétaires** entre - * l'UUID et la première boîte — vérifié sur EOS R et EOS RP. Sa taille n'est - * documentée nulle part, et rien ne garantit qu'elle soit la même partout. - * On localise donc le type recherché dans le contenu, plutôt que de coder un - * décalage en dur. + * The `uuid` box carrying `PRVW` inserts **8 proprietary bytes** between the + * UUID and the first box — verified on EOS R and EOS RP. Its size is + * documented nowhere, and nothing guarantees it is the same everywhere. We + * therefore locate the sought type within the content, rather than + * hard-coding an offset. * - * @throws CorruptedFileException si la structure est invalide + * @throws CorruptedFileException if the structure is invalid */ private function findByScanning(IsoBmffBoxReader $reader, Box $container, string $type): ?Box { $payload = $reader->readPayload($container); $position = strpos($payload, $type, self::UUID_LENGTH); - // Le type est précédé des 4 octets de taille : la boîte commence là. + // The type is preceded by the 4 size bytes: the box starts there. if (false === $position || $position < 4) { return null; } @@ -164,11 +164,11 @@ private function findByScanning(IsoBmffBoxReader $reader, Box $container, string } /** - * Lit les dimensions dans le segment SOF du JPEG. + * Reads the dimensions from the JPEG's SOF segment. * * @return array{int, int} * - * @throws CorruptedFileException si aucun segment SOF n'est trouvable + * @throws CorruptedFileException if no SOF segment can be found */ private function readJpegDimensions(string $jpeg): array { @@ -184,7 +184,7 @@ private function readJpegDimensions(string $jpeg): array $marker = ord($jpeg[$position + 1]); - // SOF0 à SOF15, hors DHT (C4), DNL (C8) et DAC (CC) qui partagent la plage. + // SOF0 to SOF15, except DHT (C4), DNL (C8) and DAC (CC) which share the range. if ($marker >= 0xC0 && $marker <= 0xCF && !in_array($marker, [0xC4, 0xC8, 0xCC], true)) { return [ unpack('n', substr($jpeg, $position + 7, 2))[1], @@ -196,7 +196,7 @@ private function readJpegDimensions(string $jpeg): array } throw new CorruptedFileException( - 'JPEG sans segment SOF : dimensions introuvables.', + 'JPEG without SOF segment: dimensions not found.', ); } } diff --git a/src/Parser/Cr3/IsoBmffBoxReader.php b/src/Parser/Cr3/IsoBmffBoxReader.php index 2ca660b..07e8de6 100644 --- a/src/Parser/Cr3/IsoBmffBoxReader.php +++ b/src/Parser/Cr3/IsoBmffBoxReader.php @@ -7,37 +7,37 @@ use RonanLenouvel\RawPreviewExtractor\Exception\CorruptedFileException; /** - * Lecture bas niveau d'un conteneur ISO-BMFF (famille MP4/HEIF, dont le CR3). + * Low-level reading of an ISO-BMFF container (MP4/HEIF family, CR3 included). * - * Ce lecteur connaît la **structure** — un arbre de boîtes `[taille][type][contenu]` — - * et rien de la sémantique : il ignore ce qu'est une preview. + * This reader knows the **structure** — a tree of `[size][type][content]` boxes — + * and nothing of the semantics: it has no idea what a preview is. * - * **ISO-BMFF est toujours big-endian**, quel que soit l'appareil : contrairement - * au TIFF, il n'y a pas d'ordre d'octets à détecter. + * **ISO-BMFF is always big-endian**, whatever the camera: unlike TIFF, there is + * no byte order to detect. * - * Le fichier est traité comme de l'entrée **non fiable** : chaque taille est - * validée contre la taille réelle du fichier, la progression du curseur est - * vérifiée à chaque itération, et la récursion est bornée. + * The file is treated as **untrusted** input: every size is validated against + * the file's real size, the cursor's progress is checked at each iteration, and + * the recursion is bounded. */ final class IsoBmffBoxReader { - /** Taille d'un en-tête de boîte : 4 octets de taille + 4 de type. */ + /** Size of a box header: 4 bytes of size + 4 of type. */ private const HEADER_LENGTH = 8; - /** En-tête étendu, quand la taille est portée sur 64 bits. */ + /** Extended header, when the size is carried on 64 bits. */ private const EXTENDED_HEADER_LENGTH = 16; - /** Longueur de l'UUID qui suit le type d'une boîte `uuid`. */ + /** Length of the UUID that follows the type of a `uuid` box. */ private const UUID_LENGTH = 16; - /** Profondeur maximale d'imbrication : un fichier hostile pourrait saturer la pile. */ + /** Maximum nesting depth: a hostile file could saturate the stack. */ private const MAX_DEPTH = 8; /** - * Boîtes dont le contenu est lui-même un arbre de boîtes. + * Boxes whose content is itself a tree of boxes. * - * Toute autre boîte est une feuille : `mdat` contient des données brutes que - * l'on prendrait pour des boîtes si on descendait dedans. + * Any other box is a leaf: `mdat` contains raw data that we would mistake + * for boxes if we descended into it. */ private const CONTAINER_TYPES = ['moov', 'trak', 'mdia', 'minf', 'stbl', 'uuid']; @@ -47,14 +47,14 @@ final class IsoBmffBoxReader private readonly int $fileSize; /** - * @param string $path chemin du fichier à lire + * @param string $path path of the file to read * - * @throws CorruptedFileException si le fichier est illisible + * @throws CorruptedFileException if the file is unreadable */ public function __construct(private readonly string $path) { if (!is_file($path)) { - throw new CorruptedFileException(sprintf('Fichier illisible : %s', $path)); + throw new CorruptedFileException(sprintf('Unreadable file: %s', $path)); } $this->handle = fopen($path, 'rb'); @@ -67,20 +67,20 @@ public function __destruct() } /** - * Inventorie les boîtes de premier niveau. + * Inventories the top-level boxes. * * @return list * - * @throws CorruptedFileException si une taille est invalide ou hors bornes + * @throws CorruptedFileException if a size is invalid or out of bounds */ public function readBoxes(): array { - // Un fichier trop court pour porter ne serait-ce qu'un en-tête n'est pas - // un conteneur vide : il est tronqué. À l'intérieur d'une boîte en - // revanche, un reliquat de moins de 8 octets est du padding normal. + // A file too short to carry even a header is not an empty container: it + // is truncated. Inside a box on the other hand, a remainder of less than + // 8 bytes is normal padding. if ($this->fileSize < self::HEADER_LENGTH) { throw new CorruptedFileException(sprintf( - 'Fichier tronqué : %d octets, moins qu\'un en-tête de boîte.', + 'Truncated file: %d bytes, less than a box header.', $this->fileSize, )); } @@ -89,11 +89,11 @@ public function readBoxes(): array } /** - * Cherche la première boîte du type donné, en descendant dans les conteneurs. + * Looks for the first box of the given type, descending into the containers. * - * @param string $type type sur 4 caractères + * @param string $type 4-character type * - * @throws CorruptedFileException si la structure est invalide + * @throws CorruptedFileException if the structure is invalid */ public function find(string $type): ?Box { @@ -101,14 +101,14 @@ public function find(string $type): ?Box } /** - * Cherche une boîte `uuid` portant l'UUID donné. + * Looks for a `uuid` box carrying the given UUID. * - * Plusieurs boîtes `uuid` distinctes coexistent dans un CR3 : le type ne - * suffit pas à les identifier, il faut lire les 16 octets qui le suivent. + * Several distinct `uuid` boxes coexist in a CR3: the type is not enough to + * identify them, the 16 bytes that follow it must be read. * - * @param string $uuid les 16 octets bruts de l'UUID recherché + * @param string $uuid the 16 raw bytes of the UUID being looked for * - * @throws CorruptedFileException si la structure est invalide + * @throws CorruptedFileException if the structure is invalid */ public function findUuid(string $uuid): ?Box { @@ -116,11 +116,11 @@ public function findUuid(string $uuid): ?Box } /** - * Inventorie les boîtes filles d'un conteneur. + * Inventories the child boxes of a container. * * @return list * - * @throws CorruptedFileException si la structure est invalide + * @throws CorruptedFileException if the structure is invalid */ public function childBoxes(Box $box): array { @@ -128,9 +128,9 @@ public function childBoxes(Box $box): array } /** - * Lit le contenu d'une boîte. + * Reads the content of a box. * - * @throws CorruptedFileException si la plage sort du fichier + * @throws CorruptedFileException if the range falls outside the file */ public function readPayload(Box $box): string { @@ -138,15 +138,15 @@ public function readPayload(Box $box): string } /** - * Lit `$length` octets bruts à partir de `$offset`. + * Reads `$length` raw bytes starting from `$offset`. * - * @throws CorruptedFileException si la plage sort du fichier + * @throws CorruptedFileException if the range falls outside the file */ public function readBytes(int $offset, int $length): string { if ($length < 1 || $offset < 0 || $offset + $length > $this->fileSize) { throw new CorruptedFileException(sprintf( - 'Lecture hors bornes : %d octets à l\'offset %d (taille du fichier : %d).', + 'Read out of bounds: %d bytes at offset %d (file size: %d).', $length, $offset, $this->fileSize, @@ -159,7 +159,7 @@ public function readBytes(int $offset, int $length): string } /** - * Inventorie les boîtes contenues dans une plage donnée. + * Inventories the boxes contained in a given range. * * @return list * @@ -174,9 +174,9 @@ private function readBoxesIn(int $start, int $end): array $box = $this->readBoxAt($offset, $end); $boxes[] = $box; - // La progression est structurellement garantie : readBoxAt() refuse - // toute taille inférieure à l'en-tête, donc le curseur avance d'au - // moins 8 octets à chaque tour. + // Progress is structurally guaranteed: readBoxAt() refuses any size + // smaller than the header, so the cursor advances by at least + // 8 bytes on each pass. $offset = $box->payloadOffset + $box->payloadLength; } @@ -184,7 +184,7 @@ private function readBoxesIn(int $start, int $end): array } /** - * Décode l'en-tête d'une boîte, en traitant les trois cas particuliers de `size`. + * Decodes a box header, handling the three special cases of `size`. * * @throws CorruptedFileException */ @@ -194,13 +194,13 @@ private function readBoxAt(int $offset, int $end): Box $size = unpack('N', substr($header, 0, 4))[1]; $type = substr($header, 4, 4); - // size == 1 : la taille réelle est sur 64 bits, juste après le type. + // size == 1: the real size is on 64 bits, right after the type. if (1 === $size) { $size = unpack('J', $this->readBytes($offset + self::HEADER_LENGTH, 8))[1]; if ($size < self::EXTENDED_HEADER_LENGTH) { throw new CorruptedFileException(sprintf( - 'Taille de boîte 64 bits invalide : %d à l\'offset %d.', + 'Invalid 64-bit box size: %d at offset %d.', $size, $offset, )); @@ -209,14 +209,14 @@ private function readBoxAt(int $offset, int $end): Box return $this->box($type, $offset, self::EXTENDED_HEADER_LENGTH, $size, $end); } - // size == 0 : la boîte s'étend jusqu'à la fin du fichier. + // size == 0: the box extends to the end of the file. if (0 === $size) { return $this->box($type, $offset, self::HEADER_LENGTH, $end - $offset, $end); } if ($size < self::HEADER_LENGTH) { throw new CorruptedFileException(sprintf( - 'Taille de boîte invalide : %d à l\'offset %d (minimum 8).', + 'Invalid box size: %d at offset %d (minimum 8).', $size, $offset, )); @@ -226,13 +226,13 @@ private function readBoxAt(int $offset, int $end): Box } /** - * @throws CorruptedFileException si la boîte déborde de la plage autorisée + * @throws CorruptedFileException if the box overflows the allowed range */ private function box(string $type, int $offset, int $headerLength, int $size, int $end): Box { if ($offset + $size > $end) { throw new CorruptedFileException(sprintf( - 'Boîte « %s » hors bornes : %d octets annoncés à l\'offset %d.', + 'Box "%s" out of bounds: %d bytes announced at offset %d.', $type, $size, $offset, @@ -243,10 +243,10 @@ private function box(string $type, int $offset, int $headerLength, int $size, in } /** - * Cherche récursivement une boîte `uuid` portant l'UUID donné. + * Recursively looks for a `uuid` box carrying the given UUID. * - * Dans un CR3 réel, l'UUID Canon vit sous `moov` et non à la racine : une - * recherche limitée au premier niveau ne le trouverait jamais. + * In a real CR3, the Canon UUID lives under `moov` and not at the root: a + * search limited to the first level would never find it. * * @param list $boxes * @@ -311,7 +311,7 @@ private function findIn(array $boxes, string $type, int $depth): ?Box } /** - * Boîtes filles d'un conteneur. + * Child boxes of a container. * * @return list * @@ -319,7 +319,7 @@ private function findIn(array $boxes, string $type, int $depth): ?Box */ private function childrenOf(Box $box): array { - // Une boîte uuid porte 16 octets d'UUID avant son contenu. + // A uuid box carries 16 bytes of UUID before its content. $start = 'uuid' === $box->type ? $box->payloadOffset + self::UUID_LENGTH : $box->payloadOffset; @@ -333,14 +333,14 @@ private function childrenOf(Box $box): array try { return $this->readBoxesIn($start, $end); } catch (CorruptedFileException) { - // Descendre dans un conteneur est spéculatif : toutes les boîtes - // `uuid` ne contiennent pas des boîtes. Celle qui porte PRVW dans un - // CR3 commence par un en-tête propriétaire, dont les octets lus - // comme un en-tête donnent une taille absurde. + // Descending into a container is speculative: not every `uuid` box + // contains boxes. The one carrying PRVW in a CR3 starts with a + // proprietary header, whose bytes read as a header give an absurd + // size. // - // Une boîte sans enfants lisibles n'a pas d'enfants — ce n'est pas - // une corruption du fichier. Les lectures non spéculatives - // (readBoxes, readPayload, readBytes) restent strictes. + // A box without readable children has no children — this is not a + // corruption of the file. The non-speculative reads (readBoxes, + // readPayload, readBytes) stay strict. return []; } } diff --git a/src/Parser/PreviewParserInterface.php b/src/Parser/PreviewParserInterface.php index fd2e3c7..4ea1876 100644 --- a/src/Parser/PreviewParserInterface.php +++ b/src/Parser/PreviewParserInterface.php @@ -10,21 +10,21 @@ use RonanLenouvel\RawPreviewExtractor\Format\Format; /** - * Extrait la preview JPEG d'un fichier RAW d'un conteneur donné. + * Extracts the JPEG preview of a RAW file of a given container. * - * Une implémentation par famille de conteneur : TIFF pour CR2/NEF/ARW/DNG, - * ISO-BMFF pour CR3. Toutes sont interchangeables derrière ce contrat — c'est - * ce qui permet à la façade de résoudre par une simple map `Format → parser`, - * et d'accueillir un nouveau format sans être modifiée. + * One implementation per container family: TIFF for CR2/NEF/ARW/DNG, ISO-BMFF + * for CR3. All are interchangeable behind this contract — that is what lets the + * facade resolve through a simple `Format → parser` map, and welcome a new + * format without being modified. */ interface PreviewParserInterface { /** - * @param string $path chemin du fichier RAW - * @param Format $format format déjà identifié par le détecteur + * @param string $path path of the RAW file + * @param Format $format format already identified by the detector * - * @throws PreviewNotFoundException si le fichier est valide mais sans preview - * @throws CorruptedFileException si le fichier est illisible ou incohérent + * @throws PreviewNotFoundException if the file is valid but without a preview + * @throws CorruptedFileException if the file is unreadable or inconsistent */ public function extract(string $path, Format $format): ExtractedPreview; } diff --git a/src/Parser/Tiff/IfdEntry.php b/src/Parser/Tiff/IfdEntry.php index 8365fa1..739d043 100644 --- a/src/Parser/Tiff/IfdEntry.php +++ b/src/Parser/Tiff/IfdEntry.php @@ -5,25 +5,25 @@ namespace RonanLenouvel\RawPreviewExtractor\Parser\Tiff; /** - * Une entrée d'IFD, valeurs déjà résolues. + * An IFD entry, values already resolved. * - * Value object pur : il ne connaît ni fichier, ni handle, ni endianness. C'est - * le {@see TiffReader} qui résout les valeurs à la lecture — y compris celles - * stockées hors de l'entrée — et construit des `IfdEntry` complètes. + * A pure value object: it knows neither file, nor handle, nor endianness. It is + * the {@see TiffReader} that resolves the values while reading — including + * those stored outside the entry — and builds complete `IfdEntry` objects. * - * Cette entrée est donc librement transportable et comparable, ce qu'un objet - * qui garderait un curseur de fichier ne serait pas. + * This entry is therefore freely transportable and comparable, which an object + * holding on to a file cursor would not be. */ final readonly class IfdEntry { /** - * @param int $tag identifiant du tag (voir {@see TiffTag}) - * @param int $type code du type de donnée TIFF (1 à 12) - * @param int $count nombre de valeurs, tel qu'annoncé par l'entrée - * @param list $values valeurs numériques résolues ; vide si le type - * est ASCII ou inconnu - * @param string $ascii valeur textuelle, NUL de fin retiré ; chaîne - * vide si le type n'est pas ASCII + * @param int $tag tag identifier (see {@see TiffTag}) + * @param int $type TIFF data type code (1 to 12) + * @param int $count number of values, as announced by the entry + * @param list $values resolved numeric values; empty if the type is + * ASCII or unknown + * @param string $ascii textual value, trailing NUL removed; empty + * string if the type is not ASCII */ public function __construct( public int $tag, @@ -35,10 +35,10 @@ public function __construct( } /** - * Première valeur numérique de l'entrée, ou null s'il n'y en a pas. + * First numeric value of the entry, or null if there is none. * - * Raccourci pour le cas courant : la plupart des tags exploités ici - * (offsets, tailles, dimensions) portent une valeur unique. + * A shortcut for the common case: most tags used here (offsets, sizes, + * dimensions) carry a single value. */ public function value(): ?int { diff --git a/src/Parser/Tiff/TiffPreviewParser.php b/src/Parser/Tiff/TiffPreviewParser.php index a63d4ef..ccffedd 100644 --- a/src/Parser/Tiff/TiffPreviewParser.php +++ b/src/Parser/Tiff/TiffPreviewParser.php @@ -11,44 +11,44 @@ use RonanLenouvel\RawPreviewExtractor\Parser\PreviewParserInterface; /** - * Extrait la preview JPEG des RAW bâtis sur TIFF : CR2, NEF, ARW et DNG. + * Extracts the JPEG preview of RAW files built on TIFF: CR2, NEF, ARW and DNG. * - * Un seul parseur couvre les quatre formats. Plutôt que de coder en dur - * l'organisation d'un constructeur — variable d'une génération d'appareil à - * l'autre — il parcourt **tous** les IFD, collecte tous les blocs JPEG - * candidats et retient **le plus grand**. + * A single parser covers the four formats. Rather than hard-coding a + * manufacturer's layout — which varies from one camera generation to the next — + * it walks **all** the IFDs, collects every candidate JPEG block and keeps + * **the largest**. * - * Ce parseur orchestre : il ne lit pas d'octets lui-même, il délègue au + * This parser orchestrates: it does not read bytes itself, it delegates to the * {@see TiffReader}. */ final class TiffPreviewParser implements PreviewParserInterface { - /** Compression JPEG « ancienne » et JPEG au sens TIFF 6.0. */ + /** "Old-style" JPEG compression and JPEG in the TIFF 6.0 sense. */ private const JPEG_COMPRESSIONS = [6, 7]; - /** Tout JPEG commence par ce marqueur (SOI). */ + /** Every JPEG starts with this marker (SOI). */ private const JPEG_MAGIC = "\xFF\xD8"; /** - * Marqueurs SOF d'un JPEG réellement décodable. + * SOF markers of a genuinely decodable JPEG. * - * `Compression = 6` ne suffit pas à distinguer une preview des données du - * capteur : Canon stocke celles-ci en **JPEG lossless** dans un CR2, avec le - * même tag. Ces blocs sont les plus gros du fichier et gagneraient donc la - * comparaison par taille — pour rendre 28 Mo qu'aucun décodeur ne lit. + * `Compression = 6` is not enough to tell a preview from the sensor data: + * Canon stores the latter as **lossless JPEG** in a CR2, with the same tag. + * Those blocks are the biggest in the file and would therefore win the + * comparison by size — only to return 28 MB that no decoder reads. * - * Vérifié sur six appareils : les previews utilisent toutes SOF0 ; seul le - * capteur d'un CR2 utilise SOF3. + * Verified on six cameras: the previews all use SOF0; only a CR2's sensor + * uses SOF3. * * @var list */ private const DECODABLE_SOF_MARKERS = [ - 0xC0, // SOF0 — baseline, le cas de toutes les previews observées + 0xC0, // SOF0 — baseline, the case of every observed preview 0xC1, // SOF1 — extended sequential - 0xC2, // SOF2 — progressif + 0xC2, // SOF2 — progressive ]; - /** Profondeur maximale de récursion dans les sous-IFD. */ + /** Maximum recursion depth into the SubIFDs. */ private const MAX_SUB_IFD_DEPTH = 4; public function extract(string $path, Format $format): ExtractedPreview @@ -60,12 +60,12 @@ public function extract(string $path, Format $format): ExtractedPreview $this->collectFromIfd($reader, $offset, $candidates, 0); } - // La plus grande preview est la plus utile : un RAW en porte souvent - // plusieurs, de la vignette 160×120 à la pleine résolution. + // The largest preview is the most useful: a RAW often carries several, + // from the 160x120 thumbnail to full resolution. usort($candidates, static fn (array $a, array $b): int => $b['length'] <=> $a['length']); - // Un candidat n'est retenu que s'il est réellement décodable : le plus - // gros bloc d'un CR2 est le capteur en JPEG lossless, pas une preview. + // A candidate is only kept if it is genuinely decodable: the biggest + // block of a CR2 is the sensor in lossless JPEG, not a preview. foreach ($candidates as $candidate) { $preview = $this->tryBuildPreview($reader, $candidate, $format); @@ -75,12 +75,12 @@ public function extract(string $path, Format $format): ExtractedPreview } throw new PreviewNotFoundException( - sprintf('Aucune preview JPEG exploitable dans %s.', basename($path)), + sprintf('No usable JPEG preview in %s.', basename($path)), ); } /** - * Collecte les candidats d'un IFD, puis descend dans ses sous-IFD. + * Collects the candidates of an IFD, then descends into its SubIFDs. * * @param list $candidates * @@ -107,7 +107,7 @@ private function collectFromIfd(TiffReader $reader, int $offset, array &$candida } /** - * Le chemin courant : le couple JPEGInterchangeFormat / …Length. + * The common path: the JPEGInterchangeFormat / …Length pair. * * @param array $entries * @@ -126,9 +126,9 @@ private function jpegInterchangeCandidate(array $entries): ?array } /** - * L'autre chemin : StripOffsets / StripByteCounts, si et seulement si le - * tag Compression annonce du JPEG. Sans ce contrôle, on prendrait les - * données brutes du capteur pour une preview. + * The other path: StripOffsets / StripByteCounts, if and only if the + * Compression tag announces JPEG. Without this check, we would mistake the + * raw sensor data for a preview. * * @param array $entries * @@ -183,19 +183,19 @@ private function tryBuildPreview(TiffReader $reader, array $candidate, Format $f { $jpeg = $reader->readBytes($candidate['offset'], $candidate['length']); - // Un tag qui ment sur son contenu est courant dans les RAW : le - // PowerShot G12 annonce Compression = 6 sur des données brutes, dans - // l'IFD dont le bloc est justement le plus gros. Ce n'est pas une - // corruption du fichier — c'est un candidat de plus à écarter. + // A tag that lies about its content is common in RAW files: the + // PowerShot G12 announces Compression = 6 on raw data, in the IFD whose + // block happens to be the biggest. This is not a corruption of the file + // — it is one more candidate to rule out. if (!str_starts_with($jpeg, self::JPEG_MAGIC)) { return null; } $sof = $this->findSofSegment($jpeg); - // Pas de SOF, ou un SOF que personne ne décode (lossless, arithmétique, - // différentiel) : ce bloc n'est pas une preview affichable. On passe au - // candidat suivant plutôt que de rendre des octets inutilisables. + // No SOF, or a SOF that nobody decodes (lossless, arithmetic, + // differential): this block is not a displayable preview. We move on to + // the next candidate rather than return unusable bytes. if (null === $sof || !in_array($sof['marker'], self::DECODABLE_SOF_MARKERS, true)) { return null; } @@ -204,15 +204,15 @@ private function tryBuildPreview(TiffReader $reader, array $candidate, Format $f } /** - * Localise le segment SOF et en extrait le marqueur et les dimensions. + * Locates the SOF segment and extracts its marker and dimensions from it. * - * Le SOF fait autorité sur les dimensions : les tags ImageWidth/ImageLength - * de l'IFD décrivent souvent l'image RAW pleine résolution, pas la preview. - * Son marqueur dit aussi **comment** l'image est encodée, donc si un - * décodeur courant saura la lire. + * The SOF is authoritative on the dimensions: the IFD's ImageWidth/ImageLength + * tags often describe the full-resolution RAW image, not the preview. Its + * marker also says **how** the image is encoded, hence whether a common + * decoder will know how to read it. * - * @return array{marker: int, width: int, height: int}|null null si le JPEG - * ne porte aucun SOF + * @return array{marker: int, width: int, height: int}|null null if the JPEG + * carries no SOF */ private function findSofSegment(string $jpeg): ?array { @@ -228,7 +228,7 @@ private function findSofSegment(string $jpeg): ?array $marker = ord($jpeg[$position + 1]); - // SOF0 à SOF15, hors DHT (C4), DNL (C8) et DAC (CC) qui partagent la plage. + // SOF0 to SOF15, except DHT (C4), DNL (C8) and DAC (CC) which share the range. if ($marker >= 0xC0 && $marker <= 0xCF && !in_array($marker, [0xC4, 0xC8, 0xCC], true)) { return [ 'marker' => $marker, diff --git a/src/Parser/Tiff/TiffReader.php b/src/Parser/Tiff/TiffReader.php index 567e473..486c783 100644 --- a/src/Parser/Tiff/TiffReader.php +++ b/src/Parser/Tiff/TiffReader.php @@ -7,15 +7,15 @@ use RonanLenouvel\RawPreviewExtractor\Exception\CorruptedFileException; /** - * Lecture bas niveau d'un conteneur TIFF 6.0. + * Low-level reading of a TIFF 6.0 container. * - * Ce lecteur connaît la **structure** du conteneur — en-tête, chaîne d'IFD, - * entrées typées — et rien de la sémantique du contenu : il ignore ce qu'est - * une preview. C'est cette frontière qui le rend testable sans fichier RAW réel. + * This reader knows the container's **structure** — header, IFD chain, typed + * entries — and nothing of the content's semantics: it has no idea what a + * preview is. It is this boundary that makes it testable without a real RAW file. * - * Toutes les lectures traitent le fichier comme de l'entrée **non fiable** : - * chaque offset est validé contre la taille réelle, chaque `fread` est vérifié - * en longueur avant `unpack`, et la chaîne d'IFD est protégée contre les boucles. + * All reads treat the file as **untrusted** input: every offset is validated + * against the real size, every `fread` is checked for length before `unpack`, + * and the IFD chain is protected against loops. */ final class TiffReader { @@ -23,26 +23,26 @@ final class TiffReader private const TIFF_MAGIC = 42; private const ENTRY_LENGTH = 12; - /** Au-delà, un IFD trahit un fichier corrompu ou hostile. */ + /** Beyond this, an IFD betrays a corrupted or hostile file. */ private const MAX_ENTRIES_PER_IFD = 4096; - /** Garde-fou contre une chaîne d'IFD artificiellement longue. */ + /** Safeguard against an artificially long IFD chain. */ private const MAX_IFD_CHAIN = 64; /** - * Au-delà, une valeur n'est pas résolue — l'entrée reste lisible. + * Beyond this, a value is not resolved — the entry stays readable. * - * Aucun tag exploité par ce package n'approche cette taille : ce sont des - * offsets, des tailles, des dimensions, un nom de fabricant. Les gros blocs - * sont des métadonnées propriétaires (`MakerNote` pèse couramment 75 Ko dans - * un CR2) que l'on traverse sans jamais les lire. + * No tag used by this package comes close to this size: they are offsets, + * sizes, dimensions, a manufacturer name. The big blocks are proprietary + * metadata (`MakerNote` commonly weighs 75 KB in a CR2) that we traverse + * without ever reading them. * - * Ne pas les résoudre évite deux choses : gaspiller la lecture, et rejeter - * un fichier parfaitement valide — ce qui arrivait au Canon 5D de 2005. + * Not resolving them avoids two things: wasting the read, and rejecting a + * perfectly valid file — which is what happened to the 2005 Canon 5D. */ private const MAX_RESOLVED_VALUE_LENGTH = 65536; - /** Taille en octets de chaque type TIFF 6.0, indexée par code de type. */ + /** Size in bytes of each TIFF 6.0 type, indexed by type code. */ private const TYPE_SIZES = [ 1 => 1, // BYTE 2 => 1, // ASCII @@ -66,33 +66,33 @@ final class TiffReader private readonly int $firstIfdOffset; /** - * @param string $path chemin du fichier TIFF à lire + * @param string $path path of the TIFF file to read * - * @throws CorruptedFileException si le fichier est illisible ou n'est pas un TIFF + * @throws CorruptedFileException if the file is unreadable or is not a TIFF */ public function __construct(private readonly string $path) { if (!is_file($path)) { - throw new CorruptedFileException(sprintf('Fichier illisible : %s', $path)); + throw new CorruptedFileException(sprintf('Unreadable file: %s', $path)); } - // is_file() a déjà écarté l'absent et le répertoire : fopen ne peut - // plus échouer que sur un problème de droits, que le @ absorberait - // silencieusement — on le laisse donc remonter. + // is_file() has already ruled out the missing file and the directory: + // fopen can now only fail on a permissions problem, which @ would + // absorb silently — so we let it bubble up. $this->handle = fopen($path, 'rb'); $this->fileSize = (int) filesize($path); $header = (string) fread($this->handle, self::HEADER_LENGTH); if (self::HEADER_LENGTH !== strlen($header)) { - throw new CorruptedFileException('En-tête TIFF tronqué : moins de 8 octets.'); + throw new CorruptedFileException('Truncated TIFF header: fewer than 8 bytes.'); } $this->bigEndian = match (substr($header, 0, 2)) { 'II' => false, 'MM' => true, default => throw new CorruptedFileException( - 'Ordre d\'octets inconnu : ni « II » ni « MM ».', + 'Unknown byte order: neither "II" nor "MM".', ), }; @@ -100,7 +100,7 @@ public function __construct(private readonly string $path) if (self::TIFF_MAGIC !== $magic) { throw new CorruptedFileException( - sprintf('Nombre magique TIFF invalide : %d au lieu de 42.', $magic), + sprintf('Invalid TIFF magic number: %d instead of 42.', $magic), ); } @@ -113,7 +113,7 @@ public function __destruct() } /** - * Le fichier est-il big-endian (« MM ») ? + * Is the file big-endian ("MM")? */ public function isBigEndian(): bool { @@ -121,15 +121,15 @@ public function isBigEndian(): bool } /** - * Parcourt la chaîne d'IFD et renvoie les offsets rencontrés, dans l'ordre. + * Walks the IFD chain and returns the offsets encountered, in order. * - * Un IFD déjà visité clôt le parcours : c'est le garde-fou anti-boucle. Un - * fichier corrompu — ou malveillant — peut faire pointer un IFD sur lui-même, - * ce qu'aucun fichier réel ne fait. + * An already visited IFD ends the walk: that is the anti-loop safeguard. A + * corrupted — or malicious — file can make an IFD point to itself, which no + * real file does. * * @return list * - * @throws CorruptedFileException si un offset de la chaîne est hors bornes + * @throws CorruptedFileException if an offset of the chain is out of bounds */ public function readIfdOffsets(): array { @@ -151,11 +151,11 @@ public function readIfdOffsets(): array } /** - * Lit les entrées d'un IFD situé à l'offset donné. + * Reads the entries of an IFD located at the given offset. * * @return list * - * @throws CorruptedFileException si l'offset est hors bornes ou l'IFD tronqué + * @throws CorruptedFileException if the offset is out of bounds or the IFD truncated */ public function readIfd(int $offset): array { @@ -172,9 +172,9 @@ public function readIfd(int $offset): array } /** - * Construit une entrée à partir de ses 12 octets, valeurs résolues comprises. + * Builds an entry from its 12 bytes, resolved values included. * - * @throws CorruptedFileException si un offset indirect est hors bornes + * @throws CorruptedFileException if an indirect offset is out of bounds */ private function readEntry(string $bytes): IfdEntry { @@ -185,36 +185,35 @@ private function readEntry(string $bytes): IfdEntry $size = self::TYPE_SIZES[$type] ?? null; - // Un type inconnu n'est pas une corruption : l'entrée reste lisible, - // seule sa valeur est indéterminable. + // An unknown type is not a corruption: the entry stays readable, only + // its value is undeterminable. if (null === $size || $count < 1) { return new IfdEntry($tag, $type, $count); } $length = $size * $count; - // Une valeur ne peut pas être plus grande que le fichier qui la porte : - // c'est une taille absurde, donc une structure qui ment. + // A value cannot be larger than the file that carries it: that is an + // absurd size, hence a structure that lies. if ($length > $this->fileSize) { throw new CorruptedFileException(sprintf( - 'Entrée 0x%04X : %d octets annoncés, plus que le fichier entier (%d).', + 'Entry 0x%04X: %d bytes announced, more than the whole file (%d).', $tag, $length, $this->fileSize, )); } - // Trop gros pour être un tag que ce package exploite : on garde l'entrée - // — elle reste traversable — mais on ne lit pas sa valeur. Un MakerNote - // de 75 Ko n'est pas une corruption, c'est une métadonnée qui ne nous - // regarde pas. + // Too big to be a tag this package uses: we keep the entry — it stays + // traversable — but we do not read its value. A 75 KB MakerNote is not + // a corruption, it is metadata that is none of our business. if ($length > self::MAX_RESOLVED_VALUE_LENGTH) { return new IfdEntry($tag, $type, $count); } - // Règle des 4 octets : au-delà, le champ porte un offset absolu et non - // la valeur elle-même. S'y tromper donne des offsets qui ressemblent à - // des données valides. + // The 4-byte rule: beyond that, the field carries an absolute offset and + // not the value itself. Getting it wrong yields offsets that look like + // valid data. $raw = $length <= 4 ? substr($field, 0, $length) : $this->readBytes($this->unpackLong($field), $length); @@ -227,19 +226,19 @@ private function readEntry(string $bytes): IfdEntry } /** - * Lit `$length` octets bruts à partir de `$offset`. + * Reads `$length` raw bytes starting from `$offset`. * - * @throws CorruptedFileException si la plage sort du fichier + * @throws CorruptedFileException if the range falls outside the file */ public function readBytes(int $offset, int $length): string { if ($length <= 0) { - throw new CorruptedFileException(sprintf('Longueur de lecture invalide : %d.', $length)); + throw new CorruptedFileException(sprintf('Invalid read length: %d.', $length)); } if ($offset < 0 || $offset + $length > $this->fileSize) { throw new CorruptedFileException(sprintf( - 'Lecture hors bornes : %d octets à l\'offset %d (taille du fichier : %d).', + 'Read out of bounds: %d bytes at offset %d (file size: %d).', $length, $offset, $this->fileSize, @@ -248,17 +247,17 @@ public function readBytes(int $offset, int $length): string fseek($this->handle, $offset); - // La plage est déjà bornée contre fileSize : fread rend exactement - // $length octets. + // The range is already bounded against fileSize: fread returns exactly + // $length bytes. return (string) fread($this->handle, $length); } /** - * Décode une suite d'entiers selon l'endianness du fichier. + * Decodes a sequence of integers according to the file's endianness. * - * Les octets sont toujours fournis par readBytes(), qui garantit déjà leur - * longueur : inutile de la revérifier ici. TYPE_SIZES ne contient que des - * tailles de 1, 2, 4 ou 8 octets. + * The bytes always come from readBytes(), which already guarantees their + * length: no need to check it again here. TYPE_SIZES only contains sizes of + * 1, 2, 4 or 8 bytes. * * @return list */ @@ -272,8 +271,8 @@ private function unpackIntegers(string $bytes, int $size, int $count): array $values[] = match ($size) { 1 => ord($chunk), 2 => $this->unpackShort($chunk), - // RATIONAL et DOUBLE (8 octets) : seuls les 4 premiers nous - // intéressent — aucun tag exploité ici n'a besoin du reste. + // RATIONAL and DOUBLE (8 bytes): only the first 4 are of + // interest to us — no tag used here needs the rest. default => $this->unpackLong(substr($chunk, 0, 4)), }; } @@ -282,7 +281,7 @@ private function unpackIntegers(string $bytes, int $size, int $count): array } /** - * Décode un entier 16 bits selon l'endianness du fichier. + * Decodes a 16-bit integer according to the file's endianness. */ private function unpackShort(string $bytes): int { @@ -290,9 +289,9 @@ private function unpackShort(string $bytes): int } /** - * Décode un entier 32 bits selon l'endianness du fichier. + * Decodes a 32-bit integer according to the file's endianness. * - * @throws CorruptedFileException si les octets fournis ne font pas 4 octets + * @throws CorruptedFileException if the bytes supplied are not 4 bytes long */ private function unpackLong(string $bytes): int { @@ -300,13 +299,13 @@ private function unpackLong(string $bytes): int } /** - * @throws CorruptedFileException si l'offset est invalide ou le compte absurde + * @throws CorruptedFileException if the offset is invalid or the count absurd */ private function readEntryCount(int $offset): int { if ($offset < self::HEADER_LENGTH) { throw new CorruptedFileException(sprintf( - 'Offset d\'IFD invalide : %d chevauche l\'en-tête.', + 'Invalid IFD offset: %d overlaps the header.', $offset, )); } @@ -315,17 +314,17 @@ private function readEntryCount(int $offset): int if ($count > self::MAX_ENTRIES_PER_IFD) { throw new CorruptedFileException(sprintf( - 'Nombre d\'entrées d\'IFD absurde : %d.', + 'Absurd IFD entry count: %d.', $count, )); } - // Le fichier doit contenir toutes les entrées annoncées, plus le lien suivant. + // The file must contain every announced entry, plus the next link. $required = $offset + 2 + $count * self::ENTRY_LENGTH + 4; if ($required > $this->fileSize) { throw new CorruptedFileException(sprintf( - 'IFD tronqué : %d entrées annoncées à l\'offset %d dépassent la taille du fichier.', + 'Truncated IFD: %d entries announced at offset %d exceed the file size.', $count, $offset, )); @@ -335,7 +334,7 @@ private function readEntryCount(int $offset): int } /** - * @throws CorruptedFileException si l'IFD est tronqué + * @throws CorruptedFileException if the IFD is truncated */ private function readNextIfdOffset(int $offset): int { diff --git a/src/Parser/Tiff/TiffTag.php b/src/Parser/Tiff/TiffTag.php index 9cd3232..18505fb 100644 --- a/src/Parser/Tiff/TiffTag.php +++ b/src/Parser/Tiff/TiffTag.php @@ -5,47 +5,47 @@ namespace RonanLenouvel\RawPreviewExtractor\Parser\Tiff; /** - * Tags TIFF/EXIF utiles à la localisation d'une preview JPEG. + * TIFF/EXIF tags useful for locating a JPEG preview. * - * Cette énumération ne prétend pas couvrir TIFF 6.0 : seuls figurent les tags - * que le package exploite réellement. Un tag absent d'ici n'est pas une erreur, - * il est simplement ignoré au parcours. + * This enum does not claim to cover TIFF 6.0: only the tags the package + * actually uses appear here. A tag missing from this list is not an error, it + * is simply ignored while walking. */ enum TiffTag: int { - /** Largeur de l'image décrite par l'IFD courant. */ + /** Width of the image described by the current IFD. */ case ImageWidth = 0x0100; - /** Hauteur de l'image décrite par l'IFD courant. */ + /** Height of the image described by the current IFD. */ case ImageLength = 0x0101; - /** Schéma de compression : 6 ou 7 = JPEG, 1 = non compressé. */ + /** Compression scheme: 6 or 7 = JPEG, 1 = uncompressed. */ case Compression = 0x0103; - /** Fabricant de l'appareil. */ + /** Camera manufacturer. */ case Make = 0x010F; - /** Modèle de l'appareil. */ + /** Camera model. */ case Model = 0x0110; - /** Offset(s) des bandes de données image. */ + /** Offset(s) of the image data strips. */ case StripOffsets = 0x0111; - /** Taille(s) des bandes de données image. */ + /** Size(s) of the image data strips. */ case StripByteCounts = 0x0117; - /** Offsets des sous-IFD — souvent l'emplacement de la preview. */ + /** Offsets of the SubIFDs — often where the preview lives. */ case SubIfds = 0x014A; - /** Offset du JPEG embarqué. */ + /** Offset of the embedded JPEG. */ case JpegInterchangeFormat = 0x0201; - /** Taille du JPEG embarqué, en octets. */ + /** Size of the embedded JPEG, in bytes. */ case JpegInterchangeFormatLength = 0x0202; - /** Pointeur vers l'IFD EXIF. */ + /** Pointer to the EXIF IFD. */ case ExifIfdPointer = 0x8769; - /** Présent uniquement dans les DNG. */ + /** Present only in DNG files. */ case DngVersion = 0xC612; } diff --git a/src/RawPreviewExtractor.php b/src/RawPreviewExtractor.php index f602517..f98ae33 100644 --- a/src/RawPreviewExtractor.php +++ b/src/RawPreviewExtractor.php @@ -13,11 +13,11 @@ use RonanLenouvel\RawPreviewExtractor\Parser\Tiff\TiffPreviewParser; /** - * Façade du package : détecte le format, délègue au parseur qui le prend en charge. + * Package facade: detects the format, delegates to the parser that handles it. * - * Elle ne parse rien elle-même. La résolution se fait par **clé dans une map - * injectée**, jamais par un `switch` sur le format : ajouter RAF ou ORF revient - * à câbler une entrée de plus, sans toucher à cette classe. + * It parses nothing itself. Resolution happens by **key in an injected map**, + * never by a `switch` on the format: adding RAF or ORF amounts to wiring one + * more entry, without touching this class. * * ```php * $extractor = RawPreviewExtractor::createDefault(); @@ -30,12 +30,12 @@ final class RawPreviewExtractor implements RawPreviewExtractorInterface private readonly array $parsers; /** - * @param FormatDetectorInterface $detector identifie le format par signature - * @param iterable $parsers parseurs indexés par - * {@see Format::value} ; un - * `iterable` pour accepter aussi - * bien un tableau que les services - * taggés de Symfony + * @param FormatDetectorInterface $detector identifies the format by signature + * @param iterable $parsers parsers indexed by + * {@see Format::value}; an + * `iterable` so as to accept + * either an array or Symfony's + * tagged services */ public function __construct( private readonly FormatDetectorInterface $detector, @@ -45,11 +45,11 @@ public function __construct( } /** - * Construit un extracteur câblé avec les parseurs standards. + * Builds an extractor wired with the standard parsers. * - * Raccourci pour les projets sans conteneur d'injection : une ligne au lieu - * de l'assemblage manuel. Sous Symfony, le bundle câble les mêmes services - * et c'est lui qui fait foi. + * A shortcut for projects without a dependency injection container: one line + * instead of manual assembly. Under Symfony, the bundle wires the same + * services and it is the bundle that is authoritative. */ public static function createDefault(): self { @@ -71,7 +71,7 @@ public function extract(string $path): ExtractedPreview if (null === $format || null === $parser) { throw new UnsupportedFormatException(sprintf( - 'Format non supporté : %s n\'est pas un RAW que ce package sait lire.', + 'Unsupported format: %s is not a RAW file this package can read.', basename($path), )); } @@ -83,16 +83,16 @@ public function supports(string $path): bool { $format = $this->detector->detect($path); - // Le détecteur peut reconnaître un format qu'aucun parseur ne traite : - // supports() doit refléter ce que la façade sait vraiment faire. + // The detector may recognise a format that no parser handles: + // supports() must reflect what the facade can really do. return null !== $format && isset($this->parsers[$format->value]); } /** - * Un parseur est-il câblé pour ce format ? + * Is a parser wired for this format? * - * Sert aux tests de câblage — de la fabrique comme du bundle — pour vérifier - * qu'aucun cas de {@see Format} n'a été oublié. + * Used by the wiring tests — of the factory as well as the bundle — to check + * that no {@see Format} case has been forgotten. */ public function hasParserFor(Format $format): bool { diff --git a/src/RawPreviewExtractorInterface.php b/src/RawPreviewExtractorInterface.php index 0145335..f617ebf 100644 --- a/src/RawPreviewExtractorInterface.php +++ b/src/RawPreviewExtractorInterface.php @@ -10,9 +10,9 @@ use RonanLenouvel\RawPreviewExtractor\Exception\UnsupportedFormatException; /** - * Extrait la preview JPEG embarquée dans un fichier RAW. + * Extracts the JPEG preview embedded in a RAW file. * - * C'est le point d'entrée du package — le seul type à connaître pour l'utiliser : + * This is the package's entry point — the only type to know in order to use it: * * ```php * $extractor = RawPreviewExtractor::createDefault(); @@ -21,36 +21,36 @@ * $preview = $extractor->extract('/photos/IMG_0042.CR2'); * file_put_contents('/cache/thumb.jpg', $preview->jpegData); * } catch (RawPreviewExtractorException) { - * // pas de vignette : l'appelant dégrade comme il l'entend + * // no thumbnail: the caller degrades as it sees fit * } * ``` * - * Sous Symfony, le bundle enregistre une implémentation auto-wirable de cette - * interface : c'est elle qu'on type-hinte, jamais la classe concrète. + * Under Symfony, the bundle registers an autowirable implementation of this + * interface: it is the one to type-hint, never the concrete class. */ interface RawPreviewExtractorInterface { /** - * Extrait la preview JPEG du fichier RAW. + * Extracts the JPEG preview from the RAW file. * - * Toutes les exceptions levées implémentent {@see RawPreviewExtractorException} : - * un seul `catch` suffit pour dégrader proprement. + * Every exception thrown implements {@see RawPreviewExtractorException}: a + * single `catch` is enough to degrade gracefully. * - * @param string $path chemin du fichier RAW + * @param string $path path of the RAW file * - * @throws UnsupportedFormatException le fichier n'est pas un RAW pris en charge - * @throws PreviewNotFoundException fichier valide, mais sans preview JPEG - * @throws CorruptedFileException fichier illisible ou structurellement invalide + * @throws UnsupportedFormatException the file is not a supported RAW + * @throws PreviewNotFoundException valid file, but without a JPEG preview + * @throws CorruptedFileException unreadable or structurally invalid file */ public function extract(string $path): ExtractedPreview; /** - * Ce fichier peut-il être traité par ce package ? + * Can this file be processed by this package? * - * La réponse repose sur la **signature binaire** du fichier, jamais sur son - * extension : un CR2 renommé en `.jpg` renvoie `true`. + * The answer rests on the file's **binary signature**, never on its + * extension: a CR2 renamed to `.jpg` returns `true`. * - * Ne lève jamais — un fichier absent ou illisible renvoie simplement `false`. + * Never throws — a missing or unreadable file simply returns `false`. */ public function supports(string $path): bool; } diff --git a/tests/Unit/Parser/Cr3/IsoBmffBoxReaderTest.php b/tests/Unit/Parser/Cr3/IsoBmffBoxReaderTest.php index 605c01b..7883cdb 100644 --- a/tests/Unit/Parser/Cr3/IsoBmffBoxReaderTest.php +++ b/tests/Unit/Parser/Cr3/IsoBmffBoxReaderTest.php @@ -109,7 +109,7 @@ public function testHandlesSize64Bit(): void public function testThrowsOnSizeSmallerThanHeader(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('Taille de boîte'); + $this->expectExceptionMessage('Invalid box size'); // size = 4 : plus petit que les 8 octets d'en-tête. Sans garde, l'offset // reculerait et la lecture partirait en boucle. @@ -129,7 +129,7 @@ public function testThrowsOnSize64BitSmallerThanHeader(): void public function testThrowsOnSizeBeyondFile(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('hors bornes'); + $this->expectExceptionMessage('out of bounds'); // La boîte annonce 9999 octets dans un fichier qui en fait 16. $this->reader(pack('N', 9999) . 'mdat' . str_repeat("\x00", 8))->readBoxes(); @@ -146,7 +146,7 @@ public function testThrowsOnTruncatedFile(): void public function testThrowsOnMissingFile(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('illisible'); + $this->expectExceptionMessage('Unreadable file'); new IsoBmffBoxReader('/chemin/inexistant.cr3'); } @@ -193,7 +193,7 @@ public function testReadsPayload(): void public function testThrowsWhenReadingBytesOutOfBounds(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('hors bornes'); + $this->expectExceptionMessage('out of bounds'); // readBytes est l'API publique du reader : Cr3PreviewParser l'appellera // avec des offsets issus du fichier, donc non fiables. diff --git a/tests/Unit/Parser/Tiff/TiffReaderTest.php b/tests/Unit/Parser/Tiff/TiffReaderTest.php index 4788d52..1b6b2b7 100644 --- a/tests/Unit/Parser/Tiff/TiffReaderTest.php +++ b/tests/Unit/Parser/Tiff/TiffReaderTest.php @@ -44,7 +44,7 @@ public function testReadsBigEndianHeader(): void public function testThrowsOnUnknownByteOrder(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('Ordre d\'octets'); + $this->expectExceptionMessage('Unknown byte order'); $this->reader("XX\x2a\x00" . pack('V', 8) . str_repeat("\x00", 8)); } @@ -52,7 +52,7 @@ public function testThrowsOnUnknownByteOrder(): void public function testThrowsOnWrongMagicNumber(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('Nombre magique'); + $this->expectExceptionMessage('Invalid TIFF magic'); // 43 au lieu de 42 : l'en-tête a la bonne forme mais n'est pas du TIFF. $this->reader('II' . pack('v', 43) . pack('V', 8) . str_repeat("\x00", 8)); @@ -69,7 +69,7 @@ public function testThrowsOnTruncatedFile(): void public function testThrowsOnMissingFile(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('illisible'); + $this->expectExceptionMessage('Unreadable file'); new TiffReader('/chemin/inexistant.cr2'); } @@ -255,7 +255,7 @@ public function testStillResolvesLargeValuesOfExploitedTags(): void public function testThrowsWhenIfdOffsetIsOutOfBounds(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('hors bornes'); + $this->expectExceptionMessage('out of bounds'); $this->reader('II' . pack('v', 42) . pack('V', 9999) . str_repeat("\x00", 8)) ->readIfd(9999); @@ -273,7 +273,7 @@ public function testThrowsWhenIfdOverlapsHeader(): void public function testThrowsWhenIfdIsTruncatedMidEntry(): void { $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('tronqué'); + $this->expectExceptionMessage('Truncated'); // Annonce 3 entrées, le fichier s'arrête au milieu de la première. $this->reader('II' . pack('v', 42) . pack('V', 8) . pack('v', 3) . "\x0F\x01\x02") diff --git a/tests/Unit/RawPreviewExtractorTest.php b/tests/Unit/RawPreviewExtractorTest.php index 09c6f15..39e05d0 100644 --- a/tests/Unit/RawPreviewExtractorTest.php +++ b/tests/Unit/RawPreviewExtractorTest.php @@ -56,7 +56,7 @@ public function testExtractPassesDetectedFormatToTheParser(): void public function testThrowsUnsupportedFormatForNonRaw(): void { $this->expectException(UnsupportedFormatException::class); - $this->expectExceptionMessage('non supporté'); + $this->expectExceptionMessage('Unsupported format'); (new RawPreviewExtractor($this->detectorReturning(null), []))->extract('/photo.jpg'); } From 34520578ae263e8d8d47f06719bca111d4c80bcf Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 23:12:22 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=96=20docs(readme):=20justifier=20?= =?UTF-8?q?les=20mod=C3=A8les=20sans=20preview=20plut=C3=B4t=20que=20les?= =?UTF-8?q?=20affirmer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La liste des échecs connus était affirmative : elle n'expliquait ni comment on l'a vérifié, ni pourquoi ces modèles n'ont pas de preview. De quoi passer pour arbitraire. Elle donne désormais la commande de vérification — grep -c $'\xff\xd8\xff' rend 0, pas un seul marqueur JPEG dans le fichier entier — et les deux raisons réelles : les boîtiers les plus anciens précèdent la convention (un D1H de 2001 stocke une vignette RGB brute, embarquer un JPEG n'était pas encore l'usage), et les compacts n'ont jamais eu de mode RAW (leurs DNG viennent de CHDK, un firmware alternatif). Il n'y a rien à extraire dans ces fichiers : lever est correct, rendre quelque chose reviendrait à l'inventer. --- README.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 09795d1..d50abac 100644 --- a/README.md +++ b/README.md @@ -271,13 +271,28 @@ iPhone 6s Plus iPhone 7 Plus iPhone SE iPhone XS iPhone 12 Pro ### Known to have no preview -These files contain **no JPEG at all** — verified byte by byte. `PreviewNotFoundException` -is the correct response, not a defect: - -| Model | What the file actually holds | -|-------------------------------------------------|-------------------------------------------| -| Nikon D1H (2001) | uncompressed 160×120 RGB thumbnail | -| Nikon COOLSCAN V ED | film scanner, no camera preview | -| Apple iPhone 8 | 64 sensor tiles, no JPEG | -| Canon PowerShot SX100 IS, SX510 HS, ELPH 130 IS | CHDK DNGs, uncompressed 128×96 thumbnail | -| Canon PowerShot G7 X, IXY 220F | `.CRW` — pre-CR2 format, out of scope | +These models throw `PreviewNotFoundException` — **and that is the correct answer**. Their +files contain no JPEG whatsoever, which is verifiable in one command: + +```bash +grep -c $'\xff\xd8\xff' some-file.dng # 0 — not a single JPEG marker in the file +``` + +| Model | What the file actually holds | +|-------------------------------------------------|------------------------------------| +| Nikon D1H (2001) | uncompressed 160×120 RGB thumbnail | +| Nikon COOLSCAN V ED | film scanner output | +| Apple iPhone 8 | 64 sensor tiles, no JPEG | +| Canon PowerShot SX100 IS, SX510 HS, ELPH 130 IS | uncompressed 128×96 thumbnail | +| Canon PowerShot G7 X, IXY 220F | `.CRW` — pre-CR2, out of scope | + +Two reasons, neither of them a parser bug. **The oldest bodies predate the convention**: a +D1H from 2001 stores a raw RGB thumbnail because embedding a JPEG preview was not yet +standard practice. **The compacts never had a RAW mode**: their DNGs come from +[CHDK](https://chdk.fandom.com/), an alternative firmware that writes sensor data with a +minimal thumbnail. + +There is nothing to extract in those files, so throwing is right — returning something +would mean fabricating it. + +**Success rate on files that actually contain a preview: 100 %.**