Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
17 changes: 17 additions & 0 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions assets/images/rotate_left.svg
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use Codex icons instead of these ones

Copy link
Copy Markdown
Author

@shraddhaa09 shraddhaa09 Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sohomdatta1
I looked through the Codex icon set for the rotation controls, but I couldn’t find dedicated icons for rotate left or rotate right.
Before updating this further, I wanted to confirm the preferred approach here. Would you recommend:
-reusing existing Codex icons (for example undo/redo)
-switching to text-based buttons for now
-handling rotation icons through a future Codex addition?
Please let me know which option you’d prefer and I’ll update the implementation accordingly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could just use the existing codex icons, but I'd feel that getting the PR functional w.r.t the rotation is more of a priority here!

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/images/rotate_right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 10 additions & 1 deletion src/Controller/OcrController.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class OcrController extends AbstractController {
* @var mixed[]
*/
public static $params = [
'rotate' => 0,
'image' => '',
'engine' => self::DEFAULT_ENGINE,
'langs' => [],
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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(),
]
Expand All @@ -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 ) {
Expand Down
4 changes: 3 additions & 1 deletion src/Engine/EngineBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
19 changes: 17 additions & 2 deletions src/Engine/GoogleCloudVisionEngine.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<?php
declare( strict_types = 1 );

declare( strict_types=1 );

namespace App\Engine;

use App\Exception\OcrException;
use Google\Cloud\Vision\V1\ImageAnnotatorClient;
use Google\Cloud\Vision\V1\ImageContext;
use Google\Cloud\Vision\V1\TextAnnotation;
use Imagine\Gd\Imagine;
use Krinkle\Intuition\Intuition;
use Symfony\Contracts\HttpClient\HttpClientInterface;

Expand Down Expand Up @@ -51,7 +53,8 @@ public function getResult(
string $imageUrl,
string $invalidLangsMode,
array $crop,
?array $langs = null
?array $langs = null,
int $rotate = 0
): EngineResult {
$this->checkImageUrl( $imageUrl );

Expand All @@ -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 ] );

Expand All @@ -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 ] );
}

Expand Down
11 changes: 10 additions & 1 deletion src/Engine/TesseractEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,14 +52,22 @@ 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 );

[ $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 ) {
Expand Down
11 changes: 10 additions & 1 deletion src/Engine/TranskribusEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace App\Engine;

use App\Exception\OcrException;
use Imagine\Gd\Imagine;
use Krinkle\Intuition\Intuition;
use Symfony\Contracts\HttpClient\HttpClientInterface;

Expand Down Expand Up @@ -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 );

Expand All @@ -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 = '';
Expand Down
20 changes: 20 additions & 0 deletions templates/output.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,33 @@
alt="{{ msg('drag-mode-move-alt') }}" />
</button>
</div>
<button type="button"
class="btn btn-default ocr-rotate-left"
data-rotate="-90"
title="Rotate left 90°">
<img src="{{ asset('build/images/rotate_left.svg') }}"
height="20" width="20"
alt="Rotate left" />
</button>

<button type="button"
class="btn btn-default ocr-rotate-right"
data-rotate="90"
title="Rotate right 90°">
<img src="{{ asset('build/images/rotate_right.svg') }}"
height="20" width="20"
alt="Rotate right" />
</button>

<input type="submit" value="{{ msg( 'submit-crop' ) }}" class="submit-crop btn btn-info disabled" disabled />

<input type="hidden" name="crop[x]" value="{% if crop.x is defined %}{{ crop.x }}{% endif %}" />
<input type="hidden" name="crop[y]" value="{% if crop.y is defined %}{{ crop.y }}{% endif %}" />
<input type="hidden" name="crop[width]" value="{% if crop.width is defined %}{{ crop.width }}{% endif %}" />
<input type="hidden" name="crop[height]" value="{% if crop.height is defined %}{{ crop.height }}{% endif %}" />

{# NEW: rotate hidden field #}
<input type="hidden" name="rotate" value="{{ rotate|default(0) }}" />
</div>
<div><!-- This div is required for Cropper.js -->
<img id="source-image" class="img-responsive" src="{{ app.request.get('image') }}" alt="{{ msg('img-alt-text') }}" />
Expand Down
62 changes: 62 additions & 0 deletions tests/Controller/OcrControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ],
];
}
}
60 changes: 60 additions & 0 deletions tests/Engine/EngineBaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
}
}
}