From 391962a2591c03fc1f907f805fe1c8f3b98509df Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 24 May 2026 16:11:45 +0200 Subject: [PATCH 1/4] refactor: Modernize horde/Image to PER-1, PSR-4, typed interfaces and DI-friendly design New modern API under src/ (Horde\Image namespace, PSR-4 autoloaded) coexists with legacy lib/ code. PHP 8.2+ strict types throughout. What changed: - ImageDriver/ImageResource/SequenceDriver interface hierarchy (ISP) - ImagickDriver, GdDriver, ImDriver (CLI), SvgDriver, PngDriver, NullDriver backends - Immutable Color value object with named(), lighten(), darken(), intensify(), brightness(), toGray() utilities - NamedColors lookup (140+ CSS color names) - DrawingContext with path-based API and state stack - SvgDrawingContext producing SVG DOM output - Brush utility with BrushShape enum (Square, Circle, Diamond, Triangle) - FontSize enum with bidirectional lookup (Tiny/Small/Medium/Large/Giant) - TextStyle accepts FontSize or raw float - Filter classes: Brightness, Contrast, Gamma, Grayscale, Colorize, Modulate, Sepia, Negate, Sharpen, Pixelate, Blur - Effect classes: TextWatermark, PolaroidImage, PhotoStack, Border, DropShadow, RoundCorners, SmartCrop, CenterCrop, LiquidResize, Composite - ImageSequence/Frame/AnimationOptions for multi-frame images - Metadata system: BundledReader, PhpExifReader, ImagickReader, ExiftoolReader with GPS parsing and MakerNote support - ImageFactory DI wrapper - Pure PHP PNG writer (PngDriver) as zero-dependency fallback - Full PHPStan level 8 compliance Not yet ported from legacy: - SWF/Flash backend (Flash EOL 2020) --- .horde.yml | 10 +- composer.json | 8 +- doc/examples/example-basic-operations.php | 67 +++ doc/examples/example-brush-fonts.php | 71 +++ doc/examples/example-drawing.php | 112 ++++ doc/examples/example-filters.php | 86 +++ doc/examples/example-png-pure.php | 56 ++ doc/examples/example-svg.php | 66 +++ doc/examples/example-value-objects.php | 54 ++ phpunit.xml.dist | 13 +- src/Color/Color.php | 210 ++++++++ src/Color/ColorModel.php | 13 + src/Color/NamedColors.php | 197 +++++++ src/Drawing/BlendMode.php | 21 + src/Drawing/Brush.php | 84 +++ src/Drawing/BrushShape.php | 13 + src/Drawing/DrawingContext.php | 58 ++ src/Drawing/FontSize.php | 80 +++ src/Drawing/GraphicsState.php | 24 + src/Drawing/LineCap.php | 12 + src/Drawing/LineDashPattern.php | 21 + src/Drawing/ShapeStyle.php | 12 + src/Drawing/TextStyle.php | 19 + src/Driver/GdDriver.php | 131 +++++ src/Driver/GdResource.php | 169 ++++++ src/Driver/ImDriver.php | 246 +++++++++ src/Driver/ImResource.php | 197 +++++++ src/Driver/ImageDriver.php | 24 + src/Driver/ImageResource.php | 31 ++ src/Driver/ImagickDrawingContext.php | 451 ++++++++++++++++ src/Driver/ImagickDriver.php | 354 +++++++++++++ src/Driver/ImagickResource.php | 189 +++++++ src/Driver/NullDriver.php | 63 +++ src/Driver/NullResource.php | 69 +++ src/Driver/PngDriver.php | 86 +++ src/Driver/PngResource.php | 243 +++++++++ src/Driver/SequenceDriver.php | 29 + src/Driver/StateEntry.php | 26 + src/Driver/SvgDrawingContext.php | 263 +++++++++ src/Driver/SvgDriver.php | 110 ++++ src/Driver/SvgResource.php | 186 +++++++ src/DriverException.php | 7 + src/Effect/Border.php | 65 +++ src/Effect/CenterCrop.php | 30 ++ src/Effect/Composite.php | 47 ++ src/Effect/DropShadow.php | 58 ++ src/Effect/Effect.php | 19 + src/Effect/LiquidResize.php | 31 ++ src/Effect/PhotoStack.php | 174 ++++++ src/Effect/PhotoStackStyle.php | 12 + src/Effect/PolaroidImage.php | 70 +++ src/Effect/RoundCorners.php | 72 +++ src/Effect/SmartCrop.php | 83 +++ src/Effect/TextWatermark.php | 137 +++++ src/Effect/WatermarkPosition.php | 19 + src/Filter/Blur.php | 13 + src/Filter/Brightness.php | 15 + src/Filter/Colorize.php | 15 + src/Filter/Contrast.php | 15 + src/Filter/Filter.php | 7 + src/Filter/Gamma.php | 15 + src/Filter/Grayscale.php | 7 + src/Filter/Modulate.php | 19 + src/Filter/Negate.php | 7 + src/Filter/Pixelate.php | 12 + src/Filter/Sepia.php | 12 + src/Filter/Sharpen.php | 15 + src/Format/DecodeOptions.php | 14 + src/Format/EncodeOptions.php | 14 + src/Format/ImageFormat.php | 39 ++ src/FormatException.php | 7 + src/Geometry/AffineTransform.php | 68 +++ src/Geometry/Point.php | 18 + src/Geometry/Rectangle.php | 31 ++ src/Geometry/Size.php | 39 ++ src/ImageException.php | 9 + src/ImageFactory.php | 84 +++ src/Metadata/FieldType.php | 14 + src/Metadata/GpsCoordinate.php | 26 + src/Metadata/ImageMetadata.php | 111 ++++ src/Metadata/MetadataCategory.php | 13 + src/Metadata/MetadataField.php | 142 +++++ src/Metadata/MetadataFormatter.php | 360 +++++++++++++ src/Metadata/MetadataReader.php | 17 + src/Metadata/Parser/CanonParser.php | 99 ++++ src/Metadata/Parser/FujifilmParser.php | 112 ++++ src/Metadata/Parser/GpsParser.php | 122 +++++ src/Metadata/Parser/MakerNoteParser.php | 15 + src/Metadata/Parser/NikonParser.php | 125 +++++ src/Metadata/Parser/OlympusParser.php | 102 ++++ src/Metadata/Parser/PanasonicParser.php | 114 ++++ src/Metadata/Parser/SanyoParser.php | 95 ++++ src/Metadata/Reader/BundledReader.php | 499 ++++++++++++++++++ src/Metadata/Reader/ExiftoolReader.php | 168 ++++++ src/Metadata/Reader/ImagickReader.php | 200 +++++++ src/Metadata/Reader/PhpExifReader.php | 156 ++++++ src/Sequence/AnimationOptions.php | 14 + src/Sequence/DisposalMethod.php | 12 + src/Sequence/Frame.php | 38 ++ src/Sequence/ImageSequence.php | 127 +++++ test/Horde/Image/RgbTest.php | 2 +- test/im.php | 6 +- test/unit/Color/ColorModelTest.php | 27 + test/unit/Color/ColorTest.php | 129 +++++ test/unit/Color/ColorUtilitiesTest.php | 240 +++++++++ test/unit/Color/NamedColorsTest.php | 107 ++++ test/unit/Drawing/BrushTest.php | 183 +++++++ test/unit/Drawing/DrawingTypesTest.php | 132 +++++ test/unit/Drawing/FontSizeTest.php | 93 ++++ test/unit/Driver/GdDriverTest.php | 267 ++++++++++ test/unit/Driver/ImDriverTest.php | 307 +++++++++++ .../unit/Driver/ImagickDrawingContextTest.php | 282 ++++++++++ test/unit/Driver/ImagickDriverTest.php | 305 +++++++++++ test/unit/Driver/ImagickSequenceTest.php | 208 ++++++++ test/unit/Driver/NullDriverTest.php | 196 +++++++ test/unit/Driver/PngDriverTest.php | 228 ++++++++ test/unit/Driver/SvgDriverTest.php | 196 +++++++ test/unit/Effect/CustomEffectTest.php | 101 ++++ test/unit/Effect/EffectApplyTest.php | 242 +++++++++ test/unit/Effect/EffectConfigTest.php | 134 +++++ test/unit/Effect/PhotoStackTest.php | 204 +++++++ test/unit/Effect/PolaroidImageTest.php | 129 +++++ test/unit/Effect/TextWatermarkTest.php | 201 +++++++ test/unit/Filter/FilterConfigTest.php | 152 ++++++ test/unit/Format/FormatTest.php | 84 +++ test/unit/Geometry/AffineTransformTest.php | 130 +++++ test/unit/Geometry/PointTest.php | 41 ++ test/unit/Geometry/RectangleTest.php | 59 +++ test/unit/Geometry/SizeTest.php | 77 +++ test/unit/Metadata/GpsCoordinateTest.php | 68 +++ test/unit/Metadata/ImageMetadataTest.php | 214 ++++++++ test/unit/Metadata/MetadataFieldTest.php | 121 +++++ test/unit/Metadata/MetadataFormatterTest.php | 282 ++++++++++ test/unit/Metadata/Parser/GpsParserTest.php | 134 +++++ .../Metadata/Reader/BundledReaderTest.php | 106 ++++ .../Metadata/Reader/ImagickReaderTest.php | 69 +++ .../Metadata/Reader/PhpExifReaderTest.php | 81 +++ test/unit/Sequence/AnimationOptionsTest.php | 31 ++ test/unit/Sequence/FrameTest.php | 100 ++++ test/unit/Sequence/ImageSequenceTest.php | 197 +++++++ 140 files changed, 14008 insertions(+), 11 deletions(-) create mode 100644 doc/examples/example-basic-operations.php create mode 100644 doc/examples/example-brush-fonts.php create mode 100644 doc/examples/example-drawing.php create mode 100644 doc/examples/example-filters.php create mode 100644 doc/examples/example-png-pure.php create mode 100644 doc/examples/example-svg.php create mode 100644 doc/examples/example-value-objects.php create mode 100644 src/Color/Color.php create mode 100644 src/Color/ColorModel.php create mode 100644 src/Color/NamedColors.php create mode 100644 src/Drawing/BlendMode.php create mode 100644 src/Drawing/Brush.php create mode 100644 src/Drawing/BrushShape.php create mode 100644 src/Drawing/DrawingContext.php create mode 100644 src/Drawing/FontSize.php create mode 100644 src/Drawing/GraphicsState.php create mode 100644 src/Drawing/LineCap.php create mode 100644 src/Drawing/LineDashPattern.php create mode 100644 src/Drawing/ShapeStyle.php create mode 100644 src/Drawing/TextStyle.php create mode 100644 src/Driver/GdDriver.php create mode 100644 src/Driver/GdResource.php create mode 100644 src/Driver/ImDriver.php create mode 100644 src/Driver/ImResource.php create mode 100644 src/Driver/ImageDriver.php create mode 100644 src/Driver/ImageResource.php create mode 100644 src/Driver/ImagickDrawingContext.php create mode 100644 src/Driver/ImagickDriver.php create mode 100644 src/Driver/ImagickResource.php create mode 100644 src/Driver/NullDriver.php create mode 100644 src/Driver/NullResource.php create mode 100644 src/Driver/PngDriver.php create mode 100644 src/Driver/PngResource.php create mode 100644 src/Driver/SequenceDriver.php create mode 100644 src/Driver/StateEntry.php create mode 100644 src/Driver/SvgDrawingContext.php create mode 100644 src/Driver/SvgDriver.php create mode 100644 src/Driver/SvgResource.php create mode 100644 src/DriverException.php create mode 100644 src/Effect/Border.php create mode 100644 src/Effect/CenterCrop.php create mode 100644 src/Effect/Composite.php create mode 100644 src/Effect/DropShadow.php create mode 100644 src/Effect/Effect.php create mode 100644 src/Effect/LiquidResize.php create mode 100644 src/Effect/PhotoStack.php create mode 100644 src/Effect/PhotoStackStyle.php create mode 100644 src/Effect/PolaroidImage.php create mode 100644 src/Effect/RoundCorners.php create mode 100644 src/Effect/SmartCrop.php create mode 100644 src/Effect/TextWatermark.php create mode 100644 src/Effect/WatermarkPosition.php create mode 100644 src/Filter/Blur.php create mode 100644 src/Filter/Brightness.php create mode 100644 src/Filter/Colorize.php create mode 100644 src/Filter/Contrast.php create mode 100644 src/Filter/Filter.php create mode 100644 src/Filter/Gamma.php create mode 100644 src/Filter/Grayscale.php create mode 100644 src/Filter/Modulate.php create mode 100644 src/Filter/Negate.php create mode 100644 src/Filter/Pixelate.php create mode 100644 src/Filter/Sepia.php create mode 100644 src/Filter/Sharpen.php create mode 100644 src/Format/DecodeOptions.php create mode 100644 src/Format/EncodeOptions.php create mode 100644 src/Format/ImageFormat.php create mode 100644 src/FormatException.php create mode 100644 src/Geometry/AffineTransform.php create mode 100644 src/Geometry/Point.php create mode 100644 src/Geometry/Rectangle.php create mode 100644 src/Geometry/Size.php create mode 100644 src/ImageException.php create mode 100644 src/ImageFactory.php create mode 100644 src/Metadata/FieldType.php create mode 100644 src/Metadata/GpsCoordinate.php create mode 100644 src/Metadata/ImageMetadata.php create mode 100644 src/Metadata/MetadataCategory.php create mode 100644 src/Metadata/MetadataField.php create mode 100644 src/Metadata/MetadataFormatter.php create mode 100644 src/Metadata/MetadataReader.php create mode 100644 src/Metadata/Parser/CanonParser.php create mode 100644 src/Metadata/Parser/FujifilmParser.php create mode 100644 src/Metadata/Parser/GpsParser.php create mode 100644 src/Metadata/Parser/MakerNoteParser.php create mode 100644 src/Metadata/Parser/NikonParser.php create mode 100644 src/Metadata/Parser/OlympusParser.php create mode 100644 src/Metadata/Parser/PanasonicParser.php create mode 100644 src/Metadata/Parser/SanyoParser.php create mode 100644 src/Metadata/Reader/BundledReader.php create mode 100644 src/Metadata/Reader/ExiftoolReader.php create mode 100644 src/Metadata/Reader/ImagickReader.php create mode 100644 src/Metadata/Reader/PhpExifReader.php create mode 100644 src/Sequence/AnimationOptions.php create mode 100644 src/Sequence/DisposalMethod.php create mode 100644 src/Sequence/Frame.php create mode 100644 src/Sequence/ImageSequence.php create mode 100644 test/unit/Color/ColorModelTest.php create mode 100644 test/unit/Color/ColorTest.php create mode 100644 test/unit/Color/ColorUtilitiesTest.php create mode 100644 test/unit/Color/NamedColorsTest.php create mode 100644 test/unit/Drawing/BrushTest.php create mode 100644 test/unit/Drawing/DrawingTypesTest.php create mode 100644 test/unit/Drawing/FontSizeTest.php create mode 100644 test/unit/Driver/GdDriverTest.php create mode 100644 test/unit/Driver/ImDriverTest.php create mode 100644 test/unit/Driver/ImagickDrawingContextTest.php create mode 100644 test/unit/Driver/ImagickDriverTest.php create mode 100644 test/unit/Driver/ImagickSequenceTest.php create mode 100644 test/unit/Driver/NullDriverTest.php create mode 100644 test/unit/Driver/PngDriverTest.php create mode 100644 test/unit/Driver/SvgDriverTest.php create mode 100644 test/unit/Effect/CustomEffectTest.php create mode 100644 test/unit/Effect/EffectApplyTest.php create mode 100644 test/unit/Effect/EffectConfigTest.php create mode 100644 test/unit/Effect/PhotoStackTest.php create mode 100644 test/unit/Effect/PolaroidImageTest.php create mode 100644 test/unit/Effect/TextWatermarkTest.php create mode 100644 test/unit/Filter/FilterConfigTest.php create mode 100644 test/unit/Format/FormatTest.php create mode 100644 test/unit/Geometry/AffineTransformTest.php create mode 100644 test/unit/Geometry/PointTest.php create mode 100644 test/unit/Geometry/RectangleTest.php create mode 100644 test/unit/Geometry/SizeTest.php create mode 100644 test/unit/Metadata/GpsCoordinateTest.php create mode 100644 test/unit/Metadata/ImageMetadataTest.php create mode 100644 test/unit/Metadata/MetadataFieldTest.php create mode 100644 test/unit/Metadata/MetadataFormatterTest.php create mode 100644 test/unit/Metadata/Parser/GpsParserTest.php create mode 100644 test/unit/Metadata/Reader/BundledReaderTest.php create mode 100644 test/unit/Metadata/Reader/ImagickReaderTest.php create mode 100644 test/unit/Metadata/Reader/PhpExifReaderTest.php create mode 100644 test/unit/Sequence/AnimationOptionsTest.php create mode 100644 test/unit/Sequence/FrameTest.php create mode 100644 test/unit/Sequence/ImageSequenceTest.php diff --git a/.horde.yml b/.horde.yml index dad6b43..3b91bd6 100644 --- a/.horde.yml +++ b/.horde.yml @@ -31,7 +31,7 @@ license: uri: http://www.horde.org/licenses/lgpl21 dependencies: required: - php: ^7.4 || ^8 + php: ^8.2 composer: horde/exception: ^3 horde/stream: ^2 @@ -47,3 +47,11 @@ dependencies: zlib: '*' imagick: '*' vendor: horde +autoload: + psr-0: + Horde_Image: lib/ + psr-4: + Horde\Image\: src/ +autoload-dev: + psr-4: + Horde\Image\Test\: test/ diff --git a/composer.json b/composer.json index dd10ca7..ea9f8e7 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "time": "2026-05-19", "repositories": [], "require": { - "php": "^7.4 || ^8", + "php": "^8.2", "horde/exception": "^3 || dev-FRAMEWORK_6_0", "horde/stream": "^2 || dev-FRAMEWORK_6_0", "horde/support": "^3 || dev-FRAMEWORK_6_0", @@ -37,11 +37,15 @@ "autoload": { "psr-0": { "Horde_Image": "lib/" + }, + "psr-4": { + "Horde\\Image\\": "src/" } }, "autoload-dev": { "psr-4": { - "Horde\\Image\\Test\\": "test/" + "Horde\\Image\\Test\\": "test/", + "Horde\\Image\\Test\\Unit\\": "test/unit/" } }, "config": { diff --git a/doc/examples/example-basic-operations.php b/doc/examples/example-basic-operations.php new file mode 100644 index 0000000..18dc657 --- /dev/null +++ b/doc/examples/example-basic-operations.php @@ -0,0 +1,67 @@ +create(new Size(200.0, 150.0), Color::rgb(0.0, 0.5, 1.0)); +echo "Canvas: {$canvas->size()->width}x{$canvas->size()->height}\n"; + +// Load from fixture (if available) +$fixturePath = __DIR__ . '/../../test/Horde/Image/Fixtures/img_exif.jpg'; +if (!file_exists($fixturePath)) { + echo "SKIP: fixture not found\n"; + exit(0); +} + +$image = $factory->loadFile($fixturePath); +echo "Loaded: {$image->size()->width}x{$image->size()->height}\n"; + +// Resize +$resized = $image->resize(new Size(100.0, 100.0)); +echo "Resized: {$resized->size()->width}x{$resized->size()->height}\n"; + +// Crop +$cropped = $image->crop(Rectangle::fromCoordinates(0.0, 0.0, 50.0, 50.0)); +echo "Cropped: {$cropped->size()->width}x{$cropped->size()->height}\n"; + +// Flip +$flipped = $resized->flip(horizontal: true); +echo "Flipped: {$flipped->size()->width}x{$flipped->size()->height}\n"; + +// Encode as PNG +$pngData = $factory->encode($resized, ImageFormat::PNG); +echo "PNG: " . strlen($pngData) . " bytes\n"; + +// Encode as WebP with quality +if ($driver->supports(ImageFormat::WebP)) { + $webpData = $factory->encode($resized, ImageFormat::WebP, new EncodeOptions(quality: 80)); + echo "WebP: " . strlen($webpData) . " bytes\n"; +} + +// Format support check +echo "Supports PNG: " . ($driver->supports(ImageFormat::PNG) ? 'yes' : 'no') . "\n"; +echo "Supports SVG: " . ($driver->supports(ImageFormat::SVG) ? 'yes' : 'no') . "\n"; diff --git a/doc/examples/example-brush-fonts.php b/doc/examples/example-brush-fonts.php new file mode 100644 index 0000000..778bde6 --- /dev/null +++ b/doc/examples/example-brush-fonts.php @@ -0,0 +1,71 @@ +create(new Size(300.0, 200.0), Color::named('white')); +$ctx = $canvas->drawingContext(); + +// Draw brush markers in different shapes +$shapes = [ + [BrushShape::Square, 50.0], + [BrushShape::Circle, 100.0], + [BrushShape::Diamond, 150.0], + [BrushShape::Triangle, 200.0], +]; + +foreach ($shapes as [$shape, $x]) { + Brush::draw($ctx, new Point($x, 50.0), Color::named('red'), $shape, 8.0); + echo "Drew {$shape->value} brush at x={$x}\n"; +} + +// Named font sizes +echo "\nFont sizes:\n"; +foreach (FontSize::cases() as $size) { + echo " {$size->value}: {$size->points()}pt\n"; +} + +// Bidirectional lookup +$nearest = FontSize::fromPoints(20.0); +echo "\nNearest to 20pt: {$nearest->value} ({$nearest->points()}pt)\n"; + +$up = FontSize::nextUp(12.0); +echo "Next up from 12pt: " . ($up ? "{$up->value} ({$up->points()}pt)" : 'none') . "\n"; + +$down = FontSize::nextDown(18.0); +echo "Next down from 18pt: " . ($down ? "{$down->value} ({$down->points()}pt)" : 'none') . "\n"; + +// Use FontSize in TextStyle +$ctx->setFillColor(Color::named('black')); +$y = 100.0; +foreach (FontSize::cases() as $size) { + $ctx->text($size->value, 20.0, $y, new TextStyle(size: $size)); + $y += $size->points() + 4.0; +} + +$png = $factory->encode($canvas, ImageFormat::PNG); +echo "\nOutput: " . strlen($png) . " bytes PNG\n"; diff --git a/doc/examples/example-drawing.php b/doc/examples/example-drawing.php new file mode 100644 index 0000000..f650eab --- /dev/null +++ b/doc/examples/example-drawing.php @@ -0,0 +1,112 @@ +create(new Size(200.0, 200.0), Color::rgb(1.0, 1.0, 1.0)); +$ctx = $canvas->drawingContext(); + +// Filled path (red rectangle) +$ctx->setFillColor(Color::rgb(1.0, 0.0, 0.0)) + ->moveTo(10.0, 10.0) + ->lineTo(90.0, 10.0) + ->lineTo(90.0, 90.0) + ->lineTo(10.0, 90.0) + ->closePath() + ->fill(); + +// Stroked rectangle +$ctx->setStrokeColor(Color::rgb(0.0, 0.0, 1.0)) + ->setLineWidth(3.0) + ->rect(100.0, 100.0, 80.0, 80.0) + ->stroke(); + +// Circle +$ctx->setFillColor(Color::rgb(0.0, 1.0, 0.0)) + ->circle(50.0, 150.0, 20.0, ShapeStyle::Fill); + +// Ellipse with stroke and fill +$ctx->setFillColor(Color::hex('#ff9900')) + ->setStrokeColor(Color::rgb(0.0, 0.0, 0.0)) + ->setLineWidth(2.0) + ->ellipse(150.0, 50.0, 30.0, 20.0, ShapeStyle::StrokeAndFill); + +// Polygon (triangle) +$ctx->setFillColor(Color::rgb(0.5, 0.0, 0.5)) + ->polygon([ + new Point(120.0, 150.0), + new Point(180.0, 150.0), + new Point(150.0, 190.0), + ], ShapeStyle::Fill); + +// Polyline (open path) +$ctx->setStrokeColor(Color::rgb(0.0, 0.5, 0.5)) + ->setLineWidth(2.0) + ->polyline([ + new Point(10.0, 195.0), + new Point(50.0, 180.0), + new Point(90.0, 195.0), + ]); + +// Rounded rectangle +$ctx->setFillColor(Color::rgba(0.0, 0.0, 1.0, 0.5)) + ->roundedRect(5.0, 100.0, 60.0, 40.0, 8.0, ShapeStyle::Fill); + +// Text +$ctx->setFillColor(Color::rgb(0.0, 0.0, 0.0)) + ->text('Hello', 100.0, 30.0, new TextStyle(size: 16.0)); + +// Dashed line with round caps +$ctx->setStrokeColor(Color::rgb(0.5, 0.5, 0.5)) + ->setDashPattern(new LineDashPattern([5.0, 3.0])) + ->setLineCap(LineCap::Round) + ->moveTo(5.0, 5.0) + ->lineTo(195.0, 5.0) + ->stroke(); + +// Save/restore state +$ctx->save() + ->setFillColor(Color::rgb(1.0, 1.0, 0.0)) + ->setLineWidth(5.0) + ->restore(); + +// Transform +$ctx->save() + ->transform(AffineTransform::translate(100.0, 100.0)) + ->setFillColor(Color::rgb(1.0, 0.0, 1.0)) + ->circle(0.0, 0.0, 5.0, ShapeStyle::Fill) + ->restore(); + +// Arc +$ctx->setStrokeColor(Color::rgb(0.8, 0.2, 0.0)) + ->setLineWidth(2.0) + ->arc(150.0, 150.0, 15.0, 0.0, 270.0, ShapeStyle::Stroke); + +// Encode result +$png = $factory->encode($canvas, ImageFormat::PNG); +echo "Drawing canvas: " . strlen($png) . " bytes PNG\n"; diff --git a/doc/examples/example-filters.php b/doc/examples/example-filters.php new file mode 100644 index 0000000..950644e --- /dev/null +++ b/doc/examples/example-filters.php @@ -0,0 +1,86 @@ +loadFile($fixturePath); +$size = $image->size(); +echo "Original: {$size->width}x{$size->height}\n"; + +// Individual filters +$gray = $image->apply(new Grayscale()); +echo "Grayscale: {$gray->size()->width}x{$gray->size()->height}\n"; + +$sepia = $image->apply(new Sepia(threshold: 90.0)); +echo "Sepia: {$sepia->size()->width}x{$sepia->size()->height}\n"; + +$blurred = $image->apply(new Blur(sigma: 2.0)); +echo "Blur: {$blurred->size()->width}x{$blurred->size()->height}\n"; + +$sharp = $image->apply(new Sharpen(radius: 0.0, sigma: 1.0, amount: 1.5, threshold: 0.05)); +echo "Sharpen: {$sharp->size()->width}x{$sharp->size()->height}\n"; + +$neg = $image->apply(new Negate()); +echo "Negate: {$neg->size()->width}x{$neg->size()->height}\n"; + +$pix = $image->apply(new Pixelate(size: 8)); +echo "Pixelate: {$pix->size()->width}x{$pix->size()->height}\n"; + +$bright = $image->apply(new Brightness(level: 20.0)); +echo "Brightness: {$bright->size()->width}x{$bright->size()->height}\n"; + +$contr = $image->apply(new Contrast(level: 30.0)); +echo "Contrast: {$contr->size()->width}x{$contr->size()->height}\n"; + +$gamma = $image->apply(new Gamma(gamma: 1.5)); +echo "Gamma: {$gamma->size()->width}x{$gamma->size()->height}\n"; + +$colorized = $image->apply(new Colorize(color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.3)); +echo "Colorize: {$colorized->size()->width}x{$colorized->size()->height}\n"; + +$modulated = $image->apply(new Modulate(brightness: 110.0, saturation: 80.0)); +echo "Modulate: {$modulated->size()->width}x{$modulated->size()->height}\n"; + +// Chaining multiple filters +$chained = $image + ->apply(new Grayscale()) + ->apply(new Blur(sigma: 1.5)) + ->apply(new Sharpen(amount: 2.0)); +$chainedPng = $factory->encode($chained, ImageFormat::PNG); +echo "Chained (grayscale+blur+sharpen): " . strlen($chainedPng) . " bytes\n"; diff --git a/doc/examples/example-png-pure.php b/doc/examples/example-png-pure.php new file mode 100644 index 0000000..77bbc11 --- /dev/null +++ b/doc/examples/example-png-pure.php @@ -0,0 +1,56 @@ +create(new Size(20.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); +assert($image instanceof PngResource); + +echo "Size: {$image->size()->width}x{$image->size()->height}\n"; + +// Set individual pixels (a blue diagonal line) +for ($i = 0; $i < 20; $i++) { + $image->setPixel($i, $i, 0, 0, 255); +} + +// Read a pixel back +$pixel = $image->getPixel(10, 10); +echo "Pixel at (10,10): rgb({$pixel[0]}, {$pixel[1]}, {$pixel[2]})\n"; + +// Encode to PNG binary +$pngData = $driver->encode($image, ImageFormat::PNG); +echo "PNG magic bytes: " . (str_starts_with($pngData, "\x89PNG") ? 'valid' : 'invalid') . "\n"; +echo "PNG size: " . strlen($pngData) . " bytes\n"; + +// Resize +$resized = $image->resize(new Size(10.0, 10.0)); +echo "Resized: {$resized->size()->width}x{$resized->size()->height}\n"; + +// Rotate 90 degrees +$rotated = $image->rotate(90.0, Color::rgb(0.0, 0.0, 0.0)); +echo "Rotated 90: {$rotated->size()->width}x{$rotated->size()->height}\n"; + +// Flip +$flipped = $image->flip(horizontal: true); +assert($flipped instanceof PngResource); +echo "Flipped pixel at (9,0): rgb(" . implode(', ', $flipped->getPixel(9, 0)) . ")\n"; + +// Format support +echo "Supports PNG: " . ($driver->supports(ImageFormat::PNG) ? 'yes' : 'no') . "\n"; +echo "Supports JPEG: " . ($driver->supports(ImageFormat::JPEG) ? 'yes' : 'no') . "\n"; diff --git a/doc/examples/example-svg.php b/doc/examples/example-svg.php new file mode 100644 index 0000000..8844399 --- /dev/null +++ b/doc/examples/example-svg.php @@ -0,0 +1,66 @@ +create(new Size(200.0, 200.0), Color::named('white')); +echo "Canvas: {$canvas->size()->width}x{$canvas->size()->height}\n"; + +// Draw shapes via DrawingContext +$ctx = $canvas->drawingContext(); + +// Blue filled triangle +$ctx->setFillColor(Color::named('dodgerblue')); +$ctx->moveTo(100.0, 10.0); +$ctx->lineTo(190.0, 190.0); +$ctx->lineTo(10.0, 190.0); +$ctx->closePath(); +$ctx->fill(); + +// Red circle +$ctx->setFillColor(Color::named('red')); +$ctx->setStrokeColor(Color::named('darkred')); +$ctx->setLineWidth(2.0); +$ctx->circle(100.0, 100.0, 30.0); + +// Text label +$ctx->setFillColor(Color::named('black')); +$ctx->text('SVG Demo', 60.0, 195.0, new TextStyle(size: 14.0)); + +// Line +$ctx->setStrokeColor(Color::named('green')); +$ctx->setLineWidth(1.5); +$ctx->line(10.0, 10.0, 190.0, 10.0); + +// Polygon +$ctx->setFillColor(Color::named('gold')); +$ctx->polygon([ + new Point(150.0, 50.0), + new Point(170.0, 80.0), + new Point(130.0, 80.0), +]); + +// Encode to SVG XML +$svg = $driver->encode($canvas, ImageFormat::SVG); +echo "SVG output: " . strlen($svg) . " bytes\n"; +echo "Contains : " . (str_contains($svg, ': " . (str_contains($svg, ': " . (str_contains($svg, ': " . (str_contains($svg, 'colorModel()->value . "\n"; +echo "Hex: " . $red->toHex() . "\n"; + +// From hex string +$blue = Color::hex('#3366cc'); +echo "Blue red component: " . round($blue->red(), 2) . "\n"; + +// RGBA with alpha +$semi = Color::rgba(1.0, 1.0, 1.0, 0.5); +echo "Alpha: " . $semi->alpha() . "\n"; + +// Named CSS colors +$coral = Color::named('coral'); +echo "Coral: " . $coral->toHex() . "\n"; + +// Size with aspect-ratio fitting +$size = new Size(800.0, 600.0); +$fitted = $size->fitWithin(new Size(400.0, 400.0)); +echo "Fitted: {$fitted->width}x{$fitted->height}\n"; + +// Point translation +$point = new Point(10.0, 20.0); +$moved = $point->translate(5.0, -3.0); +echo "Moved: {$moved->x}, {$moved->y}\n"; + +// Rectangle from coordinates +$rect = Rectangle::fromCoordinates(10.0, 20.0, 110.0, 80.0); +echo "Rect size: {$rect->size->width}x{$rect->size->height}\n"; + +// Affine transform composition +$rotate = AffineTransform::rotate(90.0); +$translate = AffineTransform::translate(100.0, 0.0); +$combined = $rotate->multiply($translate); +echo "Transform e: {$combined->e}, f: {$combined->f}\n"; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 45faed8..d6e6ad0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,22 +1,25 @@ - + src lib - - test + + test/unit + + + test/Horde - \ No newline at end of file + diff --git a/src/Color/Color.php b/src/Color/Color.php new file mode 100644 index 0000000..da6343f --- /dev/null +++ b/src/Color/Color.php @@ -0,0 +1,210 @@ +model; + } + + public function red(): float + { + return $this->c1; + } + + public function green(): float + { + return $this->c2; + } + + public function blue(): float + { + return $this->c3; + } + + public function alpha(): float + { + return $this->c4; + } + + public function cyan(): float + { + return $this->c1; + } + + public function magenta(): float + { + return $this->c2; + } + + public function yellow(): float + { + return $this->c3; + } + + public function key(): float + { + return $this->c4; + } + + public function luminance(): float + { + return $this->c1; + } + + public function toHex(): string + { + return sprintf( + '#%02x%02x%02x', + (int) round($this->c1 * 255), + (int) round($this->c2 * 255), + (int) round($this->c3 * 255), + ); + } + + public function withAlpha(float $alpha): self + { + return new self(ColorModel::Rgba, $this->c1, $this->c2, $this->c3, $alpha); + } + + public function brightness(): float + { + return $this->c1 * 0.299 + $this->c2 * 0.587 + $this->c3 * 0.114; + } + + public function toGray(): self + { + return self::gray($this->brightness()); + } + + public function lighten(float $amount): self + { + return new self( + $this->model, + min($this->c1 + $amount, 1.0), + min($this->c2 + $amount, 1.0), + min($this->c3 + $amount, 1.0), + $this->c4, + ); + } + + public function darken(float $amount): self + { + return new self( + $this->model, + max($this->c1 - $amount, 0.0), + max($this->c2 - $amount, 0.0), + max($this->c3 - $amount, 0.0), + $this->c4, + ); + } + + public function intensify(float $amount): self + { + $r = $this->c1; + $g = $this->c2; + $b = $this->c3; + + if ($r >= $g && $r >= $b) { + if ($r === 0.0) { + return $this; + } + $gRatio = $g / $r; + $bRatio = $b / $r; + $r = min($r + $amount, 1.0); + $g = min($gRatio * $r, 1.0); + $b = min($bRatio * $r, 1.0); + } elseif ($g >= $r && $g >= $b) { + if ($g === 0.0) { + return $this; + } + $rRatio = $r / $g; + $bRatio = $b / $g; + $g = min($g + $amount, 1.0); + $r = min($rRatio * $g, 1.0); + $b = min($bRatio * $g, 1.0); + } else { + if ($b === 0.0) { + return $this; + } + $rRatio = $r / $b; + $gRatio = $g / $b; + $b = min($b + $amount, 1.0); + $r = min($rRatio * $b, 1.0); + $g = min($gRatio * $b, 1.0); + } + + return new self($this->model, $r, $g, $b, $this->c4); + } +} diff --git a/src/Color/ColorModel.php b/src/Color/ColorModel.php new file mode 100644 index 0000000..b03d8af --- /dev/null +++ b/src/Color/ColorModel.php @@ -0,0 +1,13 @@ +|null + */ + private static ?array $colors = null; + + public static function lookup(string $name): ?Color + { + $colors = self::all(); + $key = strtolower(trim($name)); + + if (!isset($colors[$key])) { + return null; + } + + [$r, $g, $b] = $colors[$key]; + + return Color::rgb($r / 255, $g / 255, $b / 255); + } + + public static function has(string $name): bool + { + return isset(self::all()[strtolower(trim($name))]); + } + + /** + * @return array + */ + public static function all(): array + { + if (self::$colors === null) { + self::$colors = self::buildTable(); + } + + return self::$colors; + } + + /** + * @return array + */ + private static function buildTable(): array + { + return [ + 'aliceblue' => [240, 248, 255], + 'antiquewhite' => [250, 235, 215], + 'aqua' => [0, 255, 255], + 'aquamarine' => [127, 255, 212], + 'azure' => [240, 255, 255], + 'beige' => [245, 245, 220], + 'bisque' => [255, 228, 196], + 'black' => [0, 0, 0], + 'blanchedalmond' => [255, 235, 205], + 'blue' => [0, 0, 255], + 'blueviolet' => [138, 43, 226], + 'brown' => [165, 42, 42], + 'burlywood' => [222, 184, 135], + 'cadetblue' => [95, 158, 160], + 'chartreuse' => [127, 255, 0], + 'chocolate' => [210, 105, 30], + 'coral' => [255, 127, 80], + 'cornflowerblue' => [100, 149, 237], + 'cornsilk' => [255, 248, 220], + 'crimson' => [220, 20, 60], + 'cyan' => [0, 255, 255], + 'darkblue' => [0, 0, 139], + 'darkcyan' => [0, 139, 139], + 'darkgoldenrod' => [184, 134, 11], + 'darkgray' => [169, 169, 169], + 'darkgreen' => [0, 100, 0], + 'darkkhaki' => [189, 183, 107], + 'darkmagenta' => [139, 0, 139], + 'darkolivegreen' => [85, 107, 47], + 'darkorange' => [255, 140, 0], + 'darkorchid' => [153, 50, 204], + 'darkred' => [139, 0, 0], + 'darksalmon' => [233, 150, 122], + 'darkseagreen' => [143, 188, 143], + 'darkslateblue' => [72, 61, 139], + 'darkslategray' => [47, 79, 79], + 'darkturquoise' => [0, 206, 209], + 'darkviolet' => [148, 0, 211], + 'deeppink' => [255, 20, 147], + 'deepskyblue' => [0, 191, 255], + 'dimgray' => [105, 105, 105], + 'dodgerblue' => [30, 144, 255], + 'firebrick' => [178, 34, 34], + 'floralwhite' => [255, 250, 240], + 'forestgreen' => [34, 139, 34], + 'fuchsia' => [255, 0, 255], + 'gainsboro' => [220, 220, 220], + 'ghostwhite' => [248, 248, 255], + 'gold' => [255, 215, 0], + 'goldenrod' => [218, 165, 32], + 'gray' => [128, 128, 128], + 'green' => [0, 128, 0], + 'greenyellow' => [173, 255, 47], + 'honeydew' => [240, 255, 240], + 'hotpink' => [255, 105, 180], + 'indianred' => [205, 92, 92], + 'indigo' => [75, 0, 130], + 'ivory' => [255, 255, 240], + 'khaki' => [240, 230, 140], + 'lavender' => [230, 230, 250], + 'lavenderblush' => [255, 240, 245], + 'lawngreen' => [124, 252, 0], + 'lemonchiffon' => [255, 250, 205], + 'lightblue' => [173, 216, 230], + 'lightcoral' => [240, 128, 128], + 'lightcyan' => [224, 255, 255], + 'lightgoldenrodyellow' => [250, 250, 210], + 'lightgray' => [211, 211, 211], + 'lightgreen' => [144, 238, 144], + 'lightpink' => [255, 182, 193], + 'lightsalmon' => [255, 160, 122], + 'lightseagreen' => [32, 178, 170], + 'lightskyblue' => [135, 206, 250], + 'lightslategray' => [119, 136, 153], + 'lightsteelblue' => [176, 196, 222], + 'lightyellow' => [255, 255, 224], + 'lime' => [0, 255, 0], + 'limegreen' => [50, 205, 50], + 'linen' => [250, 240, 230], + 'magenta' => [255, 0, 255], + 'maroon' => [128, 0, 0], + 'mediumaquamarine' => [102, 205, 170], + 'mediumblue' => [0, 0, 205], + 'mediumorchid' => [186, 85, 211], + 'mediumpurple' => [147, 112, 219], + 'mediumseagreen' => [60, 179, 113], + 'mediumslateblue' => [123, 104, 238], + 'mediumspringgreen' => [0, 250, 154], + 'mediumturquoise' => [72, 209, 204], + 'mediumvioletred' => [199, 21, 133], + 'midnightblue' => [25, 25, 112], + 'mintcream' => [245, 255, 250], + 'mistyrose' => [255, 228, 225], + 'moccasin' => [255, 228, 181], + 'navajowhite' => [255, 222, 173], + 'navy' => [0, 0, 128], + 'oldlace' => [253, 245, 230], + 'olive' => [128, 128, 0], + 'olivedrab' => [107, 142, 35], + 'orange' => [255, 165, 0], + 'orangered' => [255, 69, 0], + 'orchid' => [218, 112, 214], + 'palegoldenrod' => [238, 232, 170], + 'palegreen' => [152, 251, 152], + 'paleturquoise' => [175, 238, 238], + 'palevioletred' => [219, 112, 147], + 'papayawhip' => [255, 239, 213], + 'peachpuff' => [255, 218, 185], + 'peru' => [205, 133, 63], + 'pink' => [255, 192, 203], + 'plum' => [221, 160, 221], + 'powderblue' => [176, 224, 230], + 'purple' => [128, 0, 128], + 'rebeccapurple' => [102, 51, 153], + 'red' => [255, 0, 0], + 'rosybrown' => [188, 143, 143], + 'royalblue' => [65, 105, 225], + 'saddlebrown' => [139, 69, 19], + 'salmon' => [250, 128, 114], + 'sandybrown' => [244, 164, 96], + 'seagreen' => [46, 139, 87], + 'seashell' => [255, 245, 238], + 'sienna' => [160, 82, 45], + 'silver' => [192, 192, 192], + 'skyblue' => [135, 206, 235], + 'slateblue' => [106, 90, 205], + 'slategray' => [112, 128, 144], + 'snow' => [255, 250, 250], + 'springgreen' => [0, 255, 127], + 'steelblue' => [70, 130, 180], + 'tan' => [210, 180, 140], + 'teal' => [0, 128, 128], + 'thistle' => [216, 191, 216], + 'tomato' => [255, 99, 71], + 'turquoise' => [64, 224, 208], + 'violet' => [238, 130, 238], + 'wheat' => [245, 222, 179], + 'white' => [255, 255, 255], + 'whitesmoke' => [245, 245, 245], + 'yellow' => [255, 255, 0], + 'yellowgreen' => [154, 205, 50], + ]; + } +} diff --git a/src/Drawing/BlendMode.php b/src/Drawing/BlendMode.php new file mode 100644 index 0000000..8a91b32 --- /dev/null +++ b/src/Drawing/BlendMode.php @@ -0,0 +1,21 @@ +x; + $y = $position->y; + $half = $size / 2.0; + + $ctx->save(); + $ctx->setFillColor($color); + $ctx->setStrokeColor($color); + $ctx->setLineWidth(1.0); + + match ($shape) { + BrushShape::Square => self::drawSquare($ctx, $x, $y, $half), + BrushShape::Circle => self::drawCircle($ctx, $x, $y, $half), + BrushShape::Diamond => self::drawDiamond($ctx, $x, $y, $half), + BrushShape::Triangle => self::drawTriangle($ctx, $x, $y, $half), + }; + + $ctx->restore(); + } + + private static function drawSquare(DrawingContext $ctx, float $x, float $y, float $half): void + { + $ctx->rect($x - $half, $y - $half, $half * 2, $half * 2); + $ctx->fill(); + } + + private static function drawCircle(DrawingContext $ctx, float $x, float $y, float $radius): void + { + $steps = 16; + for ($i = 0; $i <= $steps; $i++) { + $angle = 2.0 * M_PI * $i / $steps; + $px = $x + $radius * cos($angle); + $py = $y + $radius * sin($angle); + if ($i === 0) { + $ctx->moveTo($px, $py); + } else { + $ctx->lineTo($px, $py); + } + } + $ctx->closePath(); + $ctx->fill(); + } + + private static function drawDiamond(DrawingContext $ctx, float $x, float $y, float $half): void + { + $ctx->moveTo($x, $y - $half); + $ctx->lineTo($x + $half, $y); + $ctx->lineTo($x, $y + $half); + $ctx->lineTo($x - $half, $y); + $ctx->closePath(); + $ctx->fill(); + } + + private static function drawTriangle(DrawingContext $ctx, float $x, float $y, float $half): void + { + $ctx->moveTo($x, $y - $half); + $ctx->lineTo($x + $half, $y + $half); + $ctx->lineTo($x - $half, $y + $half); + $ctx->closePath(); + $ctx->fill(); + } +} diff --git a/src/Drawing/BrushShape.php b/src/Drawing/BrushShape.php new file mode 100644 index 0000000..8282e98 --- /dev/null +++ b/src/Drawing/BrushShape.php @@ -0,0 +1,13 @@ + 8.0, + self::Small => 12.0, + self::Medium => 18.0, + self::Large => 24.0, + self::Giant => 30.0, + }; + } + + /** + * Return the named size whose point value is nearest to the given value. + */ + public static function fromPoints(float $points): self + { + $best = self::Small; + $bestDiff = PHP_FLOAT_MAX; + + foreach (self::cases() as $case) { + $diff = abs($case->points() - $points); + if ($diff < $bestDiff) { + $bestDiff = $diff; + $best = $case; + } + } + + return $best; + } + + /** + * Return the smallest named size whose point value is strictly greater than the given value. + */ + public static function nextUp(float $points): ?self + { + $best = null; + + foreach (self::cases() as $case) { + if ($case->points() > $points) { + if ($best === null || $case->points() < $best->points()) { + $best = $case; + } + } + } + + return $best; + } + + /** + * Return the largest named size whose point value is strictly less than the given value. + */ + public static function nextDown(float $points): ?self + { + $best = null; + + foreach (self::cases() as $case) { + if ($case->points() < $points) { + if ($best === null || $case->points() > $best->points()) { + $best = $case; + } + } + } + + return $best; + } +} diff --git a/src/Drawing/GraphicsState.php b/src/Drawing/GraphicsState.php new file mode 100644 index 0000000..4ddb4c8 --- /dev/null +++ b/src/Drawing/GraphicsState.php @@ -0,0 +1,24 @@ + $dashArray + */ + public function __construct( + public readonly array $dashArray, + public readonly float $dashPhase = 0.0, + ) {} + + public static function solid(): self + { + return new self([], 0.0); + } +} diff --git a/src/Drawing/ShapeStyle.php b/src/Drawing/ShapeStyle.php new file mode 100644 index 0000000..27ff9b7 --- /dev/null +++ b/src/Drawing/ShapeStyle.php @@ -0,0 +1,12 @@ +size = $size instanceof FontSize ? $size->points() : $size; + } +} diff --git a/src/Driver/GdDriver.php b/src/Driver/GdDriver.php new file mode 100644 index 0000000..33378b7 --- /dev/null +++ b/src/Driver/GdDriver.php @@ -0,0 +1,131 @@ +initGd($gd); + + return new GdResource($gd, $this); + } + + public function loadFile(string $path, DecodeOptions $options = new DecodeOptions()): ImageResource + { + if (!is_readable($path)) { + throw new DriverException('File not readable: ' . $path); + } + + $data = file_get_contents($path); + if ($data === false) { + throw new DriverException('Failed to read file: ' . $path); + } + + return $this->load($data, $options); + } + + public function create(Size $size, Color $background): ImageResource + { + $w = max(1, (int) $size->width); + $h = max(1, (int) $size->height); + + $gd = imagecreatetruecolor($w, $h); + if ($gd === false) { + throw new DriverException('Failed to create GD image'); + } + + $this->initGd($gd); + + $color = self::allocateColor($gd, $background); + imagefilledrectangle($gd, 0, 0, $w - 1, $h - 1, $color); + + return new GdResource($gd, $this); + } + + public function encode(ImageResource $image, ImageFormat $format, EncodeOptions $options = new EncodeOptions()): string + { + if (!$image instanceof GdResource) { + throw new DriverException('GdDriver can only encode GdResource instances'); + } + + if (!$this->supports($format)) { + throw new FormatException('Format not supported: ' . $format->value); + } + + ob_start(); + $gd = $image->gd(); + + $success = match ($format) { + ImageFormat::PNG => imagepng($gd, null, -1), + ImageFormat::JPEG => imagejpeg($gd, null, $options->quality ?? 85), + ImageFormat::GIF => imagegif($gd), + ImageFormat::WebP => imagewebp($gd, null, $options->quality ?? 80), + ImageFormat::BMP => imagebmp($gd), + ImageFormat::AVIF => function_exists('imageavif') && imageavif($gd, null, $options->quality ?? 80), + default => false, + }; + + $data = ob_get_clean(); + + if (!$success || $data === false || $data === '') { + throw new DriverException('Failed to encode image as ' . $format->value); + } + + return $data; + } + + public function supports(ImageFormat $format): bool + { + return match ($format) { + ImageFormat::PNG, ImageFormat::JPEG, ImageFormat::GIF, ImageFormat::BMP => true, + ImageFormat::WebP => function_exists('imagewebp'), + ImageFormat::AVIF => function_exists('imageavif'), + default => false, + }; + } + + public static function allocateColor(GdImage $gd, Color $color): int + { + $r = min(255, max(0, (int) round($color->red() * 255))); + $g = min(255, max(0, (int) round($color->green() * 255))); + $b = min(255, max(0, (int) round($color->blue() * 255))); + $a = min(127, max(0, 127 - (int) round($color->alpha() * 127))); + + $result = imagecolorallocatealpha($gd, $r, $g, $b, $a); + if ($result === false) { + return 0; + } + + return $result; + } + + private function initGd(GdImage $gd): void + { + imagesavealpha($gd, true); + imagealphablending($gd, true); + } +} diff --git a/src/Driver/GdResource.php b/src/Driver/GdResource.php new file mode 100644 index 0000000..c35d19b --- /dev/null +++ b/src/Driver/GdResource.php @@ -0,0 +1,169 @@ +gd); + $h = imagesy($this->gd); + $copy = imagecreatetruecolor($w, $h); + if ($copy === false) { + throw new DriverException('Failed to clone GD image'); + } + imagesavealpha($copy, true); + imagealphablending($copy, false); + imagecopy($copy, $this->gd, 0, 0, 0, 0, $w, $h); + imagealphablending($copy, true); + $this->gd = $copy; + } + + public function gd(): GdImage + { + return $this->gd; + } + + public function size(): Size + { + return new Size((float) imagesx($this->gd), (float) imagesy($this->gd)); + } + + public function resize(Size $size): static + { + $clone = clone $this; + $newW = max(1, (int) $size->width); + $newH = max(1, (int) $size->height); + + $resized = imagecreatetruecolor($newW, $newH); + if ($resized === false) { + throw new DriverException('Failed to create resized image'); + } + + imagesavealpha($resized, true); + imagealphablending($resized, false); + imagecopyresampled($resized, $clone->gd, 0, 0, 0, 0, $newW, $newH, imagesx($clone->gd), imagesy($clone->gd)); + imagealphablending($resized, true); + + $clone->gd = $resized; + + return $clone; + } + + public function crop(Rectangle $region): static + { + $clone = clone $this; + $x = (int) $region->origin->x; + $y = (int) $region->origin->y; + $w = max(1, (int) $region->size->width); + $h = max(1, (int) $region->size->height); + + $cropped = imagecrop($clone->gd, ['x' => $x, 'y' => $y, 'width' => $w, 'height' => $h]); + if ($cropped === false) { + throw new DriverException('Failed to crop image'); + } + + $clone->gd = $cropped; + + return $clone; + } + + public function rotate(float $angleDeg, Color $background): static + { + $clone = clone $this; + $bgColor = GdDriver::allocateColor($clone->gd, $background); + + $rotated = imagerotate($clone->gd, -$angleDeg, $bgColor); + if ($rotated === false) { + throw new DriverException('Failed to rotate image'); + } + + imagesavealpha($rotated, true); + $clone->gd = $rotated; + + return $clone; + } + + public function flip(bool $horizontal = false, bool $vertical = false): static + { + $clone = clone $this; + + if ($horizontal && $vertical) { + imageflip($clone->gd, IMG_FLIP_BOTH); + } elseif ($horizontal) { + imageflip($clone->gd, IMG_FLIP_HORIZONTAL); + } elseif ($vertical) { + imageflip($clone->gd, IMG_FLIP_VERTICAL); + } + + return $clone; + } + + public function apply(Filter $filter): static + { + $clone = clone $this; + $clone->applyFilter($filter); + + return $clone; + } + + public function effect(Effect $effect): static + { + /** @var static */ + return $effect->apply($this); + } + + public function drawingContext(): DrawingContext + { + throw new DriverException('DrawingContext is not yet supported by GdResource'); + } + + private function applyFilter(Filter $filter): void + { + match (true) { + $filter instanceof Grayscale => imagefilter($this->gd, IMG_FILTER_GRAYSCALE), + $filter instanceof Negate => imagefilter($this->gd, IMG_FILTER_NEGATE), + $filter instanceof Brightness => imagefilter( + $this->gd, + IMG_FILTER_BRIGHTNESS, + (int) round($filter->level * 2.55), + ), + $filter instanceof Contrast => imagefilter( + $this->gd, + IMG_FILTER_CONTRAST, + (int) round(-$filter->level), + ), + $filter instanceof Gamma => imagegammacorrect($this->gd, 1.0, $filter->gamma), + $filter instanceof Sepia => $this->applySepiaFilter($filter->threshold), + default => throw new DriverException('Filter not supported by GdDriver: ' . $filter::class), + }; + } + + private function applySepiaFilter(float $threshold): void + { + imagefilter($this->gd, IMG_FILTER_GRAYSCALE); + imagefilter($this->gd, IMG_FILTER_COLORIZE, (int) round($threshold), (int) round($threshold * 0.54), (int) round(-$threshold * 0.29)); + } +} diff --git a/src/Driver/ImDriver.php b/src/Driver/ImDriver.php new file mode 100644 index 0000000..1ce4736 --- /dev/null +++ b/src/Driver/ImDriver.php @@ -0,0 +1,246 @@ +convertBin = $convertBin ?? $this->findBinary('convert'); + $this->identifyBin = $identifyBin ?? $this->findBinary('identify'); + } + + public function load(string $data, DecodeOptions $options = new DecodeOptions()): ImageResource + { + $tmpIn = $this->tempFile('im_in_'); + file_put_contents($tmpIn, $data); + + $size = $this->identify($tmpIn); + + return new ImResource($this, $tmpIn, $size, true); + } + + public function loadFile(string $path, DecodeOptions $options = new DecodeOptions()): ImageResource + { + if (!is_readable($path)) { + throw new DriverException('File not readable: ' . $path); + } + + $tmpIn = $this->tempFile('im_in_'); + copy($path, $tmpIn); + + $size = $this->identify($tmpIn); + + return new ImResource($this, $tmpIn, $size, true); + } + + public function create(Size $size, Color $background): ImageResource + { + $w = (int) $size->width; + $h = (int) $size->height; + $color = $this->colorToImString($background); + + $tmpOut = $this->tempFile('im_create_'); + $cmd = sprintf( + '%s -size %dx%d xc:%s png:%s', + escapeshellarg($this->convertBin), + $w, + $h, + escapeshellarg($color), + escapeshellarg($tmpOut), + ); + + $this->exec($cmd); + + return new ImResource($this, $tmpOut, $size, true); + } + + public function encode(ImageResource $image, ImageFormat $format, EncodeOptions $options = new EncodeOptions()): string + { + if (!$image instanceof ImResource) { + throw new DriverException('ImDriver can only encode ImResource instances'); + } + + if (!$this->supports($format)) { + throw new FormatException('Format not supported: ' . $format->value); + } + + $image->flush(); + + $tmpOut = $this->tempFile('im_enc_') . '.' . $format->fileExtension(); + $ops = []; + + if ($options->quality !== null && in_array($format, [ImageFormat::JPEG, ImageFormat::WebP], true)) { + $ops[] = '-quality ' . $options->quality; + } + + if ($options->stripMetadata) { + $ops[] = '-strip'; + } + + if ($options->progressive && $format === ImageFormat::JPEG) { + $ops[] = '-interlace Plane'; + } + + $cmd = sprintf( + '%s %s %s %s:%s', + escapeshellarg($this->convertBin), + escapeshellarg($image->filePath()), + implode(' ', $ops), + escapeshellarg($format->value), + escapeshellarg($tmpOut), + ); + + $this->exec($cmd); + + $data = file_get_contents($tmpOut); + @unlink($tmpOut); + + if ($data === false) { + throw new DriverException('Failed to read encoded output'); + } + + return $data; + } + + public function supports(ImageFormat $format): bool + { + return in_array($format, [ + ImageFormat::PNG, + ImageFormat::JPEG, + ImageFormat::GIF, + ImageFormat::WebP, + ImageFormat::TIFF, + ImageFormat::BMP, + ], true); + } + + /** + * @internal + */ + public function convert(string $inputPath, string $outputPath, string $operations): void + { + $cmd = sprintf( + '%s %s %s %s', + escapeshellarg($this->convertBin), + escapeshellarg($inputPath), + $operations, + escapeshellarg($outputPath), + ); + + $this->exec($cmd); + } + + /** + * @internal + */ + public function identify(string $path): Size + { + $cmd = sprintf( + '%s -format "%%w %%h" %s', + escapeshellarg($this->identifyBin), + escapeshellarg($path . '[0]'), + ); + + $output = $this->exec($cmd); + $parts = explode(' ', trim($output)); + + if (count($parts) < 2) { + throw new DriverException('Failed to identify image dimensions'); + } + + return new Size((float) $parts[0], (float) $parts[1]); + } + + /** + * @internal + */ + public function colorToImString(Color $color): string + { + $r = (int) round($color->red() * 255); + $g = (int) round($color->green() * 255); + $b = (int) round($color->blue() * 255); + $a = $color->alpha(); + + if ($a < 0.01 && $color->red() === 0.0 && $color->green() === 0.0 && $color->blue() === 0.0) { + return 'none'; + } + + if ($a > 0.0) { + return sprintf('rgba(%d,%d,%d,%.4f)', $r, $g, $b, $a); + } + + return sprintf('rgb(%d,%d,%d)', $r, $g, $b); + } + + /** + * @internal + */ + public function tempFile(string $prefix = 'im_'): string + { + $dir = $this->tmpDir !== '' ? $this->tmpDir : sys_get_temp_dir(); + $path = tempnam($dir, $prefix); + + if ($path === false) { + throw new DriverException('Failed to create temporary file'); + } + + return $path; + } + + private function exec(string $cmd): string + { + $output = []; + $exitCode = 0; + exec($cmd . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + throw new DriverException( + sprintf('ImageMagick command failed (exit %d): %s', $exitCode, implode("\n", $output)), + ); + } + + return implode("\n", $output); + } + + private function findBinary(string $name): string + { + $output = []; + $exitCode = 0; + exec('which ' . escapeshellarg($name) . ' 2>/dev/null', $output, $exitCode); + + if ($exitCode === 0 && isset($output[0]) && is_executable($output[0])) { + return $output[0]; + } + + $commonPaths = [ + '/usr/bin/' . $name, + '/usr/local/bin/' . $name, + '/opt/homebrew/bin/' . $name, + ]; + + foreach ($commonPaths as $path) { + if (is_executable($path)) { + return $path; + } + } + + throw new DriverException(sprintf('ImageMagick binary "%s" not found', $name)); + } +} diff --git a/src/Driver/ImResource.php b/src/Driver/ImResource.php new file mode 100644 index 0000000..0161b50 --- /dev/null +++ b/src/Driver/ImResource.php @@ -0,0 +1,197 @@ + */ + private array $operations = []; + private bool $dirty = false; + private bool $ownsFile; + + public function __construct( + private readonly ImDriver $driver, + private string $filePath, + private Size $size, + bool $ownsFile = false, + ) { + $this->ownsFile = $ownsFile; + } + + public function __destruct() + { + if ($this->ownsFile && file_exists($this->filePath)) { + @unlink($this->filePath); + } + } + + public function __clone() + { + $newPath = $this->driver->tempFile('im_clone_'); + copy($this->filePath, $newPath); + $this->filePath = $newPath; + $this->ownsFile = true; + $this->operations = []; + $this->dirty = false; + } + + public function filePath(): string + { + return $this->filePath; + } + + public function size(): Size + { + if ($this->dirty) { + $this->flush(); + } + + return $this->size; + } + + public function resize(Size $size): static + { + $clone = clone $this; + $clone->flush(); + + $w = (int) $size->width; + $h = (int) $size->height; + $clone->operations[] = sprintf('-resize %dx%d!', $w, $h); + $clone->size = $size; + $clone->dirty = true; + + return $clone; + } + + public function crop(Rectangle $region): static + { + $clone = clone $this; + $clone->flush(); + + $w = (int) $region->size->width; + $h = (int) $region->size->height; + $x = (int) $region->origin->x; + $y = (int) $region->origin->y; + $clone->operations[] = sprintf('-crop %dx%d+%d+%d +repage', $w, $h, $x, $y); + $clone->size = $region->size; + $clone->dirty = true; + + return $clone; + } + + public function rotate(float $angleDeg, Color $background): static + { + $clone = clone $this; + $clone->flush(); + + $bgStr = $this->driver->colorToImString($background); + $clone->operations[] = sprintf('-background %s -rotate %s', escapeshellarg($bgStr), $angleDeg); + $clone->dirty = true; + + return $clone; + } + + public function flip(bool $horizontal = false, bool $vertical = false): static + { + $clone = clone $this; + $clone->flush(); + + if ($vertical) { + $clone->operations[] = '-flip'; + $clone->dirty = true; + } + if ($horizontal) { + $clone->operations[] = '-flop'; + $clone->dirty = true; + } + + return $clone; + } + + public function apply(Filter $filter): static + { + $clone = clone $this; + $clone->flush(); + $clone->applyFilter($filter); + + return $clone; + } + + public function effect(Effect $effect): static + { + /** @var static */ + return $effect->apply($this); + } + + public function drawingContext(): DrawingContext + { + throw new DriverException('DrawingContext is not supported by ImResource'); + } + + /** + * @internal Flush pending operations to disk. + */ + public function flush(): void + { + if ($this->operations === []) { + $this->dirty = false; + return; + } + + $ops = implode(' ', $this->operations); + $tmpOut = $this->driver->tempFile('im_out_'); + $this->driver->convert($this->filePath, $tmpOut, $ops); + + if ($this->ownsFile) { + @unlink($this->filePath); + } + + $this->filePath = $tmpOut; + $this->ownsFile = true; + $this->operations = []; + $this->dirty = false; + + $this->size = $this->driver->identify($this->filePath); + } + + private function applyFilter(Filter $filter): void + { + $op = match (true) { + $filter instanceof Grayscale => '-colorspace Gray', + $filter instanceof Negate => '-negate', + $filter instanceof Sepia => sprintf('-sepia-tone %s%%', $filter->threshold), + $filter instanceof Sharpen => sprintf('-sharpen 0x%s', $filter->sigma), + $filter instanceof Gamma => sprintf('-gamma %s', $filter->gamma), + $filter instanceof Brightness => sprintf('-brightness-contrast %sx0', $filter->level), + $filter instanceof Contrast => sprintf('-brightness-contrast 0x%s', $filter->level), + $filter instanceof Modulate => sprintf( + '-modulate %s,%s,%s', + $filter->brightness, + $filter->saturation, + $filter->hue, + ), + default => throw new DriverException('Filter not supported by ImDriver: ' . $filter::class), + }; + + $this->operations[] = $op; + $this->dirty = true; + } +} diff --git a/src/Driver/ImageDriver.php b/src/Driver/ImageDriver.php new file mode 100644 index 0000000..ed75643 --- /dev/null +++ b/src/Driver/ImageDriver.php @@ -0,0 +1,24 @@ + */ + private array $stateStack = []; + + private Color $fillColor; + private Color $strokeColor; + private float $lineWidth = 1.0; + private LineCap $lineCap = LineCap::Butt; + private LineDashPattern $dashPattern; + private AffineTransform $transform; + + /** @var array */ + private array $path = []; + + private int $clipCounter = 0; + private ?string $activeClipPath = null; + + public function __construct( + private readonly Imagick $imagick, + ) { + $this->fillColor = Color::rgb(0.0, 0.0, 0.0); + $this->strokeColor = Color::rgb(0.0, 0.0, 0.0); + $this->dashPattern = LineDashPattern::solid(); + $this->transform = AffineTransform::identity(); + } + + public function save(): static + { + $this->stateStack[] = new StateEntry( + fillColor: $this->fillColor, + strokeColor: $this->strokeColor, + lineWidth: $this->lineWidth, + lineCap: $this->lineCap, + dashPattern: $this->dashPattern, + transform: $this->transform, + clipPath: $this->activeClipPath, + ); + return $this; + } + + public function restore(): static + { + if ($this->stateStack === []) { + throw new DriverException('Unbalanced restore: no matching save'); + } + + $entry = array_pop($this->stateStack); + $this->fillColor = $entry->fillColor; + $this->strokeColor = $entry->strokeColor; + $this->lineWidth = $entry->lineWidth; + $this->lineCap = $entry->lineCap; + $this->dashPattern = $entry->dashPattern; + $this->transform = $entry->transform; + $this->activeClipPath = $entry->clipPath; + + return $this; + } + + public function setFillColor(Color $color): static + { + $this->fillColor = $color; + return $this; + } + + public function setStrokeColor(Color $color): static + { + $this->strokeColor = $color; + return $this; + } + + public function setLineWidth(float $width): static + { + $this->lineWidth = $width; + return $this; + } + + public function setLineCap(LineCap $cap): static + { + $this->lineCap = $cap; + return $this; + } + + public function setDashPattern(LineDashPattern $pattern): static + { + $this->dashPattern = $pattern; + return $this; + } + + public function moveTo(float $x, float $y): static + { + $this->path[] = ['moveTo', [$x, $y]]; + return $this; + } + + public function lineTo(float $x, float $y): static + { + $this->path[] = ['lineTo', [$x, $y]]; + return $this; + } + + public function curveTo( + float $x1, + float $y1, + float $x2, + float $y2, + float $x3, + float $y3, + ): static { + $this->path[] = ['curveTo', [$x1, $y1, $x2, $y2, $x3, $y3]]; + return $this; + } + + public function closePath(): static + { + $this->path[] = ['closePath', []]; + return $this; + } + + public function rect(float $x, float $y, float $w, float $h): static + { + $this->path[] = ['moveTo', [$x, $y]]; + $this->path[] = ['lineTo', [$x + $w, $y]]; + $this->path[] = ['lineTo', [$x + $w, $y + $h]]; + $this->path[] = ['lineTo', [$x, $y + $h]]; + $this->path[] = ['closePath', []]; + return $this; + } + + public function stroke(): static + { + $this->paintPath(stroke: true, fill: false); + return $this; + } + + public function fill(): static + { + $this->paintPath(stroke: false, fill: true); + return $this; + } + + public function fillAndStroke(): static + { + $this->paintPath(stroke: true, fill: true); + return $this; + } + + public function clip(): static + { + $draw = new ImagickDraw(); + $clipId = 'clip_' . (++$this->clipCounter); + + $draw->pushClipPath($clipId); + $this->replayPath($draw); + $draw->popClipPath(); + + $this->activeClipPath = $clipId; + $this->path = []; + + return $this; + } + + public function transform(AffineTransform $transform): static + { + $this->transform = $this->transform->multiply($transform); + return $this; + } + + // --- Shape helpers (beyond DrawingContext interface) --- + + /** + * @param ShapeStyle $style How to paint the circle + */ + public function circle(float $cx, float $cy, float $radius, ShapeStyle $style = ShapeStyle::Fill): static + { + return $this->ellipse($cx, $cy, $radius, $radius, $style); + } + + /** + * @param ShapeStyle $style How to paint the ellipse + */ + public function ellipse(float $cx, float $cy, float $rx, float $ry, ShapeStyle $style = ShapeStyle::Fill): static + { + $draw = $this->createConfiguredDraw($style); + + [$tcx, $tcy] = $this->applyTransform($cx, $cy); + $draw->ellipse($tcx, $tcy, $rx, $ry, 0, 360); + + $this->imagick->drawImage($draw); + return $this; + } + + /** + * @param ShapeStyle $style How to paint the arc + */ + public function arc( + float $cx, + float $cy, + float $radius, + float $startDeg, + float $endDeg, + ShapeStyle $style = ShapeStyle::Stroke, + ): static { + $draw = $this->createConfiguredDraw($style); + + [$tcx, $tcy] = $this->applyTransform($cx, $cy); + $draw->arc( + $tcx - $radius, + $tcy - $radius, + $tcx + $radius, + $tcy + $radius, + $startDeg, + $endDeg, + ); + + $this->imagick->drawImage($draw); + return $this; + } + + /** + * @param Point[] $points + * @param ShapeStyle $style How to paint the polygon + */ + public function polygon(array $points, ShapeStyle $style = ShapeStyle::Fill): static + { + if (count($points) < 3) { + throw new DriverException('Polygon requires at least 3 points'); + } + + $draw = $this->createConfiguredDraw($style); + + $coords = []; + foreach ($points as $point) { + [$tx, $ty] = $this->applyTransform($point->x, $point->y); + $coords[] = ['x' => $tx, 'y' => $ty]; + } + + $draw->polygon($coords); + $this->imagick->drawImage($draw); + return $this; + } + + /** + * @param Point[] $points + */ + public function polyline(array $points): static + { + if (count($points) < 2) { + throw new DriverException('Polyline requires at least 2 points'); + } + + $draw = $this->createConfiguredDraw(ShapeStyle::Stroke); + + $coords = []; + foreach ($points as $point) { + [$tx, $ty] = $this->applyTransform($point->x, $point->y); + $coords[] = ['x' => $tx, 'y' => $ty]; + } + + $draw->polyline($coords); + $this->imagick->drawImage($draw); + return $this; + } + + /** + * @param ShapeStyle $style How to paint the rounded rectangle + */ + public function roundedRect( + float $x, + float $y, + float $w, + float $h, + float $radius, + ShapeStyle $style = ShapeStyle::Fill, + ): static { + $draw = $this->createConfiguredDraw($style); + + [$tx1, $ty1] = $this->applyTransform($x, $y); + [$tx2, $ty2] = $this->applyTransform($x + $w, $y + $h); + + $draw->roundRectangle($tx1, $ty1, $tx2, $ty2, $radius, $radius); + $this->imagick->drawImage($draw); + return $this; + } + + public function text(string $text, float $x, float $y, TextStyle $style = new TextStyle()): static + { + $draw = new ImagickDraw(); + + $draw->setFillColor(ImagickDriver::colorToPixel($this->fillColor)); + $draw->setFontSize($style->size); + + if ($style->fontFile !== null) { + $draw->setFont($style->fontFile); + } elseif ($style->fontFamily !== null) { + $draw->setFontFamily($style->fontFamily); + } + + [$tx, $ty] = $this->applyTransform($x, $y); + + if ($this->activeClipPath !== null) { + $draw->setClipPath($this->activeClipPath); + } + + $this->imagick->annotateImage($draw, $tx, $ty, $style->angle, $text); + return $this; + } + + // --- Private implementation --- + + private function paintPath(bool $stroke, bool $fill): void + { + if ($this->path === []) { + return; + } + + $draw = new ImagickDraw(); + + if ($fill) { + $draw->setFillColor(ImagickDriver::colorToPixel($this->fillColor)); + } else { + $draw->setFillColor(new ImagickPixel('none')); + } + + if ($stroke) { + $draw->setStrokeColor(ImagickDriver::colorToPixel($this->strokeColor)); + $draw->setStrokeWidth($this->lineWidth); + $this->applyLineCap($draw); + $this->applyDashPattern($draw); + } else { + $draw->setStrokeColor(new ImagickPixel('none')); + } + + if ($this->activeClipPath !== null) { + $draw->setClipPath($this->activeClipPath); + } + + $this->replayPath($draw); + $this->imagick->drawImage($draw); + $this->path = []; + } + + private function replayPath(ImagickDraw $draw): void + { + $draw->pathStart(); + + foreach ($this->path as [$op, $args]) { + match ($op) { + 'moveTo' => (function () use ($draw, $args) { + [$tx, $ty] = $this->applyTransform($args[0], $args[1]); + $draw->pathMoveToAbsolute($tx, $ty); + })(), + 'lineTo' => (function () use ($draw, $args) { + [$tx, $ty] = $this->applyTransform($args[0], $args[1]); + $draw->pathLineToAbsolute($tx, $ty); + })(), + 'curveTo' => (function () use ($draw, $args) { + [$tx1, $ty1] = $this->applyTransform($args[0], $args[1]); + [$tx2, $ty2] = $this->applyTransform($args[2], $args[3]); + [$tx3, $ty3] = $this->applyTransform($args[4], $args[5]); + $draw->pathCurveToAbsolute($tx1, $ty1, $tx2, $ty2, $tx3, $ty3); + })(), + 'closePath' => $draw->pathClose(), + default => null, + }; + } + + $draw->pathFinish(); + } + + private function createConfiguredDraw(ShapeStyle $style): ImagickDraw + { + $draw = new ImagickDraw(); + + match ($style) { + ShapeStyle::Fill => (function () use ($draw) { + $draw->setFillColor(ImagickDriver::colorToPixel($this->fillColor)); + $draw->setStrokeColor(new ImagickPixel('none')); + })(), + ShapeStyle::Stroke => (function () use ($draw) { + $draw->setFillColor(new ImagickPixel('none')); + $draw->setStrokeColor(ImagickDriver::colorToPixel($this->strokeColor)); + $draw->setStrokeWidth($this->lineWidth); + $this->applyLineCap($draw); + $this->applyDashPattern($draw); + })(), + ShapeStyle::StrokeAndFill => (function () use ($draw) { + $draw->setFillColor(ImagickDriver::colorToPixel($this->fillColor)); + $draw->setStrokeColor(ImagickDriver::colorToPixel($this->strokeColor)); + $draw->setStrokeWidth($this->lineWidth); + $this->applyLineCap($draw); + $this->applyDashPattern($draw); + })(), + }; + + if ($this->activeClipPath !== null) { + $draw->setClipPath($this->activeClipPath); + } + + return $draw; + } + + private function applyLineCap(ImagickDraw $draw): void + { + $draw->setStrokeLineCap(match ($this->lineCap) { + LineCap::Butt => Imagick::LINECAP_BUTT, + LineCap::Round => Imagick::LINECAP_ROUND, + LineCap::Square => Imagick::LINECAP_SQUARE, + }); + } + + private function applyDashPattern(ImagickDraw $draw): void + { + if ($this->dashPattern->dashArray !== []) { + $draw->setStrokeDashArray($this->dashPattern->dashArray); + $draw->setStrokeDashOffset($this->dashPattern->dashPhase); + } + } + + /** + * @return array{float, float} + */ + private function applyTransform(float $x, float $y): array + { + $t = $this->transform; + return [ + $t->a * $x + $t->c * $y + $t->e, + $t->b * $x + $t->d * $y + $t->f, + ]; + } +} diff --git a/src/Driver/ImagickDriver.php b/src/Driver/ImagickDriver.php new file mode 100644 index 0000000..675986b --- /dev/null +++ b/src/Driver/ImagickDriver.php @@ -0,0 +1,354 @@ +readImageBlob($data); + } catch (ImagickException $e) { + throw new DriverException('Failed to load image data: ' . $e->getMessage(), 0, $e); + } + + return $this->postProcess($imagick, $options); + } + + public function loadFile(string $path, DecodeOptions $options = new DecodeOptions()): ImageResource + { + if (!is_readable($path)) { + throw new DriverException('File not readable: ' . $path); + } + + $imagick = new Imagick(); + + try { + $imagick->readImage($path); + } catch (ImagickException $e) { + throw new DriverException('Failed to load file: ' . $e->getMessage(), 0, $e); + } + + return $this->postProcess($imagick, $options); + } + + public function create(Size $size, Color $background): ImageResource + { + $imagick = new Imagick(); + $pixel = self::colorToPixel($background); + + $imagick->newImage((int) $size->width, (int) $size->height, $pixel); + $imagick->setImageFormat('png'); + + return new ImagickResource($imagick, $this); + } + + public function encode(ImageResource $image, ImageFormat $format, EncodeOptions $options = new EncodeOptions()): string + { + if (!$image instanceof ImagickResource) { + throw new DriverException('ImagickDriver can only encode ImagickResource instances'); + } + + if (!$this->supports($format)) { + throw new FormatException('Format not supported: ' . $format->value); + } + + $imagick = clone $image->imagick(); + $imagick->setImageFormat($this->formatToImagick($format)); + + if ($options->quality !== null) { + $imagick->setImageCompressionQuality($options->quality); + } + + if ($options->progressive && in_array($format, [ImageFormat::JPEG, ImageFormat::PNG], true)) { + $imagick->setInterlaceScheme(Imagick::INTERLACE_PLANE); + } + + if ($options->stripMetadata) { + $imagick->stripImage(); + } + + return $imagick->getImageBlob(); + } + + public function supports(ImageFormat $format): bool + { + $map = $this->formatToImagick($format); + $supported = Imagick::queryFormats($map); + return $supported !== []; + } + + public function loadSequence(string $data, DecodeOptions $options = new DecodeOptions()): ImageSequence + { + $imagick = new Imagick(); + + try { + $imagick->readImageBlob($data); + } catch (ImagickException $e) { + throw new DriverException('Failed to load image data: ' . $e->getMessage(), 0, $e); + } + + return $this->buildSequence($imagick, $options); + } + + public function loadFileSequence(string $path, DecodeOptions $options = new DecodeOptions()): ImageSequence + { + if (!is_readable($path)) { + throw new DriverException('File not readable: ' . $path); + } + + $imagick = new Imagick(); + + try { + $imagick->readImage($path); + } catch (ImagickException $e) { + throw new DriverException('Failed to load file: ' . $e->getMessage(), 0, $e); + } + + return $this->buildSequence($imagick, $options); + } + + public function encodeSequence( + ImageSequence $sequence, + ImageFormat $format, + EncodeOptions $options = new EncodeOptions(), + AnimationOptions $animation = new AnimationOptions(), + ): string { + if (!$this->supports($format)) { + throw new FormatException('Format not supported: ' . $format->value); + } + + $imagick = new Imagick(); + $formatStr = $this->formatToImagick($format); + + foreach ($sequence->frames() as $frame) { + if (!$frame->image instanceof ImagickResource) { + throw new DriverException('All frames must be ImagickResource instances'); + } + + $frameIm = clone $frame->image->imagick(); + $frameIm->setImageFormat($formatStr); + + $delay = $frame->delay > 0 ? $frame->delay : $animation->defaultDelay; + $frameIm->setImageDelay((int) round($delay / 10)); + $frameIm->setImageDispose($frame->disposal->value); + $frameIm->setImagePage( + (int) $sequence->canvasSize()->width, + (int) $sequence->canvasSize()->height, + $frame->x, + $frame->y, + ); + + if ($options->quality !== null) { + $frameIm->setImageCompressionQuality($options->quality); + } + + $imagick->addImage($frameIm); + } + + $imagick->setImageIterations($animation->loopCount); + + if ($animation->optimize && $sequence->count() > 1) { + $optimized = $imagick->deconstructImages(); + $imagick->clear(); + $imagick = $optimized; + } + + if ($options->stripMetadata) { + $imagick->stripImage(); + } + + return $imagick->getImagesBlob(); + } + + public function coalesce(ImageSequence $sequence): ImageSequence + { + $combined = $this->sequenceToImagick($sequence); + $coalesced = $combined->coalesceImages(); + + return $this->imagickToSequence($coalesced); + } + + public function optimize(ImageSequence $sequence): ImageSequence + { + $combined = $this->sequenceToImagick($sequence); + $optimized = $combined->deconstructImages(); + + return $this->imagickToSequence($optimized); + } + + private function buildSequence(Imagick $imagick, DecodeOptions $options): ImageSequence + { + $numImages = $imagick->getNumberImages(); + $frames = []; + + $imagick->setFirstIterator(); + $canvasWidth = $imagick->getImageWidth(); + $canvasHeight = $imagick->getImageHeight(); + + for ($i = 0; $i < $numImages; $i++) { + $imagick->setIteratorIndex($i); + + $frameIm = clone $imagick; + $frameIm->setIteratorIndex(0); + + if ($options->autoOrient) { + $frameIm->autoOrient(); + } + + $delay = $imagick->getImageDelay() * 10; + $dispose = DisposalMethod::tryFrom($imagick->getImageDispose()) ?? DisposalMethod::None; + $page = $imagick->getImagePage(); + + $resource = new ImagickResource($frameIm, $this); + $frames[] = new Frame( + image: $resource, + delay: $delay, + disposal: $dispose, + x: $page['x'], + y: $page['y'], + ); + } + + $canvasSize = new Size((float) $canvasWidth, (float) $canvasHeight); + + return ImageSequence::fromFrames($frames, $canvasSize); + } + + private function sequenceToImagick(ImageSequence $sequence): Imagick + { + $combined = new Imagick(); + + foreach ($sequence->frames() as $frame) { + if (!$frame->image instanceof ImagickResource) { + throw new DriverException('All frames must be ImagickResource instances'); + } + $combined->addImage(clone $frame->image->imagick()); + } + + return $combined; + } + + private function imagickToSequence(Imagick $imagick): ImageSequence + { + $frames = []; + $numImages = $imagick->getNumberImages(); + + $imagick->setFirstIterator(); + $canvasWidth = $imagick->getImageWidth(); + $canvasHeight = $imagick->getImageHeight(); + + for ($i = 0; $i < $numImages; $i++) { + $imagick->setIteratorIndex($i); + + $frameIm = clone $imagick; + $frameIm->setIteratorIndex(0); + + $delay = $imagick->getImageDelay() * 10; + $dispose = DisposalMethod::tryFrom($imagick->getImageDispose()) ?? DisposalMethod::None; + $page = $imagick->getImagePage(); + + $resource = new ImagickResource($frameIm, $this); + $frames[] = new Frame( + image: $resource, + delay: $delay, + disposal: $dispose, + x: $page['x'], + y: $page['y'], + ); + } + + return ImageSequence::fromFrames($frames, new Size((float) $canvasWidth, (float) $canvasHeight)); + } + + private function postProcess(Imagick $imagick, DecodeOptions $options): ImagickResource + { + if ($options->autoOrient) { + $imagick->autoOrient(); + } + + if ($options->maxWidth !== null || $options->maxHeight !== null) { + $w = $imagick->getImageWidth(); + $h = $imagick->getImageHeight(); + $maxW = $options->maxWidth ?? $w; + $maxH = $options->maxHeight ?? $h; + + if ($w > $maxW || $h > $maxH) { + $imagick->thumbnailImage($maxW, $maxH, true); + } + } + + return new ImagickResource($imagick, $this); + } + + private function formatToImagick(ImageFormat $format): string + { + return match ($format) { + ImageFormat::PNG => 'PNG', + ImageFormat::JPEG => 'JPEG', + ImageFormat::WebP => 'WEBP', + ImageFormat::AVIF => 'AVIF', + ImageFormat::GIF => 'GIF', + ImageFormat::TIFF => 'TIFF', + ImageFormat::BMP => 'BMP', + ImageFormat::SVG => 'SVG', + }; + } + + public static function colorToPixel(Color $color): ImagickPixel + { + return match ($color->colorModel()) { + ColorModel::Rgb => new ImagickPixel(sprintf( + 'rgb(%d, %d, %d)', + (int) round($color->red() * 255), + (int) round($color->green() * 255), + (int) round($color->blue() * 255), + )), + ColorModel::Rgba => new ImagickPixel(sprintf( + 'rgba(%d, %d, %d, %.4f)', + (int) round($color->red() * 255), + (int) round($color->green() * 255), + (int) round($color->blue() * 255), + $color->alpha(), + )), + ColorModel::Cmyk => new ImagickPixel(sprintf( + 'cmyk(%d%%, %d%%, %d%%, %d%%)', + (int) round($color->cyan() * 100), + (int) round($color->magenta() * 100), + (int) round($color->yellow() * 100), + (int) round($color->key() * 100), + )), + ColorModel::Gray => new ImagickPixel(sprintf( + 'gray(%d%%)', + (int) round($color->luminance() * 100), + )), + }; + } +} diff --git a/src/Driver/ImagickResource.php b/src/Driver/ImagickResource.php new file mode 100644 index 0000000..f9ddeb2 --- /dev/null +++ b/src/Driver/ImagickResource.php @@ -0,0 +1,189 @@ +imagick->getImageWidth(), + (float) $this->imagick->getImageHeight(), + ); + } + + public function resize(Size $size): static + { + $clone = clone $this; + $clone->imagick = clone $this->imagick; + $clone->imagick->resizeImage( + (int) $size->width, + (int) $size->height, + Imagick::FILTER_LANCZOS, + 1, + ); + return $clone; + } + + public function crop(Rectangle $region): static + { + $clone = clone $this; + $clone->imagick = clone $this->imagick; + $clone->imagick->cropImage( + (int) $region->size->width, + (int) $region->size->height, + (int) $region->origin->x, + (int) $region->origin->y, + ); + $clone->imagick->setImagePage(0, 0, 0, 0); + return $clone; + } + + public function rotate(float $angleDeg, Color $background): static + { + $clone = clone $this; + $clone->imagick = clone $this->imagick; + $clone->imagick->rotateImage( + ImagickDriver::colorToPixel($background), + $angleDeg, + ); + return $clone; + } + + public function flip(bool $horizontal = false, bool $vertical = false): static + { + $clone = clone $this; + $clone->imagick = clone $this->imagick; + + if ($vertical) { + $clone->imagick->flipImage(); + } + if ($horizontal) { + $clone->imagick->flopImage(); + } + + return $clone; + } + + public function apply(Filter $filter): static + { + $clone = clone $this; + $clone->imagick = clone $this->imagick; + + match (true) { + $filter instanceof Grayscale => $clone->imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE), + $filter instanceof Sepia => $clone->imagick->sepiaToneImage($filter->threshold), + $filter instanceof Blur => $clone->imagick->blurImage($filter->radius, $filter->sigma), + $filter instanceof Sharpen => $clone->imagick->unsharpMaskImage( + $filter->radius, + $filter->sigma, + $filter->amount, + $filter->threshold, + ), + $filter instanceof Brightness => $clone->applyBrightness($filter->level), + $filter instanceof Contrast => $clone->applyContrast($filter->level), + $filter instanceof Gamma => $clone->imagick->gammaImage($filter->gamma), + $filter instanceof Colorize => $clone->imagick->colorizeImage( + ImagickDriver::colorToPixel($filter->color), + new ImagickPixel(sprintf('rgba(0,0,0,%.4f)', $filter->opacity)), + ), + $filter instanceof Negate => $clone->imagick->negateImage(false), + $filter instanceof Pixelate => $clone->applyPixelate($filter->size), + $filter instanceof Modulate => $clone->imagick->modulateImage( + $filter->brightness, + $filter->saturation, + $filter->hue, + ), + default => throw new DriverException('Unsupported filter: ' . $filter::class), + }; + + return $clone; + } + + public function drawingContext(): DrawingContext + { + return new ImagickDrawingContext($this->imagick); + } + + public function effect(Effect $effect): static + { + $result = $effect->apply($this); + if (!$result instanceof static) { + throw new DriverException('Effect must return an instance of ' . static::class); + } + return $result; + } + + public function imagick(): Imagick + { + return $this->imagick; + } + + public function withImagick(Imagick $imagick): static + { + $clone = clone $this; + $clone->imagick = $imagick; + return $clone; + } + + private function applyBrightness(float $level): void + { + $brightness = 100 + $level; + $this->imagick->modulateImage($brightness, 100, 100); + } + + private function applyContrast(float $level): void + { + if ($level > 0) { + $sharpen = true; + } else { + $sharpen = false; + $level = abs($level); + } + + $iterations = (int) ceil($level / 10); + for ($i = 0; $i < $iterations; $i++) { + $this->imagick->contrastImage($sharpen); + } + } + + private function applyPixelate(int $size): void + { + $width = $this->imagick->getImageWidth(); + $height = $this->imagick->getImageHeight(); + + $this->imagick->scaleImage( + (int) ceil($width / $size), + (int) ceil($height / $size), + ); + $this->imagick->scaleImage($width, $height); + } +} diff --git a/src/Driver/NullDriver.php b/src/Driver/NullDriver.php new file mode 100644 index 0000000..0c33f43 --- /dev/null +++ b/src/Driver/NullDriver.php @@ -0,0 +1,63 @@ + true, + default => false, + }; + } +} diff --git a/src/Driver/NullResource.php b/src/Driver/NullResource.php new file mode 100644 index 0000000..8c0e643 --- /dev/null +++ b/src/Driver/NullResource.php @@ -0,0 +1,69 @@ +size; + } + + public function resize(Size $size): static + { + return new static($size); + } + + public function crop(Rectangle $region): static + { + return new static($region->size); + } + + public function rotate(float $angleDeg, Color $background): static + { + $normalized = ((int) $angleDeg) % 360; + if ($normalized < 0) { + $normalized += 360; + } + + if ($normalized === 90 || $normalized === 270) { + return new static(new Size($this->size->height, $this->size->width)); + } + + return new static($this->size); + } + + public function flip(bool $horizontal = false, bool $vertical = false): static + { + return new static($this->size); + } + + public function apply(Filter $filter): static + { + return new static($this->size); + } + + public function effect(Effect $effect): static + { + return new static($this->size); + } + + public function drawingContext(): DrawingContext + { + throw new DriverException('NullDriver does not support drawing operations'); + } +} diff --git a/src/Driver/PngDriver.php b/src/Driver/PngDriver.php new file mode 100644 index 0000000..daba626 --- /dev/null +++ b/src/Driver/PngDriver.php @@ -0,0 +1,86 @@ +> 16) & 0xFF; + $g = ($rgb >> 8) & 0xFF; + $b = $rgb & 0xFF; + $resource->setPixel($x, $y, $r, $g, $b); + } + } + + imagedestroy($gd); + return $resource; + } + + public function loadFile(string $path, DecodeOptions $options = new DecodeOptions()): ImageResource + { + if (!is_readable($path)) { + throw new DriverException('File not readable: ' . $path); + } + + $data = file_get_contents($path); + if ($data === false) { + throw new DriverException('Failed to read file: ' . $path); + } + + return $this->load($data, $options); + } + + public function create(Size $size, Color $background): ImageResource + { + return new PngResource($size, $background); + } + + public function encode(ImageResource $image, ImageFormat $format, EncodeOptions $options = new EncodeOptions()): string + { + if (!$image instanceof PngResource) { + throw new DriverException('PngDriver can only encode PngResource instances'); + } + + if (!$this->supports($format)) { + throw new FormatException('Format not supported: ' . $format->value); + } + + return $image->toPngData(); + } + + public function supports(ImageFormat $format): bool + { + return $format === ImageFormat::PNG; + } +} diff --git a/src/Driver/PngResource.php b/src/Driver/PngResource.php new file mode 100644 index 0000000..a43ee31 --- /dev/null +++ b/src/Driver/PngResource.php @@ -0,0 +1,243 @@ +> row => col => [r, g, b] */ + private array $pixels; + private int $width; + private int $height; + + public function __construct(Size $size, Color $background) + { + $this->width = max(1, (int) $size->width); + $this->height = max(1, (int) $size->height); + + $r = (int) round($background->red() * 255); + $g = (int) round($background->green() * 255); + $b = (int) round($background->blue() * 255); + + $row = array_fill(0, $this->width, [$r, $g, $b]); + $this->pixels = array_fill(0, $this->height, $row); + } + + /** @param array> $pixels */ + private static function fromPixels(array $pixels, int $width, int $height): self + { + $size = new Size((float) $width, (float) $height); + $instance = new self($size, Color::rgb(0.0, 0.0, 0.0)); + $instance->pixels = $pixels; + $instance->width = $width; + $instance->height = $height; + return $instance; + } + + public function size(): Size + { + return new Size((float) $this->width, (float) $this->height); + } + + public function width(): int + { + return $this->width; + } + + public function height(): int + { + return $this->height; + } + + public function setPixel(int $x, int $y, int $r, int $g, int $b): void + { + if ($x >= 0 && $x < $this->width && $y >= 0 && $y < $this->height) { + $this->pixels[$y][$x] = [$r, $g, $b]; + } + } + + /** @return array{int, int, int} */ + public function getPixel(int $x, int $y): array + { + if ($x >= 0 && $x < $this->width && $y >= 0 && $y < $this->height) { + return $this->pixels[$y][$x]; + } + return [0, 0, 0]; + } + + public function resize(Size $size): static + { + $newW = max(1, (int) $size->width); + $newH = max(1, (int) $size->height); + $pixels = []; + + for ($y = 0; $y < $newH; $y++) { + $srcY = (int) ($y * $this->height / $newH); + $row = []; + for ($x = 0; $x < $newW; $x++) { + $srcX = (int) ($x * $this->width / $newW); + $row[] = $this->pixels[$srcY][$srcX]; + } + $pixels[] = $row; + } + + return self::fromPixels($pixels, $newW, $newH); + } + + public function crop(Rectangle $region): static + { + $sx = max(0, (int) $region->origin->x); + $sy = max(0, (int) $region->origin->y); + $w = (int) $region->size->width; + $h = (int) $region->size->height; + $pixels = []; + + for ($y = 0; $y < $h; $y++) { + $row = []; + for ($x = 0; $x < $w; $x++) { + $row[] = $this->getPixel($sx + $x, $sy + $y); + } + $pixels[] = $row; + } + + return self::fromPixels($pixels, $w, $h); + } + + public function rotate(float $angleDeg, Color $background): static + { + $normalized = ((int) $angleDeg) % 360; + if ($normalized < 0) { + $normalized += 360; + } + + if ($normalized === 0) { + return clone $this; + } + + if ($normalized === 90) { + $pixels = []; + for ($y = 0; $y < $this->width; $y++) { + $row = []; + for ($x = 0; $x < $this->height; $x++) { + $row[] = $this->pixels[$this->height - 1 - $x][$y]; + } + $pixels[] = $row; + } + return self::fromPixels($pixels, $this->height, $this->width); + } + + if ($normalized === 180) { + $pixels = []; + for ($y = $this->height - 1; $y >= 0; $y--) { + $pixels[] = array_reverse($this->pixels[$y]); + } + return self::fromPixels($pixels, $this->width, $this->height); + } + + if ($normalized === 270) { + $pixels = []; + for ($y = $this->width - 1; $y >= 0; $y--) { + $row = []; + for ($x = 0; $x < $this->height; $x++) { + $row[] = $this->pixels[$x][$y]; + } + $pixels[] = $row; + } + return self::fromPixels($pixels, $this->height, $this->width); + } + + return clone $this; + } + + public function flip(bool $horizontal = false, bool $vertical = false): static + { + $pixels = $this->pixels; + + if ($vertical) { + $pixels = array_reverse($pixels); + } + + if ($horizontal) { + foreach ($pixels as $y => $row) { + $pixels[$y] = array_reverse($row); + } + } + + if (!$horizontal && !$vertical) { + $pixels = $this->pixels; + } + + return self::fromPixels($pixels, $this->width, $this->height); + } + + public function apply(Filter $filter): static + { + return clone $this; + } + + public function effect(Effect $effect): static + { + return clone $this; + } + + public function drawingContext(): DrawingContext + { + throw new DriverException('PngDriver does not support a full drawing context'); + } + + public function toPngData(): string + { + $ihdr = $this->buildIhdr(); + $idat = $this->buildIdat(); + $iend = $this->buildIend(); + + return "\x89PNG\r\n\x1a\n" . $ihdr . $idat . $iend; + } + + private function buildIhdr(): string + { + $data = pack('NNCCCCC', $this->width, $this->height, 8, 2, 0, 0, 0); + return $this->buildChunk('IHDR', $data); + } + + private function buildIdat(): string + { + $raw = ''; + for ($y = 0; $y < $this->height; $y++) { + $raw .= "\x00"; + for ($x = 0; $x < $this->width; $x++) { + [$r, $g, $b] = $this->pixels[$y][$x]; + $raw .= chr($r) . chr($g) . chr($b); + } + } + + $compressed = gzcompress($raw); + if ($compressed === false) { + throw new DriverException('Failed to compress PNG data'); + } + + return $this->buildChunk('IDAT', $compressed); + } + + private function buildIend(): string + { + return $this->buildChunk('IEND', ''); + } + + private function buildChunk(string $type, string $data): string + { + $chunk = $type . $data; + $crc = pack('N', crc32($chunk)); + $length = pack('N', strlen($data)); + return $length . $chunk . $crc; + } +} diff --git a/src/Driver/SequenceDriver.php b/src/Driver/SequenceDriver.php new file mode 100644 index 0000000..354beab --- /dev/null +++ b/src/Driver/SequenceDriver.php @@ -0,0 +1,29 @@ + */ + private array $stateStack = []; + + private string $pathData = ''; + + public function __construct( + private readonly DOMDocument $dom, + private readonly DOMElement $container, + ) { + $this->fillColor = Color::rgb(0.0, 0.0, 0.0); + $this->strokeColor = Color::rgb(0.0, 0.0, 0.0); + $this->dashPattern = LineDashPattern::solid(); + } + + public function save(): static + { + $this->stateStack[] = [ + $this->fillColor, + $this->strokeColor, + $this->lineWidth, + $this->lineCap, + $this->dashPattern, + ]; + return $this; + } + + public function restore(): static + { + $state = array_pop($this->stateStack); + if ($state !== null) { + [$this->fillColor, $this->strokeColor, $this->lineWidth, $this->lineCap, $this->dashPattern] = $state; + } + return $this; + } + + public function setFillColor(Color $color): static + { + $this->fillColor = $color; + return $this; + } + + public function setStrokeColor(Color $color): static + { + $this->strokeColor = $color; + return $this; + } + + public function setLineWidth(float $width): static + { + $this->lineWidth = $width; + return $this; + } + + public function setLineCap(LineCap $cap): static + { + $this->lineCap = $cap; + return $this; + } + + public function setDashPattern(LineDashPattern $pattern): static + { + $this->dashPattern = $pattern; + return $this; + } + + public function moveTo(float $x, float $y): static + { + $this->pathData .= sprintf('M%.2f %.2f ', $x, $y); + return $this; + } + + public function lineTo(float $x, float $y): static + { + $this->pathData .= sprintf('L%.2f %.2f ', $x, $y); + return $this; + } + + public function curveTo( + float $x1, + float $y1, + float $x2, + float $y2, + float $x3, + float $y3, + ): static { + $this->pathData .= sprintf('C%.2f %.2f %.2f %.2f %.2f %.2f ', $x1, $y1, $x2, $y2, $x3, $y3); + return $this; + } + + public function closePath(): static + { + $this->pathData .= 'Z '; + return $this; + } + + public function rect(float $x, float $y, float $w, float $h): static + { + $this->pathData .= sprintf('M%.2f %.2f L%.2f %.2f L%.2f %.2f L%.2f %.2f Z ', $x, $y, $x + $w, $y, $x + $w, $y + $h, $x, $y + $h); + return $this; + } + + public function stroke(): static + { + $el = $this->createPathElement(); + $el->setAttribute('fill', 'none'); + $el->setAttribute('stroke', SvgResource::colorToSvg($this->strokeColor)); + $el->setAttribute('stroke-width', (string) $this->lineWidth); + $this->applyLineCap($el); + $this->applyDash($el); + $this->container->appendChild($el); + $this->pathData = ''; + return $this; + } + + public function fill(): static + { + $el = $this->createPathElement(); + $el->setAttribute('fill', SvgResource::colorToSvg($this->fillColor)); + $el->setAttribute('stroke', 'none'); + $this->container->appendChild($el); + $this->pathData = ''; + return $this; + } + + public function fillAndStroke(): static + { + $el = $this->createPathElement(); + $el->setAttribute('fill', SvgResource::colorToSvg($this->fillColor)); + $el->setAttribute('stroke', SvgResource::colorToSvg($this->strokeColor)); + $el->setAttribute('stroke-width', (string) $this->lineWidth); + $this->applyLineCap($el); + $this->applyDash($el); + $this->container->appendChild($el); + $this->pathData = ''; + return $this; + } + + public function clip(): static + { + $this->pathData = ''; + return $this; + } + + public function transform(AffineTransform $transform): static + { + return $this; + } + + public function circle(float $cx, float $cy, float $radius): static + { + $el = $this->dom->createElement('circle'); + $el->setAttribute('cx', (string) $cx); + $el->setAttribute('cy', (string) $cy); + $el->setAttribute('r', (string) $radius); + $el->setAttribute('fill', SvgResource::colorToSvg($this->fillColor)); + $el->setAttribute('stroke', SvgResource::colorToSvg($this->strokeColor)); + $el->setAttribute('stroke-width', (string) $this->lineWidth); + $this->container->appendChild($el); + return $this; + } + + public function line(float $x1, float $y1, float $x2, float $y2): static + { + $el = $this->dom->createElement('line'); + $el->setAttribute('x1', (string) $x1); + $el->setAttribute('y1', (string) $y1); + $el->setAttribute('x2', (string) $x2); + $el->setAttribute('y2', (string) $y2); + $el->setAttribute('stroke', SvgResource::colorToSvg($this->strokeColor)); + $el->setAttribute('stroke-width', (string) $this->lineWidth); + $this->applyLineCap($el); + $this->applyDash($el); + $this->container->appendChild($el); + return $this; + } + + /** @param array $points */ + public function polygon(array $points): static + { + $pairs = []; + foreach ($points as $pt) { + $pairs[] = sprintf('%.2f,%.2f', $pt->x, $pt->y); + } + $el = $this->dom->createElement('polygon'); + $el->setAttribute('points', implode(' ', $pairs)); + $el->setAttribute('fill', SvgResource::colorToSvg($this->fillColor)); + $el->setAttribute('stroke', SvgResource::colorToSvg($this->strokeColor)); + $el->setAttribute('stroke-width', (string) $this->lineWidth); + $this->container->appendChild($el); + return $this; + } + + public function text(string $text, float $x, float $y, TextStyle $style = new TextStyle()): static + { + $el = $this->dom->createElement('text', htmlspecialchars($text, ENT_XML1)); + $el->setAttribute('x', (string) $x); + $el->setAttribute('y', (string) $y); + $el->setAttribute('font-size', (string) $style->size); + $el->setAttribute('fill', SvgResource::colorToSvg($this->fillColor)); + + if ($style->fontFamily !== null) { + $el->setAttribute('font-family', $style->fontFamily); + } + + if ($style->angle !== 0.0) { + $el->setAttribute('transform', sprintf('rotate(%.1f %s %s)', $style->angle, (string) $x, (string) $y)); + } + + $this->container->appendChild($el); + return $this; + } + + private function createPathElement(): DOMElement + { + $el = $this->dom->createElement('path'); + $el->setAttribute('d', trim($this->pathData)); + return $el; + } + + private function applyLineCap(DOMElement $el): void + { + $cap = match ($this->lineCap) { + LineCap::Butt => 'butt', + LineCap::Round => 'round', + LineCap::Square => 'square', + }; + $el->setAttribute('stroke-linecap', $cap); + } + + private function applyDash(DOMElement $el): void + { + if ($this->dashPattern->dashArray !== []) { + $el->setAttribute('stroke-dasharray', implode(' ', array_map(fn(float $v) => (string) $v, $this->dashPattern->dashArray))); + if ($this->dashPattern->dashPhase > 0.0) { + $el->setAttribute('stroke-dashoffset', (string) $this->dashPattern->dashPhase); + } + } + } +} diff --git a/src/Driver/SvgDriver.php b/src/Driver/SvgDriver.php new file mode 100644 index 0000000..5008138 --- /dev/null +++ b/src/Driver/SvgDriver.php @@ -0,0 +1,110 @@ +parseSvgSize($data); + $resource = new SvgResource($size); + + return $this->loadSvgContent($data, $resource); + } + + public function loadFile(string $path, DecodeOptions $options = new DecodeOptions()): ImageResource + { + if (!is_readable($path)) { + throw new DriverException('File not readable: ' . $path); + } + + $data = file_get_contents($path); + if ($data === false) { + throw new DriverException('Failed to read file: ' . $path); + } + + return $this->load($data, $options); + } + + public function create(Size $size, Color $background): ImageResource + { + return new SvgResource($size, $background); + } + + public function encode(ImageResource $image, ImageFormat $format, EncodeOptions $options = new EncodeOptions()): string + { + if (!$image instanceof SvgResource) { + throw new DriverException('SvgDriver can only encode SvgResource instances'); + } + + if (!$this->supports($format)) { + throw new FormatException('Format not supported: ' . $format->value); + } + + return $image->toSvg(); + } + + public function supports(ImageFormat $format): bool + { + return $format === ImageFormat::SVG; + } + + private function parseSvgSize(string $data): Size + { + $dom = new DOMDocument(); + $prev = libxml_use_internal_errors(true); + $dom->loadXML($data); + libxml_use_internal_errors($prev); + + $svg = $dom->documentElement; + if ($svg === null) { + throw new DriverException('Invalid SVG data'); + } + + $width = 0.0; + $height = 0.0; + + if ($svg->hasAttribute('width') && $svg->hasAttribute('height')) { + $width = (float) $svg->getAttribute('width'); + $height = (float) $svg->getAttribute('height'); + } elseif ($svg->hasAttribute('viewBox')) { + $parts = preg_split('/[\s,]+/', $svg->getAttribute('viewBox')); + if ($parts !== false && count($parts) >= 4) { + $width = (float) $parts[2]; + $height = (float) $parts[3]; + } + } + + if ($width <= 0 || $height <= 0) { + throw new DriverException('Cannot determine SVG dimensions'); + } + + return new Size($width, $height); + } + + private function loadSvgContent(string $data, SvgResource $fallback): SvgResource + { + $dom = new DOMDocument(); + $prev = libxml_use_internal_errors(true); + $loaded = $dom->loadXML($data); + libxml_use_internal_errors($prev); + + if (!$loaded || $dom->documentElement === null) { + return $fallback; + } + + $size = $this->parseSvgSize($data); + return new SvgResource($size); + } +} diff --git a/src/Driver/SvgResource.php b/src/Driver/SvgResource.php new file mode 100644 index 0000000..c43bfa2 --- /dev/null +++ b/src/Driver/SvgResource.php @@ -0,0 +1,186 @@ +dom = new DOMDocument('1.0', 'UTF-8'); + $this->svg = $this->dom->createElementNS('http://www.w3.org/2000/svg', 'svg'); + $this->svg->setAttribute('width', (string) (int) $size->width); + $this->svg->setAttribute('height', (string) (int) $size->height); + $this->svg->setAttribute('viewBox', sprintf('0 0 %d %d', (int) $size->width, (int) $size->height)); + $this->dom->appendChild($this->svg); + $this->currentGroup = $this->svg; + + if ($background !== null) { + $isTransparent = $background->colorModel() === ColorModel::Rgba && $background->alpha() < 0.01; + if (!$isTransparent) { + $rect = $this->dom->createElement('rect'); + $rect->setAttribute('width', '100%'); + $rect->setAttribute('height', '100%'); + $rect->setAttribute('fill', self::colorToSvg($background)); + $this->svg->appendChild($rect); + } + } + } + + public function size(): Size + { + return $this->size; + } + + public function resize(Size $size): static + { + $clone = clone $this; + $clone->size = $size; + $clone->svg->setAttribute('width', (string) (int) $size->width); + $clone->svg->setAttribute('height', (string) (int) $size->height); + $clone->svg->setAttribute('viewBox', sprintf('0 0 %d %d', (int) $size->width, (int) $size->height)); + return $clone; + } + + public function crop(Rectangle $region): static + { + $clone = clone $this; + $clone->size = $region->size; + $clone->svg->setAttribute('width', (string) (int) $region->size->width); + $clone->svg->setAttribute('height', (string) (int) $region->size->height); + $clone->svg->setAttribute( + 'viewBox', + sprintf( + '%d %d %d %d', + (int) $region->origin->x, + (int) $region->origin->y, + (int) $region->size->width, + (int) $region->size->height, + ), + ); + return $clone; + } + + public function rotate(float $angleDeg, Color $background): static + { + $clone = clone $this; + $normalized = ((int) $angleDeg) % 360; + if ($normalized < 0) { + $normalized += 360; + } + + if ($normalized === 90 || $normalized === 270) { + $clone->size = new Size($this->size->height, $this->size->width); + } + + $g = $clone->dom->createElement('g'); + $cx = (int) $this->size->width / 2; + $cy = (int) $this->size->height / 2; + $g->setAttribute('transform', sprintf('rotate(%s %d %d)', (string) $angleDeg, $cx, $cy)); + + while ($clone->svg->firstChild) { + $g->appendChild($clone->svg->firstChild); + } + $clone->svg->appendChild($g); + + $clone->svg->setAttribute('width', (string) (int) $clone->size->width); + $clone->svg->setAttribute('height', (string) (int) $clone->size->height); + $clone->svg->setAttribute('viewBox', sprintf('0 0 %d %d', (int) $clone->size->width, (int) $clone->size->height)); + + return $clone; + } + + public function flip(bool $horizontal = false, bool $vertical = false): static + { + if (!$horizontal && !$vertical) { + return clone $this; + } + + $clone = clone $this; + $transforms = []; + + if ($horizontal) { + $transforms[] = sprintf('translate(%d, 0) scale(-1, 1)', (int) $this->size->width); + } + if ($vertical) { + $transforms[] = sprintf('translate(0, %d) scale(1, -1)', (int) $this->size->height); + } + + $g = $clone->dom->createElement('g'); + $g->setAttribute('transform', implode(' ', $transforms)); + + while ($clone->svg->firstChild) { + $g->appendChild($clone->svg->firstChild); + } + $clone->svg->appendChild($g); + + return $clone; + } + + public function apply(Filter $filter): static + { + return clone $this; + } + + public function effect(Effect $effect): static + { + return clone $this; + } + + public function drawingContext(): DrawingContext + { + return new SvgDrawingContext($this->dom, $this->currentGroup); + } + + public function toSvg(): string + { + $result = $this->dom->saveXML(); + if ($result === false) { + throw new DriverException('Failed to serialize SVG'); + } + return $result; + } + + public function __clone() + { + $this->dom = clone $this->dom; + $root = $this->dom->documentElement; + if (!$root instanceof DOMElement) { + throw new DriverException('Failed to clone SVG document'); + } + $this->svg = $root; + $this->currentGroup = $this->svg; + } + + public static function colorToSvg(Color $color): string + { + $r = (int) round($color->red() * 255); + $g = (int) round($color->green() * 255); + $b = (int) round($color->blue() * 255); + + if ($color->colorModel() === ColorModel::Rgba) { + $a = $color->alpha(); + return sprintf('rgba(%d,%d,%d,%.3f)', $r, $g, $b, $a); + } + + return sprintf('rgb(%d,%d,%d)', $r, $g, $b); + } +} diff --git a/src/DriverException.php b/src/DriverException.php new file mode 100644 index 0000000..3cde5f8 --- /dev/null +++ b/src/DriverException.php @@ -0,0 +1,7 @@ +resolvedColor = $color ?? Color::rgb(0.0, 0.0, 0.0); + } + + public function color(): Color + { + return $this->resolvedColor; + } + + public function apply(ImageResource $image): ImageResource + { + if (!$image instanceof ImagickResource) { + throw new DriverException('Border effect requires ImagickResource'); + } + + $imagick = clone $image->imagick(); + $pixel = ImagickDriver::colorToPixel($this->resolvedColor); + + if ($this->preserveTransparency) { + self::frameImage($imagick, $pixel, $this->width, $this->width); + } else { + $imagick->borderImage($pixel, $this->width, $this->width); + } + + return $image->withImagick($imagick); + } + + private static function frameImage(Imagick $imagick, ImagickPixel $color, int $width, int $height): void + { + $geo = $imagick->getImageGeometry(); + $newWidth = $geo['width'] + (2 * $width); + $newHeight = $geo['height'] + (2 * $height); + + $frame = new Imagick(); + $frame->newImage($newWidth, $newHeight, $color); + $frame->setImageFormat($imagick->getImageFormat() ?: 'png'); + $frame->compositeImage($imagick, Imagick::COMPOSITE_OVER, $width, $height); + + $imagick->clear(); + $imagick->addImage($frame); + $frame->destroy(); + } +} diff --git a/src/Effect/CenterCrop.php b/src/Effect/CenterCrop.php new file mode 100644 index 0000000..6022096 --- /dev/null +++ b/src/Effect/CenterCrop.php @@ -0,0 +1,30 @@ +imagick(); + $imagick->cropThumbnailImage($this->width, $this->height); + $imagick->setImagePage(0, 0, 0, 0); + + return $image->withImagick($imagick); + } +} diff --git a/src/Effect/Composite.php b/src/Effect/Composite.php new file mode 100644 index 0000000..10ed7e9 --- /dev/null +++ b/src/Effect/Composite.php @@ -0,0 +1,47 @@ +overlay instanceof ImagickResource) { + throw new DriverException('Composite overlay must be an ImagickResource'); + } + + $imagick = clone $image->imagick(); + $overlayIm = $this->overlay->imagick(); + + $x = $this->x; + $y = $this->y; + + if ($x === null || $y === null) { + $baseGeo = $imagick->getImageGeometry(); + $overlayGeo = $overlayIm->getImageGeometry(); + $x ??= (int) round(($baseGeo['width'] - $overlayGeo['width']) / 2); + $y ??= (int) round(($baseGeo['height'] - $overlayGeo['height']) / 2); + } + + $imagick->compositeImage($overlayIm, $this->compositeOp, $x, $y); // @phpstan-ignore argument.type + + return $image->withImagick($imagick); + } +} diff --git a/src/Effect/DropShadow.php b/src/Effect/DropShadow.php new file mode 100644 index 0000000..1fd0fd2 --- /dev/null +++ b/src/Effect/DropShadow.php @@ -0,0 +1,58 @@ +resolvedBackground = $background ?? Color::rgba(0.0, 0.0, 0.0, 0.0); + } + + public function apply(ImageResource $image): ImageResource + { + if (!$image instanceof ImagickResource) { + throw new DriverException('DropShadow effect requires ImagickResource'); + } + + $shadow = clone $image->imagick(); + $shadow->setImageBackgroundColor(new ImagickPixel('black')); + $shadow->shadowImage(80, $this->sigma, $this->distance, $this->distance); + + $bgColor = ImagickDriver::colorToPixel($this->resolvedBackground); + if ($this->resolvedBackground->alpha() > 0.01) { + $size = $shadow->getImageGeometry(); + $bg = new Imagick(); + $bg->newImage($size['width'], $size['height'], $bgColor); + $bg->setImageFormat($image->imagick()->getImageFormat() ?: 'png'); + $bg->compositeImage($shadow, Imagick::COMPOSITE_OVER, 0, 0); + $shadow->clear(); + $shadow->addImage($bg); + $bg->destroy(); + } + + $shadow->compositeImage($image->imagick(), Imagick::COMPOSITE_OVER, 0, 0); + + if ($this->padding > 0) { + $shadow->borderImage($bgColor, $this->padding, $this->padding); + } + + return $image->withImagick($shadow); + } +} diff --git a/src/Effect/Effect.php b/src/Effect/Effect.php new file mode 100644 index 0000000..e2cdc04 --- /dev/null +++ b/src/Effect/Effect.php @@ -0,0 +1,19 @@ +imagick(); + $imagick->liquidRescaleImage($this->width, $this->height, $this->deltaX, $this->rigidity); + + return $image->withImagick($imagick); + } +} diff --git a/src/Effect/PhotoStack.php b/src/Effect/PhotoStack.php new file mode 100644 index 0000000..8822baf --- /dev/null +++ b/src/Effect/PhotoStack.php @@ -0,0 +1,174 @@ + $images Background images (bottom of stack) + */ + public function __construct( + public readonly array $images = [], + public readonly PhotoStackStyle $style = PhotoStackStyle::Plain, + public readonly int $thumbnailHeight = 150, + ?Color $background = null, + ?Color $borderColor = null, + public readonly int $borderWidth = 1, + public readonly int $offset = 5, + public readonly int $padding = 0, + public readonly int $borderRounding = 10, + ) { + $this->resolvedBackground = $background ?? Color::rgba(0.0, 0.0, 0.0, 0.0); + $this->resolvedBorderColor = $borderColor ?? Color::hex('#333333'); + } + + public function background(): Color + { + return $this->resolvedBackground; + } + + public function borderColor(): Color + { + return $this->resolvedBorderColor; + } + + public function apply(ImageResource $image): ImageResource + { + if (!$image instanceof ImagickResource) { + throw new DriverException('PhotoStack effect requires ImagickResource'); + } + + return match ($this->style) { + PhotoStackStyle::Plain => $this->applyPlain($image), + PhotoStackStyle::Rounded => $this->applyRounded($image), + PhotoStackStyle::Polaroid => $this->applyPolaroid($image), + }; + } + + private function applyPlain(ImagickResource $image): ImagickResource + { + $allImages = $this->prepareStack($image); + $borderPixel = ImagickDriver::colorToPixel($this->resolvedBorderColor); + + foreach ($allImages as $img) { + $img->borderImage($borderPixel, $this->borderWidth, $this->borderWidth); + } + + return $this->compositeStack($image, $allImages); + } + + private function applyRounded(ImagickResource $image): ImagickResource + { + $allImages = $this->prepareStack($image); + + foreach ($allImages as $img) { + $img->roundCorners($this->borderRounding, $this->borderRounding); + $borderPixel = ImagickDriver::colorToPixel($this->resolvedBorderColor); + $img->borderImage($borderPixel, $this->borderWidth, $this->borderWidth); + } + + return $this->compositeStack($image, $allImages); + } + + private function applyPolaroid(ImagickResource $image): ImagickResource + { + $allImages = $this->prepareStack($image); + + $count = count($allImages); + foreach ($allImages as $i => $img) { + $img->setImageBackgroundColor(new ImagickPixel('black')); + $angle = ($i === $count - 1) ? 0.0 : (float) mt_rand(-25, 25); + $img->polaroidImage(new ImagickDraw(), $angle); + } + + return $this->compositeStack($image, $allImages); + } + + /** + * @return list + */ + private function prepareStack(ImagickResource $topImage): array + { + $result = []; + + foreach ($this->images as $bgImage) { + $img = clone $bgImage->imagick(); + $img->thumbnailImage(0, $this->thumbnailHeight); + $result[] = $img; + } + + $top = clone $topImage->imagick(); + $top->thumbnailImage(0, $this->thumbnailHeight); + $result[] = $top; + + return $result; + } + + /** + * @param list $allImages + */ + private function compositeStack(ImagickResource $original, array $allImages): ImagickResource + { + if ($allImages === []) { + return $original; + } + + $maxWidth = 0; + $maxHeight = 0; + foreach ($allImages as $img) { + $geo = $img->getImageGeometry(); + $diagonal = (int) ceil(sqrt($geo['width'] ** 2 + $geo['height'] ** 2)); + if ($diagonal > $maxWidth) { + $maxWidth = $diagonal; + } + if ($diagonal > $maxHeight) { + $maxHeight = $diagonal; + } + } + + $count = count($allImages); + $canvasWidth = (int) ($maxWidth * 1.5) + ($count * $this->offset) + 20; + $canvasHeight = (int) ($maxHeight * 1.5) + ($count * $this->offset) + 20; + + $canvas = new Imagick(); + $canvas->newImage($canvasWidth, $canvasHeight, new ImagickPixel('transparent')); + $canvas->setImageFormat('png'); + + $xo = (int) (($canvasWidth - $maxWidth) / 2) + (($count - 1) * $this->offset); + $yo = (int) (($canvasHeight - $maxHeight) / 2) + (($count - 1) * $this->offset); + + foreach ($allImages as $i => $img) { + $geo = $img->getImageGeometry(); + $x = $xo - (int) (($geo['width'] - $maxWidth) / 2); + $y = $yo - (int) (($geo['height'] - $maxHeight) / 2); + $canvas->compositeImage($img, Imagick::COMPOSITE_OVER, $x, $y); + $img->destroy(); + $xo -= $this->offset; + $yo -= $this->offset; + } + + $canvas->trimImage(0); + $canvas->setImagePage(0, 0, 0, 0); + + if ($this->padding > 0) { + $bgPixel = ImagickDriver::colorToPixel($this->resolvedBackground); + $canvas->borderImage($bgPixel, $this->padding, $this->padding); + } + + return $original->withImagick($canvas); + } +} diff --git a/src/Effect/PhotoStackStyle.php b/src/Effect/PhotoStackStyle.php new file mode 100644 index 0000000..6330032 --- /dev/null +++ b/src/Effect/PhotoStackStyle.php @@ -0,0 +1,12 @@ +resolvedBackground = $background ?? Color::rgba(0.0, 0.0, 0.0, 0.0); + $this->resolvedShadowColor = $shadowColor ?? Color::rgb(0.0, 0.0, 0.0); + } + + public function background(): Color + { + return $this->resolvedBackground; + } + + public function shadowColor(): Color + { + return $this->resolvedShadowColor; + } + + public function apply(ImageResource $image): ImageResource + { + if (!$image instanceof ImagickResource) { + throw new DriverException('PolaroidImage effect requires ImagickResource'); + } + + $imagick = clone $image->imagick(); + + $imagick->setImageBackgroundColor($this->colorToPixel($this->resolvedShadowColor)); + $imagick->polaroidImage(new ImagickDraw(), $this->angle); + + $geo = $imagick->getImageGeometry(); + $canvas = new Imagick(); + $canvas->newImage($geo['width'], $geo['height'], $this->colorToPixel($this->resolvedBackground)); + $canvas->setImageFormat('png'); + $canvas->compositeImage($imagick, Imagick::COMPOSITE_OVER, 0, 0); + + $imagick->destroy(); + + return $image->withImagick($canvas); + } + + private function colorToPixel(Color $color): ImagickPixel + { + $r = (int) round($color->red() * 255); + $g = (int) round($color->green() * 255); + $b = (int) round($color->blue() * 255); + $a = $color->alpha(); + + return new ImagickPixel(sprintf('rgba(%d,%d,%d,%s)', $r, $g, $b, $a)); + } +} diff --git a/src/Effect/RoundCorners.php b/src/Effect/RoundCorners.php new file mode 100644 index 0000000..6f7d245 --- /dev/null +++ b/src/Effect/RoundCorners.php @@ -0,0 +1,72 @@ +resolvedBackground = $background ?? Color::rgba(0.0, 0.0, 0.0, 0.0); + $this->resolvedBorderColor = $borderColor ?? Color::rgba(0.0, 0.0, 0.0, 0.0); + } + + public function apply(ImageResource $image): ImageResource + { + if (!$image instanceof ImagickResource) { + throw new DriverException('RoundCorners effect requires ImagickResource'); + } + + $imagick = clone $image->imagick(); + $imagick->roundCorners($this->radius, $this->radius); + + if ($this->border > 0 && $this->resolvedBorderColor->alpha() > 0.01) { + $size = $imagick->getImageGeometry(); + $frame = new Imagick(); + $frame->newImage( + $size['width'] + $this->border, + $size['height'] + $this->border, + ImagickDriver::colorToPixel($this->resolvedBorderColor), + ); + $frame->setImageFormat($imagick->getImageFormat() ?: 'png'); + $frame->roundCorners($this->radius, $this->radius); + $frame->compositeImage( + $imagick, + Imagick::COMPOSITE_OVER, + (int) round($this->border / 2), + (int) round($this->border / 2), + ); + $imagick->clear(); + $imagick->addImage($frame); + $frame->destroy(); + } + + if ($this->resolvedBackground->alpha() > 0.01) { + $size = $imagick->getImageGeometry(); + $bg = new Imagick(); + $bg->newImage($size['width'], $size['height'], ImagickDriver::colorToPixel($this->resolvedBackground)); + $bg->setImageFormat($imagick->getImageFormat() ?: 'png'); + $bg->compositeImage($imagick, Imagick::COMPOSITE_OVER, 0, 0); + $imagick->clear(); + $imagick->addImage($bg); + $bg->destroy(); + } + + return $image->withImagick($imagick); + } +} diff --git a/src/Effect/SmartCrop.php b/src/Effect/SmartCrop.php new file mode 100644 index 0000000..ba4602c --- /dev/null +++ b/src/Effect/SmartCrop.php @@ -0,0 +1,83 @@ +imagick(); + + $imgWidth = $imagick->getImageWidth(); + $imgHeight = $imagick->getImageHeight(); + + if ($imgWidth <= $this->width && $imgHeight <= $this->height) { + return $image->withImagick($imagick); + } + + $edge = clone $imagick; + $edge->edgeImage(1); + $edge->modulateImage(100, 0, 100); + + $bestX = 0; + $bestY = 0; + $bestScore = 0.0; + + $scaleFactors = [1.0, 0.75, 0.5]; + foreach ($scaleFactors as $scale) { + $testW = (int) round($this->width * $scale); + $testH = (int) round($this->height * $scale); + + if ($testW > $imgWidth || $testH > $imgHeight) { + continue; + } + + for ($xStep = 0; $xStep <= 2; $xStep++) { + for ($yStep = 0; $yStep <= 2; $yStep++) { + $x = (int) round(($imgWidth - $testW) * $xStep / 2); + $y = (int) round(($imgHeight - $testH) * $yStep / 2); + + $region = clone $edge; + $region->cropImage($testW, $testH, $x, $y); + + /** @var array $stats */ + $stats = $region->getImageChannelStatistics(); + $score = ($stats[1]['mean'] ?? 0.0) + + ($stats[2]['mean'] ?? 0.0) + + ($stats[4]['mean'] ?? 0.0); + $region->destroy(); + + if ($score > $bestScore) { + $bestScore = $score; + $bestX = $x; + $bestY = $y; + } + } + } + } + + $edge->destroy(); + + $imagick->cropImage($this->width, $this->height, $bestX, $bestY); + $imagick->setImagePage(0, 0, 0, 0); + + return $image->withImagick($imagick); + } +} diff --git a/src/Effect/TextWatermark.php b/src/Effect/TextWatermark.php new file mode 100644 index 0000000..70dc2dc --- /dev/null +++ b/src/Effect/TextWatermark.php @@ -0,0 +1,137 @@ +resolvedColor = $color ?? Color::rgb(1.0, 1.0, 1.0); + } + + public function color(): Color + { + return $this->resolvedColor; + } + + public function apply(ImageResource $image): ImageResource + { + if (!$image instanceof ImagickResource) { + throw new DriverException('TextWatermark effect requires ImagickResource'); + } + + $imagick = clone $image->imagick(); + + if ($this->position === WatermarkPosition::Tile) { + $this->applyTiled($imagick); + } else { + $this->applyPositioned($imagick); + } + + return $image->withImagick($imagick); + } + + private function applyPositioned(Imagick $imagick): void + { + $draw = $this->createDraw(); + $draw->setGravity($this->mapGravity($this->position)); // @phpstan-ignore argument.type + + $imagick->annotateImage($draw, $this->padding, $this->padding, $this->angle, $this->text); + } + + private function applyTiled(Imagick $imagick): void + { + $draw = $this->createDraw(); + + $metrics = $imagick->queryFontMetrics($draw, $this->text); + $textWidth = (int) ceil($metrics['textWidth']); + $textHeight = (int) ceil($metrics['textHeight']); + + $stampWidth = $textWidth + $this->padding * 2; + $stampHeight = $textHeight + $this->padding * 2; + + $stamp = new Imagick(); + $stamp->newImage($stampWidth, $stampHeight, new ImagickPixel('transparent')); + $stamp->setImageFormat('png'); + + $stampDraw = $this->createDraw(); + $stampDraw->setGravity(Imagick::GRAVITY_CENTER); + $stamp->annotateImage($stampDraw, 0, 0, $this->angle, $this->text); + + $geo = $imagick->getImageGeometry(); + $imgWidth = $geo['width']; + $imgHeight = $geo['height']; + + for ($y = 0; $y < $imgHeight; $y += $stampHeight + $this->padding) { + for ($x = 0; $x < $imgWidth; $x += $stampWidth + $this->padding) { + $imagick->compositeImage($stamp, Imagick::COMPOSITE_OVER, $x, $y); + } + } + + $stamp->destroy(); + } + + private function createDraw(): ImagickDraw + { + $draw = new ImagickDraw(); + + $pixel = $this->colorToPixel(); + $draw->setFillColor($pixel); + $draw->setFontSize($this->fontSize); + + if ($this->fontFile !== null) { + $draw->setFont($this->fontFile); + } elseif ($this->fontFamily !== null) { + $draw->setFontFamily($this->fontFamily); + } + + return $draw; + } + + private function colorToPixel(): ImagickPixel + { + $r = (int) round($this->resolvedColor->red() * 255); + $g = (int) round($this->resolvedColor->green() * 255); + $b = (int) round($this->resolvedColor->blue() * 255); + $a = $this->opacity; + + return new ImagickPixel(sprintf('rgba(%d,%d,%d,%s)', $r, $g, $b, $a)); + } + + private function mapGravity(WatermarkPosition $position): int + { + return match ($position) { + WatermarkPosition::TopLeft => Imagick::GRAVITY_NORTHWEST, + WatermarkPosition::TopCenter => Imagick::GRAVITY_NORTH, + WatermarkPosition::TopRight => Imagick::GRAVITY_NORTHEAST, + WatermarkPosition::CenterLeft => Imagick::GRAVITY_WEST, + WatermarkPosition::Center => Imagick::GRAVITY_CENTER, + WatermarkPosition::CenterRight => Imagick::GRAVITY_EAST, + WatermarkPosition::BottomLeft => Imagick::GRAVITY_SOUTHWEST, + WatermarkPosition::BottomCenter => Imagick::GRAVITY_SOUTH, + WatermarkPosition::BottomRight => Imagick::GRAVITY_SOUTHEAST, + WatermarkPosition::Tile => Imagick::GRAVITY_CENTER, + }; + } +} diff --git a/src/Effect/WatermarkPosition.php b/src/Effect/WatermarkPosition.php new file mode 100644 index 0000000..41c3299 --- /dev/null +++ b/src/Effect/WatermarkPosition.php @@ -0,0 +1,19 @@ + 0.0, 1.0 = no change) + */ + public function __construct( + public readonly float $gamma = 1.0, + ) {} +} diff --git a/src/Filter/Grayscale.php b/src/Filter/Grayscale.php new file mode 100644 index 0000000..a8e9138 --- /dev/null +++ b/src/Filter/Grayscale.php @@ -0,0 +1,7 @@ + 'image/png', + self::JPEG => 'image/jpeg', + self::WebP => 'image/webp', + self::AVIF => 'image/avif', + self::GIF => 'image/gif', + self::TIFF => 'image/tiff', + self::BMP => 'image/bmp', + self::SVG => 'image/svg+xml', + }; + } + + public function fileExtension(): string + { + return match ($this) { + self::JPEG => 'jpg', + default => $this->value, + }; + } +} diff --git a/src/FormatException.php b/src/FormatException.php new file mode 100644 index 0000000..b7483c6 --- /dev/null +++ b/src/FormatException.php @@ -0,0 +1,7 @@ +a * $other->a + $this->b * $other->c, + $this->a * $other->b + $this->b * $other->d, + $this->c * $other->a + $this->d * $other->c, + $this->c * $other->b + $this->d * $other->d, + $this->e * $other->a + $this->f * $other->c + $other->e, + $this->e * $other->b + $this->f * $other->d + $other->f, + ); + } +} diff --git a/src/Geometry/Point.php b/src/Geometry/Point.php new file mode 100644 index 0000000..5bfcd60 --- /dev/null +++ b/src/Geometry/Point.php @@ -0,0 +1,18 @@ +x + $dx, $this->y + $dy); + } +} diff --git a/src/Geometry/Rectangle.php b/src/Geometry/Rectangle.php new file mode 100644 index 0000000..64ba6b1 --- /dev/null +++ b/src/Geometry/Rectangle.php @@ -0,0 +1,31 @@ +origin->x + $this->size->width; + } + + public function bottom(): float + { + return $this->origin->y + $this->size->height; + } +} diff --git a/src/Geometry/Size.php b/src/Geometry/Size.php new file mode 100644 index 0000000..79370e1 --- /dev/null +++ b/src/Geometry/Size.php @@ -0,0 +1,39 @@ +width * $factor, $this->height * $factor); + } + + public function fitWithin(self $bounds): self + { + $ratio = min($bounds->width / $this->width, $bounds->height / $this->height); + if ($ratio >= 1.0) { + return $this; + } + return new self( + round($this->width * $ratio), + round($this->height * $ratio), + ); + } + + public function cover(self $bounds): self + { + $ratio = max($bounds->width / $this->width, $bounds->height / $this->height); + return new self( + round($this->width * $ratio), + round($this->height * $ratio), + ); + } +} diff --git a/src/ImageException.php b/src/ImageException.php new file mode 100644 index 0000000..d0533bd --- /dev/null +++ b/src/ImageException.php @@ -0,0 +1,9 @@ +driver->load($data, $options); + } + + public function loadFile(string $path, DecodeOptions $options = new DecodeOptions()): ImageResource + { + return $this->driver->loadFile($path, $options); + } + + public function create(Size $size, Color $background): ImageResource + { + return $this->driver->create($size, $background); + } + + public function encode(ImageResource $image, ImageFormat $format, EncodeOptions $options = new EncodeOptions()): string + { + return $this->driver->encode($image, $format, $options); + } + + public function supports(ImageFormat $format): bool + { + return $this->driver->supports($format); + } + + public function driver(): ImageDriver + { + return $this->driver; + } + + public function loadSequence(string $data, DecodeOptions $options = new DecodeOptions()): ImageSequence + { + return $this->sequenceDriver()->loadSequence($data, $options); + } + + public function loadFileSequence(string $path, DecodeOptions $options = new DecodeOptions()): ImageSequence + { + return $this->sequenceDriver()->loadFileSequence($path, $options); + } + + public function encodeSequence( + ImageSequence $sequence, + ImageFormat $format, + EncodeOptions $options = new EncodeOptions(), + AnimationOptions $animation = new AnimationOptions(), + ): string { + return $this->sequenceDriver()->encodeSequence($sequence, $format, $options, $animation); + } + + private function sequenceDriver(): SequenceDriver + { + if (!$this->driver instanceof SequenceDriver) { + throw new DriverException('Sequence operations require a SequenceDriver (e.g. ImagickDriver)'); + } + + return $this->driver; + } +} diff --git a/src/Metadata/FieldType.php b/src/Metadata/FieldType.php new file mode 100644 index 0000000..775c596 --- /dev/null +++ b/src/Metadata/FieldType.php @@ -0,0 +1,14 @@ + $this->latitude, + 'longitude' => $this->longitude, + 'altitude' => $this->altitude, + ]; + } +} diff --git a/src/Metadata/ImageMetadata.php b/src/Metadata/ImageMetadata.php new file mode 100644 index 0000000..80d2fc9 --- /dev/null +++ b/src/Metadata/ImageMetadata.php @@ -0,0 +1,111 @@ + $data + */ + public function __construct( + private readonly array $data = [], + ) {} + + public function get(MetadataField $field): mixed + { + return $this->data[$field->value] ?? null; + } + + public function has(MetadataField $field): bool + { + return isset($this->data[$field->value]); + } + + /** + * @return array + */ + public function all(): array + { + return $this->data; + } + + public function gps(): ?GpsCoordinate + { + $lat = $this->get(MetadataField::GPSLatitude); + $lon = $this->get(MetadataField::GPSLongitude); + + if ($lat === null || $lon === null) { + return null; + } + + return new GpsCoordinate((float) $lat, (float) $lon); + } + + public function dateOriginal(): ?DateTimeImmutable + { + $ts = $this->get(MetadataField::DateTimeOriginal); + if ($ts === null) { + return null; + } + + if (is_int($ts)) { + return (new DateTimeImmutable())->setTimestamp($ts); + } + + $dt = DateTimeImmutable::createFromFormat('Y:m:d H:i:s', (string) $ts); + return $dt ?: null; + } + + public function camera(): ?string + { + $make = $this->get(MetadataField::Make); + $model = $this->get(MetadataField::Model); + + if ($make === null && $model === null) { + return null; + } + + if ($make !== null && $model !== null) { + if (str_starts_with((string) $model, (string) $make)) { + return (string) $model; + } + return trim($make . ' ' . $model); + } + + return (string) ($make ?? $model); + } + + public function title(): ?string + { + foreach (MetadataField::titleFields() as $field) { + $val = $this->get($field); + if ($val !== null && $val !== '') { + return (string) $val; + } + } + return null; + } + + public function description(): ?string + { + foreach (MetadataField::descriptionFields() as $field) { + $val = $this->get($field); + if ($val !== null && $val !== '') { + return (string) $val; + } + } + return null; + } + + /** + * @param array $data + */ + public function merge(array $data): self + { + return new self(array_merge($this->data, $data)); + } +} diff --git a/src/Metadata/MetadataCategory.php b/src/Metadata/MetadataCategory.php new file mode 100644 index 0000000..a556392 --- /dev/null +++ b/src/Metadata/MetadataCategory.php @@ -0,0 +1,13 @@ + MetadataCategory::Iptc, + + self::Creator, self::Rights, self::UsageTerms, + self::Title, self::Description + => MetadataCategory::Xmp, + + self::LensID, self::Lens, self::Aperture, + self::DOF, self::FOV + => MetadataCategory::Composite, + + default => MetadataCategory::Exif, + }; + } + + public function type(): FieldType + { + return match ($this) { + self::Keywords => FieldType::Array_, + + self::DateTime, self::DateTimeOriginal, self::DateTimeDigitized + => FieldType::Date, + + self::GPSLatitude, self::GPSLongitude => FieldType::Gps, + + self::FileSize, self::ExifImageWidth, self::ExifImageLength, + self::XResolution, self::YResolution, self::ShutterSpeedValue, + self::ExposureTime, self::FocalLength, self::FocalLengthIn35mmFilm, + self::ApertureValue, self::FNumber, self::ISOSpeedRatings, + self::ExposureBiasValue, self::ExposureMode, self::ExposureProgram, + self::MeteringMode, self::Flash, self::ColorSpace, + self::SensingMethod, self::WhiteBalance, self::Orientation, + self::LightSource, self::SceneCaptureType + => FieldType::Number, + + default => FieldType::Text, + }; + } + + /** + * @return list + */ + public static function forCategory(MetadataCategory $category): array + { + $fields = []; + foreach (self::cases() as $field) { + if ($field->category() === $category) { + $fields[] = $field; + } + } + return $fields; + } + + /** + * @return list + */ + public static function titleFields(): array + { + return [self::ObjectName, self::Title]; + } + + /** + * @return list + */ + public static function descriptionFields(): array + { + return [self::CaptionAbstract, self::Description, self::ImageDescription]; + } +} diff --git a/src/Metadata/MetadataFormatter.php b/src/Metadata/MetadataFormatter.php new file mode 100644 index 0000000..6cabb6a --- /dev/null +++ b/src/Metadata/MetadataFormatter.php @@ -0,0 +1,360 @@ + self::formatExposureMode($data), + MetadataField::ExposureProgram => self::formatExposureProgram($data), + MetadataField::XResolution, + MetadataField::YResolution => self::formatResolution($data), + MetadataField::ResolutionUnit => self::formatResolutionUnit($data), + MetadataField::ExifImageWidth, + MetadataField::ExifImageLength => $data . ' pixels', + MetadataField::Orientation => self::formatOrientation($data), + MetadataField::ExposureTime => self::formatExposureTime($data), + MetadataField::ShutterSpeedValue => self::formatShutterSpeed($data), + MetadataField::ApertureValue => self::formatAperture($data), + MetadataField::FocalLength => self::formatFocalLength($data), + MetadataField::FocalLengthIn35mmFilm => $data . ' mm', + MetadataField::FNumber => self::formatFNumber($data), + MetadataField::ExposureBiasValue => self::formatExposureBias($data), + MetadataField::MeteringMode => self::formatMeteringMode($data), + MetadataField::LightSource => self::formatLightSource($data), + MetadataField::WhiteBalance => self::formatWhiteBalance($data), + MetadataField::Flash => self::formatFlash($data), + MetadataField::FileSize => self::formatFileSize($data), + MetadataField::SensingMethod => self::formatSensingMethod($data), + MetadataField::ColorSpace => self::formatColorSpace($data), + MetadataField::SceneCaptureType => self::formatSceneCaptureType($data), + MetadataField::DateTime, + MetadataField::DateTimeOriginal, + MetadataField::DateTimeDigitized => self::formatDate($data), + default => (string) $data, + }; + } + + private static function formatExposureMode(mixed $data): string + { + return match ((int) $data) { + 0 => 'Auto exposure', + 1 => 'Manual exposure', + 2 => 'Auto bracket', + default => 'Unknown', + }; + } + + private static function formatExposureProgram(mixed $data): string + { + return match ((int) $data) { + 1 => 'Manual', + 2 => 'Normal Program', + 3 => 'Aperture Priority', + 4 => 'Shutter Priority', + 5 => 'Creative', + 6 => 'Action', + 7 => 'Portrait', + 8 => 'Landscape', + default => 'Unknown', + }; + } + + private static function formatResolution(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n, $d] = explode('/', $data, 2); + return (int) $n . ' dots per unit'; + } + return $data . ' per unit'; + } + + private static function formatResolutionUnit(mixed $data): string + { + return match ((int) $data) { + 1 => 'Pixels', + 2 => 'Inch', + 3 => 'Centimeter', + default => 'Unknown', + }; + } + + private static function formatOrientation(mixed $data): string + { + return match ((int) $data) { + 1 => 'Normal (0 deg)', + 2 => 'Mirrored', + 3 => 'Upside down', + 4 => 'Upside down Mirrored', + 5 => '90 deg CW Mirrored', + 6 => '90 deg CCW', + 7 => '90 deg CCW Mirrored', + 8 => '90 deg CW', + default => 'Unknown', + }; + } + + private static function formatExposureTime(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n, $d] = explode('/', $data, 2); + if ((int) $d === 0) { + return 'Unknown'; + } + $data = (float) $n / (float) $d; + } + return self::formatExposure((float) $data); + } + + private static function formatShutterSpeed(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n, $d] = explode('/', $data, 2); + if ((int) $d === 0) { + return 'Unknown'; + } + $data = (float) $n / (float) $d; + } + $data = exp((float) $data * log(2)); + if ($data > 0) { + $data = 1.0 / $data; + } + return self::formatExposure($data); + } + + private static function formatExposure(float $data): string + { + if ($data > 0) { + if ($data > 1) { + return round($data, 2) . ' sec'; + } + $n = 0; + $d = 0; + self::convertToFraction($data, $n, $d); + if ($n !== 1) { + return sprintf('%.4f sec', $n / $d); + } + return $n . '/' . $d . ' sec'; + } + return 'Bulb'; + } + + private static function convertToFraction(float $v, int &$n, int &$d): void + { + $maxTerms = 15; + $minDivisor = 0.000001; + $maxError = 0.00000001; + + $f = $v; + $nUn = 1; + $dUn = 0; + $nDeux = 0; + $dDeux = 1; + + for ($i = 0; $i < $maxTerms; $i++) { + $a = (int) floor($f); + $f = $f - $a; + $n = $nUn * $a + $nDeux; + $d = $dUn * $a + $dDeux; + $nDeux = $nUn; + $dDeux = $dUn; + $nUn = $n; + $dUn = $d; + + if ($f < $minDivisor) { + break; + } + if (abs($v - $n / $d) < $maxError) { + break; + } + $f = 1.0 / $f; + } + } + + private static function formatAperture(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n, $d] = explode('/', $data, 2); + if ((int) $d === 0) { + return 'Unknown'; + } + $data = (float) $n / (float) $d; + $data = exp(($data * log(2)) / 2); + $data = round($data, 1); + } + return 'f/' . $data; + } + + private static function formatFocalLength(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n, $d] = explode('/', $data, 2); + if ((int) $d === 0) { + return 'Unknown'; + } + return round((float) $n / (float) $d) . ' mm'; + } + return $data . ' mm'; + } + + private static function formatFNumber(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n, $d] = explode('/', $data, 2); + if ((int) $d !== 0) { + return 'f/' . round((float) $n / (float) $d, 1); + } + } + return 'f/' . $data; + } + + private static function formatExposureBias(mixed $data): string + { + if (is_string($data) && str_contains($data, '/')) { + [$n] = explode('/', $data, 2); + if ((int) $n === 0) { + return '0 EV'; + } + } + return $data . ' EV'; + } + + private static function formatMeteringMode(mixed $data): string + { + return match ((int) $data) { + 0 => 'Unknown', + 1 => 'Average', + 2 => 'Center Weighted Average', + 3 => 'Spot', + 4 => 'Multi-Spot', + 5 => 'Multi-Segment', + 6 => 'Partial', + 255 => 'Other', + default => 'Unknown: ' . $data, + }; + } + + private static function formatLightSource(mixed $data): string + { + return match ((int) $data) { + 0 => 'Unknown', + 1 => 'Daylight', + 2 => 'Fluorescent', + 3 => 'Tungsten', + 4 => 'Flash', + 9 => 'Fine weather', + 10 => 'Cloudy weather', + 11 => 'Shade', + 12 => 'Daylight fluorescent', + 13 => 'Day white fluorescent', + 14 => 'Cool white fluorescent', + 15 => 'White fluorescent', + 17 => 'Standard light A', + 18 => 'Standard light B', + 19 => 'Standard light C', + 20 => 'D55', + 21 => 'D65', + 22 => 'D75', + 23 => 'D50', + 24 => 'ISO studio tungsten', + 255 => 'Other', + default => 'Unknown', + }; + } + + private static function formatWhiteBalance(mixed $data): string + { + return match ((int) $data) { + 0 => 'Auto', + 1 => 'Manual', + default => 'Unknown', + }; + } + + private static function formatFlash(mixed $data): string + { + return match ((int) $data) { + 0, 16, 24, 32 => 'No Flash', + 1 => 'Flash', + 5 => 'Flash, strobe return light not detected', + 7 => 'Flash, strobe return light detected', + 9 => 'Compulsory Flash', + 13 => 'Compulsory Flash, Return light not detected', + 15 => 'Compulsory Flash, Return light detected', + 25 => 'Flash, Auto-Mode', + 29 => 'Flash, Auto-Mode, Return light not detected', + 31 => 'Flash, Auto-Mode, Return light detected', + 65 => 'Red Eye', + 69 => 'Red Eye, Return light not detected', + 71 => 'Red Eye, Return light detected', + 73 => 'Red Eye, Compulsory Flash', + 77 => 'Red Eye, Compulsory Flash, Return light not detected', + 79 => 'Red Eye, Compulsory Flash, Return light detected', + 89 => 'Red Eye, Auto-Mode', + 93 => 'Red Eye, Auto-Mode, Return light not detected', + 95 => 'Red Eye, Auto-Mode, Return light detected', + default => 'Unknown', + }; + } + + private static function formatFileSize(mixed $data): string + { + $data = (int) $data; + if ($data <= 0) { + return '0 B'; + } + $units = ['B', 'kB', 'MB', 'GB']; + $exp = (int) floor(log($data, 1024)); + $exp = min($exp, count($units) - 1); + return round($data / pow(1024, $exp), 2) . ' ' . $units[$exp]; + } + + private static function formatSensingMethod(mixed $data): string + { + return match ((int) $data) { + 1 => 'Not defined', + 2 => 'One Chip Color Area Sensor', + 3 => 'Two Chip Color Area Sensor', + 4 => 'Three Chip Color Area Sensor', + 5 => 'Color Sequential Area Sensor', + 7 => 'Trilinear Sensor', + 8 => 'Color Sequential Linear Sensor', + default => 'Unknown', + }; + } + + private static function formatColorSpace(mixed $data): string + { + return match ((int) $data) { + 1 => 'sRGB', + default => 'Uncalibrated', + }; + } + + private static function formatSceneCaptureType(mixed $data): string + { + return match ((int) $data) { + 0 => 'Standard', + 1 => 'Landscape', + 2 => 'Portrait', + 3 => 'Night Scene', + default => 'Unknown', + }; + } + + private static function formatDate(mixed $data): string + { + if (is_int($data)) { + return date('Y-m-d H:i:s', $data); + } + return (string) $data; + } +} diff --git a/src/Metadata/MetadataReader.php b/src/Metadata/MetadataReader.php new file mode 100644 index 0000000..dda098c --- /dev/null +++ b/src/Metadata/MetadataReader.php @@ -0,0 +1,17 @@ + + */ + public function supportedCategories(): array; +} diff --git a/src/Metadata/Parser/CanonParser.php b/src/Metadata/Parser/CanonParser.php new file mode 100644 index 0000000..84eb564 --- /dev/null +++ b/src/Metadata/Parser/CanonParser.php @@ -0,0 +1,99 @@ +readShort($data, $offset, $intel); + $offset += 2; + + for ($i = 0; $i < $count && ($offset + 12) <= $length; $i++) { + $tag = $this->readShort($data, $offset, $intel); + $type = $this->readShort($data, $offset + 2, $intel); + $numValues = $this->readLong($data, $offset + 4, $intel); + $valueOffset = $offset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $offset + 8, $intel); + } + + $name = $this->tagName($tag); + if ($name !== null && $valueOffset + $byteCount <= $length) { + if ($type === 2) { + $result[$name] = rtrim(substr($data, $valueOffset, $numValues), "\x00"); + } elseif ($type === 3 && $numValues === 1) { + $result[$name] = $this->readShort($data, $valueOffset, $intel); + } elseif ($type === 4) { + $result[$name] = $this->readLong($data, $valueOffset, $intel); + } + } + + $offset += 12; + } + + return $result; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x0006 => 'Canon:ImageType', + 0x0007 => 'Canon:FirmwareVersion', + 0x0008 => 'Canon:ImageNumber', + 0x0009 => 'Canon:OwnerName', + 0x000C => 'Canon:SerialNumber', + 0x0095 => 'Canon:LensModel', + 0x0096 => 'Canon:InternalSerialNumber', + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } +} diff --git a/src/Metadata/Parser/FujifilmParser.php b/src/Metadata/Parser/FujifilmParser.php new file mode 100644 index 0000000..a3e667d --- /dev/null +++ b/src/Metadata/Parser/FujifilmParser.php @@ -0,0 +1,112 @@ + $length) { + return $result; + } + + $count = $this->readShort($data, $headerOffset, $intel); + $offset = $headerOffset + 2; + + for ($i = 0; $i < $count && ($offset + 12) <= $length; $i++) { + $tag = $this->readShort($data, $offset, $intel); + $type = $this->readShort($data, $offset + 2, $intel); + $numValues = $this->readLong($data, $offset + 4, $intel); + $valueOffset = $offset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $offset + 8, $intel); + } + + $name = $this->tagName($tag); + if ($name !== null && $valueOffset >= 0 && $valueOffset + min($byteCount, 256) <= $length) { + if ($type === 2) { + $result[$name] = rtrim(substr($data, $valueOffset, min($numValues, 256)), "\x00"); + } elseif ($type === 3 && $numValues === 1) { + $result[$name] = $this->readShort($data, $valueOffset, $intel); + } + } + + $offset += 12; + } + + return $result; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x0000 => 'Fuji:Version', + 0x1000 => 'Fuji:Quality', + 0x1001 => 'Fuji:Sharpness', + 0x1002 => 'Fuji:WhiteBalance', + 0x1003 => 'Fuji:Color', + 0x1004 => 'Fuji:Tone', + 0x1010 => 'Fuji:FlashMode', + 0x1011 => 'Fuji:FlashStrength', + 0x1020 => 'Fuji:Macro', + 0x1021 => 'Fuji:FocusMode', + 0x1030 => 'Fuji:SlowSync', + 0x1031 => 'Fuji:PictureMode', + 0x1100 => 'Fuji:ContCine', + 0x1300 => 'Fuji:BlurWarning', + 0x1301 => 'Fuji:FocusWarning', + 0x1302 => 'Fuji:AEWarning', + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } +} diff --git a/src/Metadata/Parser/GpsParser.php b/src/Metadata/Parser/GpsParser.php new file mode 100644 index 0000000..bb0a3d4 --- /dev/null +++ b/src/Metadata/Parser/GpsParser.php @@ -0,0 +1,122 @@ + $data Raw EXIF data containing GPS fields + */ + public static function parse(array $data): ?GpsCoordinate + { + $lat = self::parseCoordinate( + $data['GPSLatitude'] ?? null, + $data['GPSLatitudeRef'] ?? null, + ); + + $lon = self::parseCoordinate( + $data['GPSLongitude'] ?? null, + $data['GPSLongitudeRef'] ?? null, + ); + + if ($lat === null || $lon === null) { + return null; + } + + $altitude = self::parseAltitude( + $data['GPSAltitude'] ?? null, + $data['GPSAltitudeRef'] ?? null, + ); + + return new GpsCoordinate($lat, $lon, $altitude); + } + + /** + * Parse a GPS coordinate (latitude or longitude) to decimal degrees. + * + * Accepts: + * - Array of [degrees, minutes, seconds] (fractions like "dd/1") + * - Scalar decimal value + * - String "dd/1, mm/1, ss/1" format + */ + public static function parseCoordinate(mixed $value, ?string $ref = null): ?float + { + if ($value === null || $value === '' || $value === []) { + return null; + } + + if (is_string($value) && str_contains($value, ',')) { + $parts = array_map('trim', explode(',', $value)); + if (count($parts) === 3) { + $value = $parts; + } + } + + if (is_array($value)) { + if (count($value) < 3) { + return null; + } + + $degrees = self::parseFraction($value[0]); + $minutes = self::parseFraction($value[1]); + $seconds = self::parseFraction($value[2]); + + if ($degrees == 0 && $minutes == 0 && $seconds == 0) { + return null; + } + + $decimal = $degrees + ($minutes / 60) + ($seconds / 3600); + } else { + $decimal = (float) $value; + if ($decimal == 0.0) { + return null; + } + } + + $decimal = round($decimal, 6); + + if ($ref !== null && in_array($ref, ['S', 'South', 'W', 'West'], true)) { + $decimal = -abs($decimal); + } + + return $decimal; + } + + private static function parseAltitude(mixed $value, ?string $ref): ?float + { + if ($value === null || $value === '') { + return null; + } + + $altitude = self::parseFraction($value); + + if ($ref === '1') { + $altitude = -$altitude; + } + + return $altitude; + } + + public static function parseFraction(mixed $value): float + { + if (is_string($value) && str_contains($value, '/')) { + $parts = explode('/', $value, 2); + if (count($parts) === 2 && (float) $parts[1] !== 0.0) { + return (float) $parts[0] / (float) $parts[1]; + } + return (float) $parts[0]; + } + return (float) $value; + } + + public static function degToDecimal(float $degrees, float $minutes, float $seconds): float + { + return round($degrees + ($minutes / 60) + ($seconds / 3600), 6); + } +} diff --git a/src/Metadata/Parser/MakerNoteParser.php b/src/Metadata/Parser/MakerNoteParser.php new file mode 100644 index 0000000..01bb76a --- /dev/null +++ b/src/Metadata/Parser/MakerNoteParser.php @@ -0,0 +1,15 @@ + + */ + public function parse(string $data, bool $intel): array; +} diff --git a/src/Metadata/Parser/NikonParser.php b/src/Metadata/Parser/NikonParser.php new file mode 100644 index 0000000..c1c3483 --- /dev/null +++ b/src/Metadata/Parser/NikonParser.php @@ -0,0 +1,125 @@ + 18) { + $headerOffset = 10; + $byteOrder = substr($data, 10, 2); + $intel = ($byteOrder === "II"); + $headerOffset = 18; + } else { + $headerOffset = 8; + } + } + + if ($headerOffset + 2 > $length) { + return $result; + } + + $count = $this->readShort($data, $headerOffset, $intel); + $offset = $headerOffset + 2; + + for ($i = 0; $i < $count && ($offset + 12) <= $length; $i++) { + $tag = $this->readShort($data, $offset, $intel); + $type = $this->readShort($data, $offset + 2, $intel); + $numValues = $this->readLong($data, $offset + 4, $intel); + $valueOffset = $offset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $offset + 8, $intel) + $headerOffset; + } + + $name = $this->tagName($tag); + if ($name !== null && $valueOffset >= 0 && $valueOffset + min($byteCount, 256) <= $length) { + if ($type === 2) { + $result[$name] = rtrim(substr($data, $valueOffset, min($numValues, 256)), "\x00"); + } elseif ($type === 3 && $numValues === 1) { + $result[$name] = $this->readShort($data, $valueOffset, $intel); + } elseif ($type === 4 && $numValues === 1) { + $result[$name] = $this->readLong($data, $valueOffset, $intel); + } + } + + $offset += 12; + } + + return $result; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x0001 => 'Nikon:MakerNoteVersion', + 0x0002 => 'Nikon:ISO', + 0x0004 => 'Nikon:Quality', + 0x0005 => 'Nikon:WhiteBalance', + 0x0007 => 'Nikon:FocusMode', + 0x0008 => 'Nikon:FlashSetting', + 0x0009 => 'Nikon:FlashType', + 0x000D => 'Nikon:ProgramShift', + 0x000E => 'Nikon:ExposureDifference', + 0x0084 => 'Nikon:Lens', + 0x0087 => 'Nikon:FlashMode', + 0x008B => 'Nikon:LensFStops', + 0x0095 => 'Nikon:NoiseReduction', + 0x00A7 => 'Nikon:ShutterCount', + 0x00A9 => 'Nikon:ImageOptimization', + 0x00AA => 'Nikon:Saturation', + 0x00AB => 'Nikon:VariProgram', + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } +} diff --git a/src/Metadata/Parser/OlympusParser.php b/src/Metadata/Parser/OlympusParser.php new file mode 100644 index 0000000..3fe3537 --- /dev/null +++ b/src/Metadata/Parser/OlympusParser.php @@ -0,0 +1,102 @@ + $length) { + return $result; + } + + $count = $this->readShort($data, $headerOffset, $intel); + $offset = $headerOffset + 2; + + for ($i = 0; $i < $count && ($offset + 12) <= $length; $i++) { + $tag = $this->readShort($data, $offset, $intel); + $type = $this->readShort($data, $offset + 2, $intel); + $numValues = $this->readLong($data, $offset + 4, $intel); + $valueOffset = $offset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $offset + 8, $intel) + $headerOffset; + } + + $name = $this->tagName($tag); + if ($name !== null && $valueOffset >= 0 && $valueOffset + min($byteCount, 256) <= $length) { + if ($type === 2) { + $result[$name] = rtrim(substr($data, $valueOffset, min($numValues, 256)), "\x00"); + } elseif ($type === 3 && $numValues === 1) { + $result[$name] = $this->readShort($data, $valueOffset, $intel); + } + } + + $offset += 12; + } + + return $result; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x0200 => 'Olympus:SpecialMode', + 0x0201 => 'Olympus:Quality', + 0x0202 => 'Olympus:Macro', + 0x0204 => 'Olympus:DigitalZoom', + 0x0207 => 'Olympus:SoftwareRelease', + 0x0209 => 'Olympus:CameraID', + 0x020B => 'Olympus:DataDump', + 0x0F00 => 'Olympus:DataDump2', + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } +} diff --git a/src/Metadata/Parser/PanasonicParser.php b/src/Metadata/Parser/PanasonicParser.php new file mode 100644 index 0000000..2705dc5 --- /dev/null +++ b/src/Metadata/Parser/PanasonicParser.php @@ -0,0 +1,114 @@ + $length) { + return $result; + } + + $count = $this->readShort($data, $headerOffset, $intel); + $offset = $headerOffset + 2; + + for ($i = 0; $i < $count && ($offset + 12) <= $length; $i++) { + $tag = $this->readShort($data, $offset, $intel); + $type = $this->readShort($data, $offset + 2, $intel); + $numValues = $this->readLong($data, $offset + 4, $intel); + $valueOffset = $offset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $offset + 8, $intel) + $headerOffset; + } + + $name = $this->tagName($tag); + if ($name !== null && $valueOffset >= 0 && $valueOffset + min($byteCount, 256) <= $length) { + if ($type === 2) { + $result[$name] = rtrim(substr($data, $valueOffset, min($numValues, 256)), "\x00"); + } elseif ($type === 3 && $numValues === 1) { + $result[$name] = $this->readShort($data, $valueOffset, $intel); + } + } + + $offset += 12; + } + + return $result; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x0001 => 'Panasonic:Quality', + 0x0003 => 'Panasonic:WhiteBalance', + 0x0007 => 'Panasonic:FocusMode', + 0x000F => 'Panasonic:AFMode', + 0x001A => 'Panasonic:ImageStabilization', + 0x001C => 'Panasonic:Macro', + 0x001F => 'Panasonic:ShootingMode', + 0x0020 => 'Panasonic:Audio', + 0x0023 => 'Panasonic:WhiteBalanceBias', + 0x0024 => 'Panasonic:FlashBias', + 0x0025 => 'Panasonic:InternalSerialNumber', + 0x0028 => 'Panasonic:ColorEffect', + 0x002A => 'Panasonic:BurstMode', + 0x002C => 'Panasonic:Contrast', + 0x002D => 'Panasonic:NoiseReduction', + 0x002E => 'Panasonic:SelfTimer', + 0x0030 => 'Panasonic:Rotation', + 0x0032 => 'Panasonic:ColorMode', + 0x0051 => 'Panasonic:LensType', + 0x0052 => 'Panasonic:LensSerialNumber', + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } +} diff --git a/src/Metadata/Parser/SanyoParser.php b/src/Metadata/Parser/SanyoParser.php new file mode 100644 index 0000000..67a8a5a --- /dev/null +++ b/src/Metadata/Parser/SanyoParser.php @@ -0,0 +1,95 @@ +readShort($data, $headerOffset, $intel); + $offset = $headerOffset + 2; + + for ($i = 0; $i < $count && ($offset + 12) <= $length; $i++) { + $tag = $this->readShort($data, $offset, $intel); + $type = $this->readShort($data, $offset + 2, $intel); + $numValues = $this->readLong($data, $offset + 4, $intel); + $valueOffset = $offset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $offset + 8, $intel); + } + + $name = $this->tagName($tag); + if ($name !== null && $valueOffset >= 0 && $valueOffset + min($byteCount, 256) <= $length) { + if ($type === 2) { + $result[$name] = rtrim(substr($data, $valueOffset, min($numValues, 256)), "\x00"); + } elseif ($type === 3 && $numValues === 1) { + $result[$name] = $this->readShort($data, $valueOffset, $intel); + } + } + + $offset += 12; + } + + return $result; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x0100 => 'Sanyo:ThumbnailOffset', + 0x0200 => 'Sanyo:SpecialMode', + 0x0201 => 'Sanyo:Quality', + 0x0202 => 'Sanyo:Macro', + 0x0204 => 'Sanyo:DigitalZoom', + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } +} diff --git a/src/Metadata/Reader/BundledReader.php b/src/Metadata/Reader/BundledReader.php new file mode 100644 index 0000000..5cb4d13 --- /dev/null +++ b/src/Metadata/Reader/BundledReader.php @@ -0,0 +1,499 @@ + */ + private readonly array $makerNoteParsers; + + /** + * @param list $makerNoteParsers + */ + public function __construct(array $makerNoteParsers = []) + { + $this->makerNoteParsers = $makerNoteParsers; + } + + public function readFile(string $path): ImageMetadata + { + $stream = @fopen($path, 'rb'); + if ($stream === false) { + return new ImageMetadata(); + } + + try { + $raw = $this->parseJpeg($stream); + $raw['FileSize'] = filesize($path) ?: 0; + return new ImageMetadata($this->processRaw($raw)); + } finally { + fclose($stream); + } + } + + public function readData(string $data): ImageMetadata + { + $stream = fopen('php://memory', 'r+b'); + if ($stream === false) { + return new ImageMetadata(); + } + + fwrite($stream, $data); + rewind($stream); + + try { + $raw = $this->parseJpeg($stream); + $raw['FileSize'] = strlen($data); + return new ImageMetadata($this->processRaw($raw)); + } finally { + fclose($stream); + } + } + + public function supportedCategories(): array + { + return [MetadataCategory::Exif]; + } + + /** + * @param resource $stream + * @return array + */ + private function parseJpeg($stream): array + { + $header = fread($stream, 2); + if ($header === false || bin2hex($header) !== 'ffd8') { + return []; + } + + while (!feof($stream)) { + $marker = fread($stream, 2); + if ($marker === false || strlen($marker) < 2) { + break; + } + + $markerHex = bin2hex($marker); + + if ($markerHex === 'ffd9' || $markerHex === 'ffc0') { + break; + } + + $sizeBytes = fread($stream, 2); + if ($sizeBytes === false || strlen($sizeBytes) < 2) { + break; + } + $unpacked = unpack('n', $sizeBytes); + $size = $unpacked !== false ? $unpacked[1] : 0; + + if ($markerHex === 'ffe1') { + $readLen = $size - 2; + if ($readLen < 1) { + return []; + } + $exifData = fread($stream, $readLen); + if ($exifData !== false && str_starts_with($exifData, "Exif\x00\x00")) { + return $this->parseTiff(substr($exifData, 6)); + } + return []; + } + + fseek($stream, $size - 2, SEEK_CUR); + } + + return []; + } + + /** + * @return array + */ + private function parseTiff(string $data): array + { + if (strlen($data) < 8) { + return []; + } + + $byteOrder = substr($data, 0, 2); + $intel = ($byteOrder === "II"); + + $magic = $this->readShort($data, 2, $intel); + if ($magic !== 0x002A) { + return []; + } + + $ifd0Offset = $this->readLong($data, 4, $intel); + $result = []; + + $ifd0 = $this->readIfd($data, $ifd0Offset, $intel); + $result = array_merge($result, $ifd0); + + if (isset($ifd0['ExifIFDPointer'])) { + $subIfd = $this->readIfd($data, (int) $ifd0['ExifIFDPointer'], $intel); + unset($result['ExifIFDPointer']); + $result = array_merge($result, $subIfd); + + if (isset($subIfd['MakerNote']) && isset($result['Make'])) { + $makerData = $this->parseMakerNote((string) $result['Make'], $subIfd['MakerNote'], $intel); + $result = array_merge($result, $makerData); + unset($result['MakerNote']); + } + } + + if (isset($ifd0['GPSInfoIFDPointer'])) { + $gpsIfd = $this->readIfd($data, (int) $ifd0['GPSInfoIFDPointer'], $intel); + unset($result['GPSInfoIFDPointer']); + $this->processGpsIfd($gpsIfd, $result); + } + + return $result; + } + + /** + * @return array + */ + private function readIfd(string $data, int $offset, bool $intel): array + { + if ($offset + 2 > strlen($data)) { + return []; + } + + $count = $this->readShort($data, $offset, $intel); + $result = []; + + for ($i = 0; $i < $count; $i++) { + $entryOffset = $offset + 2 + ($i * 12); + if ($entryOffset + 12 > strlen($data)) { + break; + } + + $tag = $this->readShort($data, $entryOffset, $intel); + $type = $this->readShort($data, $entryOffset + 2, $intel); + $numValues = $this->readLong($data, $entryOffset + 4, $intel); + $valueOffset = $entryOffset + 8; + + $byteCount = $this->typeSize($type) * $numValues; + if ($byteCount > 4) { + $valueOffset = $this->readLong($data, $entryOffset + 8, $intel); + } + + if ($valueOffset + $byteCount > strlen($data)) { + continue; + } + + $name = $this->tagName($tag); + $value = $this->readValue($data, $valueOffset, $type, $numValues, $intel); + + if ($name !== null) { + $result[$name] = $value; + } + } + + return $result; + } + + private function readValue(string $data, int $offset, int $type, int $count, bool $intel): mixed + { + return match ($type) { + 1 => ord($data[$offset]), // UBYTE + 2 => rtrim(substr($data, $offset, $count), "\x00"), // ASCII + 3 => $count === 1 + ? $this->readShort($data, $offset, $intel) + : $this->readShortArray($data, $offset, $count, $intel), + 4 => $this->readLong($data, $offset, $intel), // ULONG + 5 => $count === 1 + ? $this->readRational($data, $offset, $intel) + : $this->readRationalArray($data, $offset, $count, $intel), // URATIONAL + 6 => $this->readSignedByte($data, $offset), // SBYTE + 7 => substr($data, $offset, $count), // UNDEFINED + 8 => $this->readSignedShort($data, $offset, $intel), // SSHORT + 9 => $this->readSignedLong($data, $offset, $intel), // SLONG + 10 => $this->readSignedRational($data, $offset, $intel), // SRATIONAL + default => null, + }; + } + + private function readShort(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 2); + if (strlen($bytes) < 2) { + return 0; + } + $unpacked = $intel ? unpack('v', $bytes) : unpack('n', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + /** + * @return list + */ + private function readShortArray(string $data, int $offset, int $count, bool $intel): array + { + $values = []; + for ($i = 0; $i < $count; $i++) { + $values[] = $this->readShort($data, $offset + ($i * 2), $intel); + } + return $values; + } + + /** + * @return list + */ + private function readRationalArray(string $data, int $offset, int $count, bool $intel): array + { + $values = []; + for ($i = 0; $i < $count; $i++) { + $values[] = $this->readRational($data, $offset + ($i * 8), $intel); + } + return $values; + } + + private function readLong(string $data, int $offset, bool $intel): int + { + $bytes = substr($data, $offset, 4); + if (strlen($bytes) < 4) { + return 0; + } + $unpacked = $intel ? unpack('V', $bytes) : unpack('N', $bytes); + return $unpacked !== false ? $unpacked[1] : 0; + } + + private function readRational(string $data, int $offset, bool $intel): string + { + $num = $this->readLong($data, $offset, $intel); + $den = $this->readLong($data, $offset + 4, $intel); + if ($den === 0) { + return '0'; + } + return $num . '/' . $den; + } + + private function readSignedByte(string $data, int $offset): int + { + $val = ord($data[$offset]); + return $val > 127 ? $val - 256 : $val; + } + + private function readSignedShort(string $data, int $offset, bool $intel): int + { + $val = $this->readShort($data, $offset, $intel); + return $val > 32767 ? $val - 65536 : $val; + } + + private function readSignedLong(string $data, int $offset, bool $intel): int + { + $val = $this->readLong($data, $offset, $intel); + return $val > 2147483647 ? $val - 4294967296 : $val; + } + + private function readSignedRational(string $data, int $offset, bool $intel): string + { + $num = $this->readSignedLong($data, $offset, $intel); + $den = $this->readSignedLong($data, $offset + 4, $intel); + if ($den === 0) { + return '0'; + } + return $num . '/' . $den; + } + + private function typeSize(int $type): int + { + return match ($type) { + 1, 2, 6, 7 => 1, + 3, 8 => 2, + 4, 9, 11 => 4, + 5, 10, 12 => 8, + default => 1, + }; + } + + private function tagName(int $tag): ?string + { + return match ($tag) { + 0x010F => 'Make', + 0x0110 => 'Model', + 0x0112 => 'Orientation', + 0x011A => 'XResolution', + 0x011B => 'YResolution', + 0x0128 => 'ResolutionUnit', + 0x0131 => 'Software', + 0x0132 => 'DateTime', + 0x013B => 'Artist', + 0x8298 => 'Copyright', + 0x8769 => 'ExifIFDPointer', + 0x8825 => 'GPSInfoIFDPointer', + + // SubIFD tags + 0x829A => 'ExposureTime', + 0x829D => 'FNumber', + 0x8822 => 'ExposureProgram', + 0x8827 => 'ISOSpeedRatings', + 0x9000 => 'ExifVersion', + 0x9003 => 'DateTimeOriginal', + 0x9004 => 'DateTimeDigitized', + 0x9201 => 'ShutterSpeedValue', + 0x9202 => 'ApertureValue', + 0x9204 => 'ExposureBiasValue', + 0x9207 => 'MeteringMode', + 0x9208 => 'LightSource', + 0x9209 => 'Flash', + 0x920A => 'FocalLength', + 0x9286 => 'UserComment', + 0xA001 => 'ColorSpace', + 0xA002 => 'ExifImageWidth', + 0xA003 => 'ExifImageLength', + 0xA210 => 'FocalPlaneResolutionUnit', + 0xA217 => 'SensingMethod', + 0xA401 => 'CustomRendered', + 0xA402 => 'ExposureMode', + 0xA403 => 'WhiteBalance', + 0xA405 => 'FocalLengthIn35mmFilm', + 0xA406 => 'SceneCaptureType', + 0x927C => 'MakerNote', + 0xA434 => 'Lens', + + // GPS tags + 0x0000 => 'GPSVersionID', + 0x0001 => 'GPSLatitudeRef', + 0x0002 => 'GPSLatitude', + 0x0003 => 'GPSLongitudeRef', + 0x0004 => 'GPSLongitude', + 0x0005 => 'GPSAltitudeRef', + 0x0006 => 'GPSAltitude', + + default => null, + }; + } + + /** + * @param array $gpsIfd + * @param array $result + */ + private function processGpsIfd(array $gpsIfd, array &$result): void + { + if (isset($gpsIfd['GPSLatitude']) && isset($gpsIfd['GPSLongitude'])) { + $latRef = $gpsIfd['GPSLatitudeRef'] ?? 'N'; + $lonRef = $gpsIfd['GPSLongitudeRef'] ?? 'E'; + + $lat = $this->parseGpsCoord($gpsIfd['GPSLatitude']); + $lon = $this->parseGpsCoord($gpsIfd['GPSLongitude']); + + if ($lat !== null) { + if (in_array($latRef, ['S', 'South'], true)) { + $lat = -abs($lat); + } + $result['GPSLatitude'] = $lat; + } + + if ($lon !== null) { + if (in_array($lonRef, ['W', 'West'], true)) { + $lon = -abs($lon); + } + $result['GPSLongitude'] = $lon; + } + } + } + + private function parseGpsCoord(mixed $value): ?float + { + if (is_array($value) && count($value) >= 3) { + $deg = GpsParser::parseFraction($value[0]); + $min = GpsParser::parseFraction($value[1]); + $sec = GpsParser::parseFraction($value[2]); + return GpsParser::degToDecimal($deg, $min, $sec); + } + + if (is_string($value)) { + return GpsParser::parseCoordinate($value); + } + + return is_numeric($value) ? (float) $value : null; + } + + /** + * @return array + */ + private function parseMakerNote(string $make, mixed $noteData, bool $intel): array + { + if (!is_string($noteData)) { + return []; + } + + $makeLower = strtolower(trim($make)); + foreach ($this->makerNoteParsers as $parser) { + if ($parser->supports($makeLower)) { + return $parser->parse($noteData, $intel); + } + } + + return []; + } + + /** + * @param array $raw + * @return array + */ + private function processRaw(array $raw): array + { + $results = []; + $supportedFields = MetadataField::forCategory(MetadataCategory::Exif); + + foreach ($supportedFields as $field) { + $key = $field->value; + $value = $raw[$key] ?? null; + + if ($value === null || $value === '') { + continue; + } + + if ($field->type() === FieldType::Gps) { + if (!is_numeric($value)) { + continue; + } + $results[$key] = (float) $value; + continue; + } + + if ($field->type() === FieldType::Date) { + if (is_string($value)) { + $parts = explode(' ', $value, 2); + if (count($parts) === 2) { + [$ymd, $hms] = $parts; + $dateParts = explode(':', $ymd, 3); + if (count($dateParts) === 3) { + [$year, $month, $day] = $dateParts; + $ts = strtotime("$month/$day/$year $hms"); + if ($ts !== false) { + $value = $ts; + } + } + } + } + } + + if (is_array($value)) { + $value = implode(',', array_map('strval', $value)); + } + + $results[$key] = $value; + } + + if (isset($raw['FileSize'])) { + $results['FileSize'] = (int) $raw['FileSize']; + } + + return $results; + } +} diff --git a/src/Metadata/Reader/ExiftoolReader.php b/src/Metadata/Reader/ExiftoolReader.php new file mode 100644 index 0000000..436b9ab --- /dev/null +++ b/src/Metadata/Reader/ExiftoolReader.php @@ -0,0 +1,168 @@ +exiftoolPath)) { + throw new DriverException('Exiftool binary not found or not executable: ' . $this->exiftoolPath); + } + } + + public function readFile(string $path): ImageMetadata + { + $tags = $this->buildTagArgs(); + $command = sprintf( + '%s -j %s %s', + escapeshellarg($this->exiftoolPath), + $tags, + escapeshellarg($path), + ); + + $output = []; + $retval = 0; + exec($command, $output, $retval); + + $json = implode('', $output); + $results = json_decode($json, true); + + if (!is_array($results) || $results === []) { + return new ImageMetadata(); + } + + $raw = (array) array_pop($results); + return new ImageMetadata($this->processData($raw)); + } + + public function readData(string $data): ImageMetadata + { + $tmp = tempnam(sys_get_temp_dir(), 'horde_exiftool_'); + if ($tmp === false) { + return new ImageMetadata(); + } + + try { + file_put_contents($tmp, $data); + return $this->readFile($tmp); + } finally { + @unlink($tmp); + } + } + + public function supportedCategories(): array + { + return [ + MetadataCategory::Exif, + MetadataCategory::Iptc, + MetadataCategory::Xmp, + MetadataCategory::Composite, + ]; + } + + private function buildTagArgs(): string + { + $tags = []; + foreach (MetadataField::cases() as $field) { + $suffix = $field->category() === MetadataCategory::Composite ? '' : '#'; + $tags[] = '-' . $field->value . $suffix; + } + return implode(' ', $tags); + } + + /** + * @param array $raw + * @return array + */ + private function processData(array $raw): array + { + $results = []; + + foreach (MetadataField::cases() as $field) { + $key = $field->value; + $value = $raw[$key] ?? ''; + + if ($value === '') { + continue; + } + + if ($field->type() === FieldType::Gps) { + $value = $this->parseGps($raw, $key); + if ($value === null) { + continue; + } + } elseif ($field->type() === FieldType::Date) { + $value = $this->parseDate($value); + if ($value === null) { + continue; + } + } elseif ($field->type() === FieldType::Array_) { + if (is_array($value)) { + $value = implode(',', $value); + } + } + + $results[$key] = $value; + } + + return $results; + } + + /** + * @param array $raw + */ + private function parseGps(array $raw, string $key): ?float + { + $value = $raw[$key] ?? null; + if ($value === null || $value === '') { + return null; + } + + $decimal = (float) $value; + + $ref = $raw[$key . 'Ref'] ?? ''; + if (in_array($ref, ['S', 'South', 'W', 'West'], true)) { + $decimal = -abs($decimal); + } + + return round($decimal, 6); + } + + private function parseDate(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + $value = (string) $value; + $parts = explode(' ', $value, 2); + if (count($parts) !== 2) { + return null; + } + + [$ymd, $hms] = $parts; + $dateParts = explode(':', $ymd, 3); + if (count($dateParts) !== 3) { + return null; + } + + [$year, $month, $day] = $dateParts; + if (empty($year) || empty($month) || empty($day)) { + return null; + } + + $ts = strtotime("$month/$day/$year $hms"); + return $ts !== false ? $ts : null; + } +} diff --git a/src/Metadata/Reader/ImagickReader.php b/src/Metadata/Reader/ImagickReader.php new file mode 100644 index 0000000..e756176 --- /dev/null +++ b/src/Metadata/Reader/ImagickReader.php @@ -0,0 +1,200 @@ +readImage($path); + } catch (ImagickException $e) { + return new ImageMetadata(); + } + + return $this->extract($imagick); + } + + public function readData(string $data): ImageMetadata + { + $imagick = new Imagick(); + + try { + $imagick->readImageBlob($data); + } catch (ImagickException $e) { + return new ImageMetadata(); + } + + return $this->extract($imagick); + } + + public function supportedCategories(): array + { + return [MetadataCategory::Exif, MetadataCategory::Iptc, MetadataCategory::Xmp]; + } + + private function extract(Imagick $imagick): ImageMetadata + { + $props = $imagick->getImageProperties(); + $results = []; + + foreach (MetadataField::cases() as $field) { + $category = $field->category(); + if (!in_array($category, $this->supportedCategories(), true)) { + continue; + } + + $value = $this->findProperty($props, $field); + if ($value === null || $value === '') { + continue; + } + + if ($field->type() === FieldType::Gps) { + $value = $this->parseGpsProperty($props, $field); + if ($value === null) { + continue; + } + } elseif ($field->type() === FieldType::Date) { + $value = $this->parseDate($value); + if ($value === null) { + continue; + } + } + + $results[$field->value] = $value; + } + + $imagick->clear(); + return new ImageMetadata($results); + } + + /** + * @param array $props + */ + private function findProperty(array $props, MetadataField $field): ?string + { + $key = $field->value; + + $prefixes = match ($field->category()) { + MetadataCategory::Exif => ['exif:', 'exif:thumbnail:', ''], + MetadataCategory::Iptc => ['iptc:', ''], + MetadataCategory::Xmp => ['xmp:', 'xmp-dc:', ''], + MetadataCategory::Composite => [''], + }; + + foreach ($prefixes as $prefix) { + $fullKey = $prefix . $key; + if (isset($props[$fullKey])) { + return $props[$fullKey]; + } + } + + $keyLower = strtolower($key); + foreach ($props as $propKey => $propValue) { + if (strtolower(basename(str_replace(':', '/', $propKey))) === $keyLower) { + return $propValue; + } + } + + return null; + } + + /** + * @param array $props + */ + private function parseGpsProperty(array $props, MetadataField $field): ?float + { + $value = $this->findProperty($props, $field); + if ($value === null) { + return null; + } + + $decimal = $this->parseDmsString($value); + if ($decimal === null) { + $decimal = (float) $value; + } + + $refKey = $field->value . 'Ref'; + $ref = $props['exif:' . $refKey] ?? $props[$refKey] ?? null; + if ($ref !== null && in_array($ref, ['S', 'South', 'W', 'West'], true)) { + $decimal = -abs($decimal); + } + + return round($decimal, 6); + } + + private function parseDmsString(string $value): ?float + { + if (preg_match('/^(\d+)[\/,]\s*(\d+)\s*(\d+)[\/,]\s*(\d+)\s*(\d+)[\/,]\s*(\d+)$/', $value, $m)) { + $deg = (int) $m[1] / max((int) $m[2], 1); + $min = (int) $m[3] / max((int) $m[4], 1); + $sec = (int) $m[5] / max((int) $m[6], 1); + return $deg + ($min / 60) + ($sec / 3600); + } + + if (str_contains($value, ',')) { + $parts = explode(',', $value); + if (count($parts) === 3) { + $deg = $this->parseFraction(trim($parts[0])); + $min = $this->parseFraction(trim($parts[1])); + $sec = $this->parseFraction(trim($parts[2])); + return $deg + ($min / 60) + ($sec / 3600); + } + } + + return null; + } + + private function parseFraction(string $value): float + { + if (str_contains($value, '/')) { + $parts = explode('/', $value, 2); + if (count($parts) === 2 && (float) $parts[1] !== 0.0) { + return (float) $parts[0] / (float) $parts[1]; + } + } + return (float) $value; + } + + private function parseDate(string $value): ?int + { + $parts = explode(' ', $value, 2); + if (count($parts) !== 2) { + return null; + } + + [$ymd, $hms] = $parts; + $dateParts = explode(':', $ymd, 3); + if (count($dateParts) !== 3) { + return null; + } + + [$year, $month, $day] = $dateParts; + if (empty($year) || empty($month) || empty($day)) { + return null; + } + + $ts = strtotime("$month/$day/$year $hms"); + return $ts !== false ? $ts : null; + } +} diff --git a/src/Metadata/Reader/PhpExifReader.php b/src/Metadata/Reader/PhpExifReader.php new file mode 100644 index 0000000..d343240 --- /dev/null +++ b/src/Metadata/Reader/PhpExifReader.php @@ -0,0 +1,156 @@ +processData($raw)); + } + + public function readData(string $data): ImageMetadata + { + $tmp = tempnam(sys_get_temp_dir(), 'horde_exif_'); + if ($tmp === false) { + return new ImageMetadata(); + } + + try { + file_put_contents($tmp, $data); + return $this->readFile($tmp); + } finally { + @unlink($tmp); + } + } + + public function supportedCategories(): array + { + return [MetadataCategory::Exif]; + } + + /** + * @param array $raw + * @return array + */ + private function processData(array $raw): array + { + $results = []; + $supportedFields = MetadataField::forCategory(MetadataCategory::Exif); + + foreach ($supportedFields as $field) { + $key = $field->value; + $value = $raw[$key] ?? ''; + + if ($value === '' || $value === []) { + continue; + } + + if ($field->type() === FieldType::Gps) { + $value = $this->parseGps($raw, $key); + if ($value === null) { + continue; + } + } elseif ($field->type() === FieldType::Date) { + $value = $this->parseDate($value); + if ($value === null) { + continue; + } + } elseif (is_array($value)) { + $value = implode(',', $value); + } + + $results[$key] = $value; + } + + return $results; + } + + /** + * @param array $raw + */ + private function parseGps(array $raw, string $key): ?float + { + $value = $raw[$key] ?? null; + if ($value === null) { + return null; + } + + if (!is_array($value)) { + return (float) $value; + } + + if (count($value) < 3 || $value[0] == 0) { + return null; + } + + $degrees = $this->parseFraction($value[0]); + $minutes = $this->parseFraction($value[1]); + $seconds = $this->parseFraction($value[2]); + + $decimal = $degrees + ($minutes / 60) + ($seconds / 3600); + $decimal = round($decimal, 6); + + $ref = $raw[$key . 'Ref'] ?? ''; + if (in_array($ref, ['S', 'South', 'W', 'West'], true)) { + $decimal = -abs($decimal); + } + + return $decimal; + } + + private function parseFraction(mixed $value): float + { + if (is_string($value) && str_contains($value, '/')) { + $parts = explode('/', $value, 2); + if (count($parts) === 2 && (float) $parts[1] !== 0.0) { + return (float) $parts[0] / (float) $parts[1]; + } + } + return (float) $value; + } + + private function parseDate(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + $value = (string) $value; + $parts = explode(' ', $value, 2); + if (count($parts) !== 2) { + return null; + } + + [$ymd, $hms] = $parts; + [$year, $month, $day] = explode(':', $ymd, 3); + + if (empty($year) || empty($month) || empty($day)) { + return null; + } + + $ts = strtotime("$month/$day/$year $hms"); + return $ts !== false ? $ts : null; + } +} diff --git a/src/Sequence/AnimationOptions.php b/src/Sequence/AnimationOptions.php new file mode 100644 index 0000000..37caa50 --- /dev/null +++ b/src/Sequence/AnimationOptions.php @@ -0,0 +1,14 @@ +image, $milliseconds, $this->disposal, $this->x, $this->y); + } + + public function withImage(ImageResource $image): self + { + return new self($image, $this->delay, $this->disposal, $this->x, $this->y); + } + + public function withDisposal(DisposalMethod $disposal): self + { + return new self($this->image, $this->delay, $disposal, $this->x, $this->y); + } + + public function withOffset(int $x, int $y): self + { + return new self($this->image, $this->delay, $this->disposal, $x, $y); + } +} diff --git a/src/Sequence/ImageSequence.php b/src/Sequence/ImageSequence.php new file mode 100644 index 0000000..b261edb --- /dev/null +++ b/src/Sequence/ImageSequence.php @@ -0,0 +1,127 @@ + + */ +final class ImageSequence implements Countable, IteratorAggregate +{ + /** @var list