From e491b880b341a0d2960c9e9be7dd3b4070d7f15a Mon Sep 17 00:00:00 2001 From: midzelis Date: Tue, 3 Mar 2026 19:42:24 +0000 Subject: [PATCH] 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 --- .../asset-viewer/ocr-bounding-box.svelte | 12 ++-- .../asset-viewer/photo-viewer.svelte | 12 ++-- web/src/lib/utils/ocr-utils.ts | 58 +++++++++++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index 6f6caad0fc..2ef9ec1054 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 3e609ff130..38471f8a41 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -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, }; diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 3da36cf57a..a97f82be5d 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -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; };