diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193ea45a..3c7922ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: extensions: ast - name: Read .nvmrc - run: echo ::set-output name=NODE_VERSION::$(cat .nvmrc) + run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT id: nvm - name: Set up Node ${{ steps.nvm.outputs.NODE_VERSION }} diff --git a/assets/app.js b/assets/app.js index 70d2019b..564421d6 100644 --- a/assets/app.js +++ b/assets/app.js @@ -175,6 +175,9 @@ $(function () { width = document.querySelector('[name="crop[width]"]'), height = document.querySelector('[name="crop[height]"]'), $modeButtons = $('.drag-mode'); + const rotateInput = document.querySelector('[name="rotate"]'); + const $rotateButtons = $('.ocr-rotate-left, .ocr-rotate-right'); + new Cropper(img, { viewMode: 2, dragMode: 'move', @@ -195,6 +198,20 @@ $(function () { $button.addClass('active'); this.cropper.setDragMode($button.data('drag-mode')); }); + // React to rotate buttons. + if (rotateInput) { + $rotateButtons.on('click', event => { + const $button = $(event.currentTarget); + const delta = $button.hasClass('ocr-rotate-left') ? -90 : 90; + + // Update Cropper rotation. + this.cropper.rotate(delta); + + // Track cumulative rotation in the hidden field. + const current = Number.parseFloat(rotateInput.value) || 0; + rotateInput.value = (current + delta + 360) % 360; + }); + } }, data: { x: Number.parseFloat(x.value), diff --git a/assets/images/rotate_left.svg b/assets/images/rotate_left.svg new file mode 100644 index 00000000..ac3ca69b --- /dev/null +++ b/assets/images/rotate_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/rotate_right.svg b/assets/images/rotate_right.svg new file mode 100644 index 00000000..6731a328 --- /dev/null +++ b/assets/images/rotate_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Controller/OcrController.php b/src/Controller/OcrController.php index c6075e89..6b56d746 100644 --- a/src/Controller/OcrController.php +++ b/src/Controller/OcrController.php @@ -57,6 +57,7 @@ class OcrController extends AbstractController { * @var mixed[] */ public static $params = [ + 'rotate' => 0, 'image' => '', 'engine' => self::DEFAULT_ENGINE, 'langs' => [], @@ -120,6 +121,10 @@ private function setup(): void { $crop = []; } static::$params['crop'] = array_map( 'intval', $crop ); + // NEW: normalize rotation (degrees) + $rotate = (int)$this->request->query->get( 'rotate', static::$params['rotate'] ); + $rotate = ( $rotate % 360 + 360 ) % 360; + static::$params['rotate'] = $rotate; } /** @@ -390,6 +395,8 @@ private function getResult( string $invalidLangsMode ): EngineResult { implode( '|', array_map( 'strval', static::$params['crop'] ) ), static::$params['psm'], static::$params['line_id'], + // NEW + static::$params['rotate'], // Warning messages are localized $this->intuition->getLang(), ] @@ -401,7 +408,9 @@ private function getResult( string $invalidLangsMode ): EngineResult { static::$params['image'], $invalidLangsMode, static::$params['crop'], - static::$params['langs'] + static::$params['langs'], + // NEW + static::$params['rotate'] ); } ); if ( !$result instanceof EngineResult ) { diff --git a/src/Engine/EngineBase.php b/src/Engine/EngineBase.php index c00737ce..68dae8fd 100644 --- a/src/Engine/EngineBase.php +++ b/src/Engine/EngineBase.php @@ -58,13 +58,15 @@ abstract public static function getId(): string; * @param string $invalidLangsMode * @param int[] $crop * @param string[]|null $models + * @param int $rotate Rotation in degrees (0-359) * @return EngineResult */ abstract public function getResult( string $imageUrl, string $invalidLangsMode, array $crop, - ?array $models = null + ?array $models = null, + int $rotate = 0 ): EngineResult; /** diff --git a/src/Engine/GoogleCloudVisionEngine.php b/src/Engine/GoogleCloudVisionEngine.php index 8b97b64b..4f05ba35 100644 --- a/src/Engine/GoogleCloudVisionEngine.php +++ b/src/Engine/GoogleCloudVisionEngine.php @@ -1,5 +1,6 @@ checkImageUrl( $imageUrl ); @@ -67,6 +70,12 @@ public function getResult( } $image = $this->getImage( $imageUrl, $crop ); + if ( $rotate !== 0 ) { + $imagine = new Imagine(); + $loaded = $imagine->load( $image->getData() ?: file_get_contents( $image->getUrl() ) ); + $loaded->rotate( $rotate ); + $image->setData( $loaded->get( 'jpg' ) ); + } $imageUrlOrData = $image->hasData() ? $image->getData() : $image->getUrl(); $response = $this->imageAnnotator->textDetection( $imageUrlOrData, [ 'imageContext' => $imageContext ] ); @@ -78,6 +87,12 @@ public function getResult( && stripos( $response->getError()->getMessage(), 'download the content and pass it in' ) !== false ) { $image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE ); + if ( $rotate !== 0 ) { + $imagine = new Imagine(); + $loaded = $imagine->load( $image->getData() ); + $loaded->rotate( $rotate ); + $image->setData( $loaded->get( 'jpg' ) ); + } $response = $this->imageAnnotator->textDetection( $image->getData(), [ 'imageContext' => $imageContext ] ); } diff --git a/src/Engine/TesseractEngine.php b/src/Engine/TesseractEngine.php index 80c0d419..93f69921 100644 --- a/src/Engine/TesseractEngine.php +++ b/src/Engine/TesseractEngine.php @@ -4,6 +4,7 @@ namespace App\Engine; use App\Exception\OcrException; +use Imagine\Gd\Imagine; use Krinkle\Intuition\Intuition; use Symfony\Contracts\HttpClient\HttpClientInterface; use thiagoalessio\TesseractOCR\TesseractOCR; @@ -51,7 +52,8 @@ public function getResult( string $imageUrl, string $invalidLangsMode, array $crop, - ?array $langs = null + ?array $langs = null, + int $rotate = 0 ): EngineResult { // Check the URL and fetch the image data. $this->checkImageUrl( $imageUrl ); @@ -59,6 +61,13 @@ public function getResult( [ $validLangs, $invalidLangs ] = $this->filterValidLangs( $langs, $invalidLangsMode ); $image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE ); + // If there is a rotation, apply it to the image data. + if ( $rotate !== 0 ) { + $imagine = new Imagine(); + $loaded = $imagine->load( $image->getData() ); + $loaded->rotate( $rotate ); + $image->setData( $loaded->get( 'jpg' ) ); + } $this->ocr->imageData( $image->getData(), $image->getSize() ); if ( $validLangs ) { diff --git a/src/Engine/TranskribusEngine.php b/src/Engine/TranskribusEngine.php index 12c15c23..19d29a74 100644 --- a/src/Engine/TranskribusEngine.php +++ b/src/Engine/TranskribusEngine.php @@ -4,6 +4,7 @@ namespace App\Engine; use App\Exception\OcrException; +use Imagine\Gd\Imagine; use Krinkle\Intuition\Intuition; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -114,7 +115,8 @@ public function getResult( string $imageUrl, string $invalidLangsMode, array $crop, - ?array $langs = null + ?array $langs = null, + int $rotate = 0 ): EngineResult { $this->checkImageUrl( $imageUrl ); @@ -141,6 +143,13 @@ public function getResult( $modelInfo = $this->getModelList()[$modelCode]; $htrModelId = (int)$modelInfo['htr']; $image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE ); + if ( $rotate !== 0 ) { + $imagine = new Imagine(); + $loaded = $imagine->load( $image->getData() ); + $loaded->rotate( $rotate ); + $image->setData( $loaded->get( 'jpg' ) ); + } + $processId = $this->transkribusClient->initProcess( $image, $htrModelId, $this->lineId, $points ); $resText = ''; diff --git a/templates/output.html.twig b/templates/output.html.twig index cbcc5660..eba081a1 100644 --- a/templates/output.html.twig +++ b/templates/output.html.twig @@ -92,6 +92,23 @@ alt="{{ msg('drag-mode-move-alt') }}" /> + + + @@ -99,6 +116,9 @@ + + {# NEW: rotate hidden field #} +
{{ msg('img-alt-text') }} diff --git a/tests/Controller/OcrControllerTest.php b/tests/Controller/OcrControllerTest.php index 1c5750af..0a07efe9 100644 --- a/tests/Controller/OcrControllerTest.php +++ b/tests/Controller/OcrControllerTest.php @@ -87,4 +87,66 @@ public function provideGetLang(): array { ], ]; } + + /** + * @dataProvider provideRotationNormalization + * @covers OcrController + * @param int $inputRotation + * @param int $expectedRotation + */ + public function testRotationNormalization( int $inputRotation, int $expectedRotation ): void { + $request = new Request( [ 'rotate' => $inputRotation ] ); + $requestStack = new RequestStack(); + $requestStack->push( $request ); + $request->setSession( new Session( new MockArraySessionStorage() ) ); + $intuition = new Intuition( [] ); + $controller = new OcrController( + $requestStack, + $intuition, + new EngineFactory( + new GoogleCloudVisionEngine( + dirname( __DIR__ ) . '/fixtures/google-account-keyfile.json', + $intuition, + $this->projectDir, + new MockHttpClient() + ), + new TesseractEngine( new MockHttpClient(), $intuition, $this->projectDir, new TesseractOCR() ), + new TranskribusEngine( + new TranskribusClient( + getenv( 'APP_TRANSKRIBUS_USERNAME' ), + getenv( 'APP_TRANSKRIBUS_PASSWORD' ), + new MockHttpClient(), + new NullAdapter(), + new NullAdapter() + ), + $intuition, + $this->projectDir, + new MockHttpClient() + ), + ), + new FilesystemAdapter() + ); + // Trigger the setup to process rotation + $reflection = new \ReflectionMethod( $controller, 'setup' ); + $reflection->setAccessible( true ); + $reflection->invoke( $controller ); + + $this->assertSame( $expectedRotation, OcrController::$params['rotate'] ); + } + + /** + * @return int[][] + */ + public function provideRotationNormalization(): array { + return [ + 'zero rotation' => [ 0, 0 ], + 'positive rotation' => [ 90, 90 ], + 'negative rotation' => [ -90, 270 ], + 'large positive rotation' => [ 450, 90 ], + 'large negative rotation' => [ -450, 270 ], + 'boundary 359' => [ 359, 359 ], + 'boundary 360' => [ 360, 0 ], + 'boundary -360' => [ -360, 0 ], + ]; + } } diff --git a/tests/Engine/EngineBaseTest.php b/tests/Engine/EngineBaseTest.php index 957e190f..84450760 100644 --- a/tests/Engine/EngineBaseTest.php +++ b/tests/Engine/EngineBaseTest.php @@ -205,4 +205,64 @@ public function instantiateEngine( string $engineName ): EngineBase { } return $engine; } + + /** + * @covers EngineBase::getResult + * Test that all engines accept the rotation parameter without errors + */ + public function testEngineAcceptsRotationParameter(): void { + // Test that engines can be called with rotation parameter + // This is a smoke test to ensure rotation parameter doesn't break the interface + $this->tesseractEngine->setImageHosts( 'upload.wikimedia.org' ); + + // All engines should accept the rotation parameter in their signature + try { + // We don't expect actual OCR results here, just that the parameter is accepted + $reflection = new \ReflectionMethod( $this->tesseractEngine, 'getResult' ); + $parameters = $reflection->getParameters(); + + // Check that 'rotate' parameter exists + $rotateParam = null; + foreach ( $parameters as $param ) { + if ( $param->getName() === 'rotate' ) { + $rotateParam = $param; + break; + } + } + + $this->assertNotNull( $rotateParam, 'rotate parameter should exist in getResult()' ); + $this->assertTrue( $rotateParam->isOptional(), 'rotate parameter should be optional' ); + $this->assertSame( 0, $rotateParam->getDefaultValue(), 'rotate parameter should default to 0' ); + } catch ( \Exception $e ) { + $this->fail( 'Failed to verify rotation parameter: ' . $e->getMessage() ); + } + } + + /** + * @covers EngineBase::getResult + * Test that rotation parameter is part of all engine interfaces + */ + public function testAllEnginesHaveRotationParameter(): void { + $engines = [ + $this->googleEngine, + $this->tesseractEngine, + $this->transkribusEngine, + ]; + + foreach ( $engines as $engine ) { + $reflection = new \ReflectionMethod( $engine, 'getResult' ); + $parameters = $reflection->getParameters(); + + $paramNames = []; + foreach ( $parameters as $param ) { + $paramNames[] = $param->getName(); + } + + $this->assertContains( + 'rotate', + $paramNames, + 'Engine ' . get_class( $engine ) . ' should have rotate parameter' + ); + } + } }