mirror of
https://github.com/immich-app/immich.git
synced 2026-03-09 11:23:46 -04:00
feat(web): improve OCR overlay text fitting, reactivity, and accessibility
- Precise font sizing using canvas measureText instead of character-count heuristic - Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads - Fix animation delay on resize by using transition-colors instead of transition-all - Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort - Show text on focus (same styling as hover) with proper ARIA attributes
This commit is contained in:
parent
9597f8c37f
commit
e491b880b3
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { OcrBox } from '$lib/utils/ocr-utils';
|
||||
import { calculateBoundingBoxMatrix } from '$lib/utils/ocr-utils';
|
||||
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
|
||||
|
||||
type Props = {
|
||||
ocrBox: OcrBox;
|
||||
@ -11,16 +11,16 @@
|
||||
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
|
||||
|
||||
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
|
||||
// Fits almost all strings within the box, depends on font family
|
||||
const fontSize = $derived(
|
||||
`max(var(--text-sm), min(var(--text-6xl), ${(1.4 * dimensions.width) / ocrBox.text.length}px))`,
|
||||
);
|
||||
const fontSize = $derived(calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height) + 'px');
|
||||
</script>
|
||||
|
||||
<div class="absolute left-0 top-0">
|
||||
<div
|
||||
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
|
||||
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-nowrap select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none"
|
||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={ocrBox.text}
|
||||
>
|
||||
{ocrBox.text}
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
|
||||
import { type ContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
@ -74,12 +74,16 @@
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const client = { width: containerWidth, height: containerHeight };
|
||||
const scaled = scaleToFit(natural, client);
|
||||
const offsetX = (client.width - scaled.width) / 2;
|
||||
const offsetY = (client.height - scaled.height) / 2;
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
return {
|
||||
contentWidth: contentWidth * currentZoom,
|
||||
contentHeight: contentHeight * currentZoom,
|
||||
contentWidth: scaled.width * currentZoom,
|
||||
contentHeight: scaled.height * currentZoom,
|
||||
offsetX: offsetX * currentZoom + currentPositionX,
|
||||
offsetY: offsetY * currentZoom + currentPositionY,
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { clamp } from 'lodash-es';
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
@ -55,6 +56,54 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[];
|
||||
return { matrix, width, height };
|
||||
};
|
||||
|
||||
const HORIZONTAL_PADDING = 16;
|
||||
const VERTICAL_PADDING = 8;
|
||||
const REFERENCE_FONT_SIZE = 100;
|
||||
const MIN_FONT_SIZE = 8;
|
||||
const MAX_FONT_SIZE = 96;
|
||||
const REFERENCE_FONT = `${REFERENCE_FONT_SIZE}px 'GoogleSans', sans-serif`;
|
||||
|
||||
let sharedCanvasContext: CanvasRenderingContext2D | null = null;
|
||||
|
||||
const getCanvasContext = (): CanvasRenderingContext2D | null => {
|
||||
if (sharedCanvasContext !== null) {
|
||||
return sharedCanvasContext;
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
sharedCanvasContext = context;
|
||||
return sharedCanvasContext;
|
||||
};
|
||||
|
||||
export const calculateFittedFontSize = (text: string, boxWidth: number, boxHeight: number): number => {
|
||||
const availableWidth = boxWidth - HORIZONTAL_PADDING;
|
||||
const availableHeight = boxHeight - VERTICAL_PADDING;
|
||||
|
||||
const context = getCanvasContext();
|
||||
if (!context) {
|
||||
return clamp((1.4 * availableWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
}
|
||||
|
||||
// Unsupported in Safari iOS <16.6; falls back to default canvas font, giving less accurate but functional sizing
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
context.font = REFERENCE_FONT;
|
||||
|
||||
const metrics = context.measureText(text);
|
||||
const measuredWidth = metrics.width;
|
||||
const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
||||
|
||||
const scaleFromWidth = (availableWidth / measuredWidth) * REFERENCE_FONT_SIZE;
|
||||
const scaleFromHeight = (availableHeight / measuredHeight) * REFERENCE_FONT_SIZE;
|
||||
|
||||
return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
};
|
||||
|
||||
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
|
||||
const boxes: OcrBox[] = [];
|
||||
for (const ocr of ocrData) {
|
||||
@ -76,5 +125,14 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM
|
||||
});
|
||||
}
|
||||
|
||||
const rowThreshold = metrics.contentHeight * 0.02;
|
||||
boxes.sort((a, b) => {
|
||||
const yDifference = a.points[0].y - b.points[0].y;
|
||||
if (Math.abs(yDifference) < rowThreshold) {
|
||||
return a.points[0].x - b.points[0].x;
|
||||
}
|
||||
return yDifference;
|
||||
});
|
||||
|
||||
return boxes;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user