Skip to content

Commit ee33e13

Browse files
feat: add image rotation functionality with UI controls and localization
1 parent 70ea70a commit ee33e13

10 files changed

Lines changed: 230 additions & 80 deletions

File tree

assets/app.js

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -166,35 +166,79 @@ $(function () {
166166
$(".loader").addClass('hidden');
167167
});
168168

169+
// --- Image workspace (crop, rotate, OCR results) ---
169170
var $ocrOutputDiv = $('.ocr-output');
170-
if ($ocrOutputDiv.length) {
171-
// Cropper.
172-
const img = document.getElementById('source-image'),
173-
x = document.querySelector('[name="crop[x]"]'),
174-
y = document.querySelector('[name="crop[y]"]'),
175-
width = document.querySelector('[name="crop[width]"]'),
176-
height = document.querySelector('[name="crop[height]"]'),
177-
$modeButtons = $('.drag-mode');
171+
const img = document.getElementById('source-image');
172+
const x = document.querySelector('[name="crop[x]"]');
173+
const y = document.querySelector('[name="crop[y]"]');
174+
const width = document.querySelector('[name="crop[width]"]');
175+
const height = document.querySelector('[name="crop[height]"]');
176+
const rotate = document.getElementById('rotate');
177+
const $modeButtons = $('.drag-mode');
178+
let cropperInstance = null;
179+
180+
/**
181+
* Initialize (or re-initialize) Cropper.js on the source image.
182+
*/
183+
function initCropper() {
184+
if (cropperInstance) {
185+
cropperInstance.destroy();
186+
cropperInstance = null;
187+
}
188+
178189
new Cropper(img, {
179190
viewMode: 2,
180191
dragMode: 'move',
181-
// Remove double-click drag mode toggling, because we've got buttons for that.
182192
toggleDragModeOnDblclick: false,
183-
// Only show a crop area if it is defined.
184193
autoCrop: width.value > 0 && height.value > 0,
185194
responsive: true,
186195
ready () {
196+
cropperInstance = this.cropper;
197+
187198
// Make textarea match height of image.
188199
$('#text').css({
189200
height: this.cropper.getContainerData().height,
190201
});
191202
// React to changes in the crop-mode buttons.
192-
$modeButtons.on( 'click', event => {
203+
$modeButtons.off('click.cropmode').on('click.cropmode', event => {
193204
const $button = $(event.currentTarget);
194205
$modeButtons.removeClass('active');
195206
$button.addClass('active');
196207
this.cropper.setDragMode($button.data('drag-mode'));
197208
});
209+
// Initialize rotation if present
210+
if (rotate && rotate.value && rotate.value !== '0') {
211+
this.cropper.rotate(Number.parseFloat(rotate.value));
212+
}
213+
214+
// Update rotation value from cropper
215+
const updateRotationValue = () => {
216+
if (rotate && cropperInstance) {
217+
const currentRotate = cropperInstance.getData().rotate || 0;
218+
// Normalize to 0-359 range
219+
const normalizedRotate = ((currentRotate % 360) + 360) % 360;
220+
rotate.value = Math.round(normalizedRotate);
221+
}
222+
};
223+
224+
// Rotation buttons
225+
$('.rotate-left').off('click.rotate').on('click.rotate', function(e) {
226+
e.preventDefault();
227+
e.stopPropagation();
228+
if (cropperInstance && typeof cropperInstance.rotate === 'function') {
229+
cropperInstance.rotate(-90);
230+
updateRotationValue();
231+
}
232+
});
233+
234+
$('.rotate-right').off('click.rotate').on('click.rotate', function(e) {
235+
e.preventDefault();
236+
e.stopPropagation();
237+
if (cropperInstance && typeof cropperInstance.rotate === 'function') {
238+
cropperInstance.rotate(90);
239+
updateRotationValue();
240+
}
241+
});
198242
},
199243
data: {
200244
x: Number.parseFloat(x.value),
@@ -207,23 +251,67 @@ $(function () {
207251
y.value = Math.round(event.detail.y);
208252
width.value = Math.round(event.detail.width);
209253
height.value = Math.round(event.detail.height);
210-
// Enable the cropping buttons. No need to disable them ever because there's no way to remove the crop box.
211-
$('.btn.submit-crop').attr('disabled', false).removeClass('disabled');
212-
$modeButtons.attr('disabled', false);
254+
// Only enable the crop-submit button when a real crop area has been drawn by the user.
255+
if (event.detail.width > 0 && event.detail.height > 0 && cropperInstance && cropperInstance.cropped) {
256+
$('.btn.submit-crop').attr('disabled', false).removeClass('disabled');
257+
$modeButtons.attr('disabled', false);
258+
}
213259
}
214260
});
261+
}
215262

216-
// When setting a new image URL, remove the preview and the crop dimensions.
217-
$('[name=image]').on('change', e => {
218-
$ocrOutputDiv.remove();
219-
});
263+
// Initialize cropper when image loads (works for both server-rendered and JS-set src).
264+
$(img).on('load', function() {
265+
$ocrOutputDiv.removeClass('hidden');
266+
initCropper();
267+
});
220268

221-
// When submitting the main 'transcribe' button, do not send crop dimensions.
222-
$('.submit-full').on('click', e => {
223-
x.value = null;
224-
y.value = null;
225-
width.value = null;
226-
height.value = null;
227-
});
269+
// If image is already loaded (cached), initialize immediately.
270+
if (img.src && img.complete && img.naturalWidth > 0) {
271+
initCropper();
228272
}
273+
274+
// Hide output section on image load error (only when no OCR text present).
275+
$(img).on('error', function() {
276+
if (!$('#text').val()) {
277+
$ocrOutputDiv.addClass('hidden');
278+
}
279+
});
280+
281+
// When the image URL input changes, update the source image.
282+
$('[name=image]').on('input', function() {
283+
const url = $(this).val();
284+
if (url) {
285+
// Reset crop and rotation for new image.
286+
x.value = '';
287+
y.value = '';
288+
width.value = '';
289+
height.value = '';
290+
if (rotate) {
291+
rotate.value = 0;
292+
}
293+
// Clear any existing OCR text.
294+
$('#text').val('');
295+
$('.copy-button').addClass('hidden');
296+
297+
// Destroy existing cropper before changing src.
298+
if (cropperInstance) {
299+
cropperInstance.destroy();
300+
cropperInstance = null;
301+
}
302+
img.src = url;
303+
// The 'load' event will show the output div and init cropper.
304+
} else {
305+
$ocrOutputDiv.addClass('hidden');
306+
}
307+
});
308+
309+
// When submitting the main 'transcribe' button, do not send crop dimensions.
310+
// Rotation is preserved — the user intentionally set it.
311+
$('.submit-full').on('click', e => {
312+
x.value = null;
313+
y.value = null;
314+
width.value = null;
315+
height.value = null;
316+
});
229317
});

i18n/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,7 @@
7676
"transkribus-job-description": "Description",
7777
"transkribus-job-start": "Started",
7878
"transkribus-job-end": "Finished",
79-
"transkribus-job-waited": "Start delay (minutes)"
79+
"transkribus-job-waited": "Start delay (minutes)",
80+
"rotate-left": "Rotate left",
81+
"rotate-right": "Rotate right"
8082
}

i18n/qqq.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,7 @@
8383
"transkribus-job-description": "Table column header text for the job description.",
8484
"transkribus-job-start": "Table column header text for the job-started date.",
8585
"transkribus-job-end": "Table column header text for the job-ended date",
86-
"transkribus-job-waited": "Table column header text for the job start-delay, in minutes."
86+
"transkribus-job-waited": "Table column header text for the job start-delay, in minutes.",
87+
"rotate-left": "Tooltip for the rotate-left button.",
88+
"rotate-right": "Tooltip for the rotate-right button."
8789
}

src/Controller/OcrController.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class OcrController extends AbstractController {
6363
'psm' => TesseractEngine::DEFAULT_PSM,
6464
'crop' => [],
6565
'line_id' => TranskribusEngine::DEFAULT_LINEID,
66+
'rotate' => 0,
6667
];
6768

6869
/**
@@ -120,6 +121,9 @@ private function setup(): void {
120121
$crop = [];
121122
}
122123
static::$params['crop'] = array_map( 'intval', $crop );
124+
$rotate = (int)$this->request->query->get( 'rotate', 0 );
125+
// Normalize rotation to 0-359 range
126+
static::$params['rotate'] = ( ( $rotate % 360 ) + 360 ) % 360;
123127
}
124128

125129
/**
@@ -271,6 +275,12 @@ public function homeAction(): Response {
271275
* description="Crop parameter `height` value.",
272276
* @OA\Schema(type="int")
273277
* )
278+
* @OA\Parameter(
279+
* name="rotate",
280+
* in="query",
281+
* description="Rotation angle in degrees (0-359).",
282+
* @OA\Schema(type="int")
283+
* )
274284
* @OA\Response(response=200, description="The OCR text, and other data.")
275285
* @return JsonResponse
276286
*/
@@ -390,6 +400,7 @@ private function getResult( string $invalidLangsMode ): EngineResult {
390400
implode( '|', array_map( 'strval', static::$params['crop'] ) ),
391401
static::$params['psm'],
392402
static::$params['line_id'],
403+
static::$params['rotate'],
393404
// Warning messages are localized
394405
$this->intuition->getLang(),
395406
]
@@ -401,7 +412,8 @@ private function getResult( string $invalidLangsMode ): EngineResult {
401412
static::$params['image'],
402413
$invalidLangsMode,
403414
static::$params['crop'],
404-
static::$params['langs']
415+
static::$params['langs'],
416+
static::$params['rotate']
405417
);
406418
} );
407419
if ( !$result instanceof EngineResult ) {

src/Engine/EngineBase.php

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ abstract public static function getId(): string;
5858
* @param string $invalidLangsMode
5959
* @param int[] $crop
6060
* @param string[]|null $models
61+
* @param int $rotate
6162
* @return EngineResult
6263
*/
6364
abstract public function getResult(
6465
string $imageUrl,
6566
string $invalidLangsMode,
6667
array $crop,
67-
?array $models = null
68+
?array $models = null,
69+
int $rotate = 0
6870
): EngineResult;
6971

7072
/**
@@ -184,13 +186,14 @@ protected function getInvalidLangsWarning( array $invalidLangs ): string {
184186
* @param string $imageUrl The original image URL.
185187
* @param int[] $crop Array with keys `x, `y`, `width` and `height`.
186188
* @param ?bool $downloadMode Whether to download the image or not.
189+
* @param int $rotate Rotation angle in degrees.
187190
* @return Image
188191
* @throws OcrException If the image couldn't be fetched.
189192
*/
190-
public function getImage( string $imageUrl, array $crop, ?bool $downloadMode = false ): Image {
191-
$image = new Image( $imageUrl, $crop );
193+
public function getImage( string $imageUrl, array $crop, ?bool $downloadMode = false, int $rotate = 0 ): Image {
194+
$image = new Image( $imageUrl, $crop, $rotate );
192195

193-
if ( self::DO_DOWNLOAD_IMAGE !== $downloadMode && !$image->needsCropping() ) {
196+
if ( self::DO_DOWNLOAD_IMAGE !== $downloadMode && !$image->needsCropping() && !$image->needsRotation() ) {
194197
return $image;
195198
}
196199

@@ -201,16 +204,23 @@ public function getImage( string $imageUrl, array $crop, ?bool $downloadMode = f
201204
throw new OcrException( 'image-retrieval-failed', [ $exception->getMessage() ] );
202205
}
203206

207+
$imagine = new Imagine();
208+
$loadedImage = $imagine->load( $data );
209+
210+
// Apply rotation first, before cropping.
211+
if ( $image->needsRotation() ) {
212+
$loadedImage = $loadedImage->rotate( $image->getRotate() );
213+
}
214+
204215
if ( !$image->needsCropping() ) {
205-
// If it doesn't need cropping, use the full image's data.
206-
$image->setData( $data );
207-
$image->setSize( (int)$imageResponse->getHeaders()['content-length'][0] );
216+
// If it doesn't need cropping, use the rotated image's data (or original if no rotation).
217+
$image->setData( $loadedImage->get( 'jpg' ) );
218+
$image->setSize( strlen( $image->getData() ) );
208219
} else {
209-
// Otherwise, crop it.
210-
$imagine = new Imagine();
211-
$loadedImage = $imagine->load( $data );
220+
// Otherwise, crop it after rotation.
212221
$croppedImage = $image->getCrop()->apply( $loadedImage );
213222
$image->setData( $croppedImage->get( 'jpg' ) );
223+
$image->setSize( strlen( $image->getData() ) );
214224
}
215225

216226
return $image;

src/Engine/GoogleCloudVisionEngine.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ public function getResult(
5151
string $imageUrl,
5252
string $invalidLangsMode,
5353
array $crop,
54-
?array $langs = null
54+
?array $langs = null,
55+
int $rotate = 0
5556
): EngineResult {
5657
$this->checkImageUrl( $imageUrl );
5758

@@ -66,7 +67,7 @@ public function getResult(
6667
throw new OcrException( 'google-error', [ 'Key for Google OCR engine is missing' ] );
6768
}
6869

69-
$image = $this->getImage( $imageUrl, $crop );
70+
$image = $this->getImage( $imageUrl, $crop, false, $rotate );
7071
$imageUrlOrData = $image->hasData() ? $image->getData() : $image->getUrl();
7172
$response = $this->imageAnnotator->textDetection( $imageUrlOrData, [ 'imageContext' => $imageContext ] );
7273

@@ -77,7 +78,7 @@ public function getResult(
7778
if ( $response->getError()
7879
&& stripos( $response->getError()->getMessage(), 'download the content and pass it in' ) !== false
7980
) {
80-
$image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE );
81+
$image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE, $rotate );
8182
$response = $this->imageAnnotator->textDetection( $image->getData(), [ 'imageContext' => $imageContext ] );
8283
}
8384

src/Engine/Image.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ class Image {
1616
/** @var int[] Array of floats with keys: `x`, `y`, `width`, and `height`. */
1717
private $crop;
1818

19+
/** @var int Rotation angle in degrees. */
20+
private $rotate;
21+
1922
/** @var string|null Image data. */
2023
private $data;
2124

@@ -25,10 +28,12 @@ class Image {
2528
/**
2629
* @param string $imageUrl
2730
* @param int[] $crop
31+
* @param int $rotate
2832
*/
29-
public function __construct( string $imageUrl, array $crop = [] ) {
33+
public function __construct( string $imageUrl, array $crop = [], int $rotate = 0 ) {
3034
$this->imageUrl = $imageUrl;
3135
$this->crop = $crop;
36+
$this->rotate = $rotate;
3237
}
3338

3439
/**
@@ -53,6 +58,22 @@ public function getCrop(): Crop {
5358
);
5459
}
5560

61+
/**
62+
* Check if rotation is needed.
63+
* @return bool
64+
*/
65+
public function needsRotation(): bool {
66+
return $this->rotate !== 0;
67+
}
68+
69+
/**
70+
* Get the rotation angle in degrees.
71+
* @return int
72+
*/
73+
public function getRotate(): int {
74+
return $this->rotate;
75+
}
76+
5677
public function hasData(): bool {
5778
return $this->data !== null;
5879
}

src/Engine/TesseractEngine.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,15 @@ public function getResult(
5151
string $imageUrl,
5252
string $invalidLangsMode,
5353
array $crop,
54-
?array $langs = null
54+
?array $langs = null,
55+
int $rotate = 0
5556
): EngineResult {
5657
// Check the URL and fetch the image data.
5758
$this->checkImageUrl( $imageUrl );
5859

5960
[ $validLangs, $invalidLangs ] = $this->filterValidLangs( $langs, $invalidLangsMode );
6061

61-
$image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE );
62+
$image = $this->getImage( $imageUrl, $crop, self::DO_DOWNLOAD_IMAGE, $rotate );
6263
$this->ocr->imageData( $image->getData(), $image->getSize() );
6364

6465
if ( $validLangs ) {

0 commit comments

Comments
 (0)