From 3a694219bfc292f57d9bb0d61aaab38b84505c8b Mon Sep 17 00:00:00 2001 From: Kevin Puertas Date: Wed, 19 Nov 2025 04:24:17 +0100 Subject: [PATCH 01/10] feat: add originalPath for external library assets in dedupe (#23710) * Add original path info row to duplicate asset component View path of images, useful when using external Library * Make if for not show path in internal images * Update web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * Refactor original path display logic in duplicate-asset * Update duplicate-asset.svelte * Add full path localization string * Change translated data * format: fix --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Co-authored-by: Alex Tran --- i18n/en.json | 1 + .../duplicates/duplicate-asset.svelte | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/i18n/en.json b/i18n/en.json index 2b69858f9a..4e3f274340 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1121,6 +1121,7 @@ "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "forgot_pin_code_question": "Forgot your PIN?", "forward": "Forward", + "full_path": "Full path: {path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", "general": "General", diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 8a8395d792..c0f5428c81 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -12,6 +12,7 @@ mdiClock, mdiFile, mdiFitToScreen, + mdiFolderOutline, mdiHeart, mdiImageMultipleOutline, mdiImageOutline, @@ -51,6 +52,7 @@ fileName: isDifferent((a) => a.originalFileName), fileSize: isDifferent((a) => getFileSize(a)), resolution: isDifferent((a) => getAssetResolution(a)), + originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')), date: isDifferent((a) => { const tz = a.exifInfo?.timeZone; const dt = @@ -79,6 +81,24 @@ (a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown', ), }); + + const getBasePath = (fullpath: string, fileName: string): string => { + if (fileName && fullpath.endsWith(fileName)) { + return fullpath.slice(0, -(fileName.length + 1)); + } + return fullpath; + }; + + function truncateMiddle(path: string, maxLength: number = 50): string { + if (path.length <= maxLength) { + return path; + } + + const start = Math.floor(maxLength / 2) - 2; + const end = Math.floor(maxLength / 2) - 2; + + return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end)); + }
@@ -152,6 +172,14 @@ {asset.originalFileName} + + {truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')} + + {getFileSize(asset)} From 42dd3315f8abad0003638815d68a0fb839d13f61 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 18 Nov 2025 22:26:15 -0500 Subject: [PATCH 02/10] refactor(web): fix TimelineManager import - use value import instead of type-only (#23983) --- web/src/lib/components/timeline/TimelineDateGroup.svelte | 2 +- .../timeline-manager/internal/intersection-support.svelte.ts | 2 +- .../managers/timeline-manager/internal/layout-support.svelte.ts | 2 +- .../managers/timeline-manager/internal/load-support.svelte.ts | 2 +- .../managers/timeline-manager/internal/search-support.svelte.ts | 2 +- web/src/lib/modals/NavigateToDateModal.svelte | 2 +- web/src/lib/utils/asset-utils.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/timeline/TimelineDateGroup.svelte b/web/src/lib/components/timeline/TimelineDateGroup.svelte index cd0dc9a212..c662c16e72 100644 --- a/web/src/lib/components/timeline/TimelineDateGroup.svelte +++ b/web/src/lib/components/timeline/TimelineDateGroup.svelte @@ -2,7 +2,7 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; - import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; + import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; diff --git a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts index bdf2b17cbe..3c6f2d8256 100644 --- a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts @@ -1,6 +1,6 @@ import { TUNABLES } from '$lib/utils/tunables'; import type { MonthGroup } from '../month-group.svelte'; -import type { TimelineManager } from '../timeline-manager.svelte'; +import { TimelineManager } from '../timeline-manager.svelte'; const { TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, diff --git a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts index 0f6ca112d1..71dc168971 100644 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts @@ -1,5 +1,5 @@ import type { MonthGroup } from '../month-group.svelte'; -import type { TimelineManager } from '../timeline-manager.svelte'; +import { TimelineManager } from '../timeline-manager.svelte'; import type { UpdateGeometryOptions } from '../types'; export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) { diff --git a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts index 0d966c9cee..ec50e3d75e 100644 --- a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts @@ -2,7 +2,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { toISOYearMonthUTC } from '$lib/utils/timeline-util'; import { getTimeBucket } from '@immich/sdk'; import type { MonthGroup } from '../month-group.svelte'; -import type { TimelineManager } from '../timeline-manager.svelte'; +import { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManagerOptions } from '../types'; export async function loadFromTimeBuckets( diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts index 52a37b52d0..f889456c20 100644 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts @@ -2,7 +2,7 @@ import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timelin import { AssetOrder } from '@immich/sdk'; import { DateTime } from 'luxon'; import type { MonthGroup } from '../month-group.svelte'; -import type { TimelineManager } from '../timeline-manager.svelte'; +import { TimelineManager } from '../timeline-manager.svelte'; import type { AssetDescriptor, Direction, TimelineAsset } from '../types'; export async function getAssetWithOffset( diff --git a/web/src/lib/modals/NavigateToDateModal.svelte b/web/src/lib/modals/NavigateToDateModal.svelte index 4b83c66bc6..365cbdb21c 100644 --- a/web/src/lib/modals/NavigateToDateModal.svelte +++ b/web/src/lib/modals/NavigateToDateModal.svelte @@ -1,6 +1,6 @@ + +
{ + const [newAssetId] = await openFileUploadDialog({ multiple: false }); + await copyAsset({ assetCopyDto: { sourceId: oldAssetId, targetId: newAssetId } }); + await deleteAssets({ assetBulkDeleteDto: { ids: [oldAssetId], force: true } }); + + eventManager.emit('AssetReplace', { oldAssetId, newAssetId }); +}; diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 3f602bdb29..516d682625 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -12,7 +12,6 @@ import { AssetMediaStatus, AssetVisibility, checkBulkUpload, - getAssetOriginalPath, getBaseUrl, type AssetMediaResponseDto, } from '@immich/sdk'; @@ -44,12 +43,10 @@ export const addDummyItems = () => { export const uploadExecutionQueue = new ExecutorQueue({ concurrency: 2 }); -type FileUploadParam = { multiple?: boolean } & ( - | { albumId?: string; assetId?: never } - | { albumId?: never; assetId?: string } -); +type FileUploadParam = { multiple?: boolean; albumId?: string }; + export const openFileUploadDialog = async (options: FileUploadParam = {}) => { - const { albumId, multiple = true, assetId } = options; + const { albumId, multiple = true } = options; const extensions = uploadManager.getExtensions(); return new Promise((resolve, reject) => { @@ -68,7 +65,7 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => { } const files = Array.from(target.files); - resolve(fileUploadHandler({ files, albumId, replaceAssetId: assetId })); + resolve(fileUploadHandler({ files, albumId })); }, { passive: true }, ); @@ -88,7 +85,6 @@ type FileUploadHandlerParams = Omit => { const extensions = uploadManager.getExtensions(); @@ -99,9 +95,7 @@ export const fileUploadHandler = async ({ const deviceAssetId = getDeviceAssetId(file); uploadAssetsStore.addItem({ id: deviceAssetId, file, albumId }); promises.push( - uploadExecutionQueue.addTask(() => - fileUploader({ assetFile: file, deviceAssetId, albumId, replaceAssetId, isLockedAssets }), - ), + uploadExecutionQueue.addTask(() => fileUploader({ assetFile: file, deviceAssetId, albumId, isLockedAssets })), ); } } @@ -127,7 +121,6 @@ async function fileUploader({ assetFile, deviceAssetId, albumId, - replaceAssetId, isLockedAssets = false, }: FileUploaderParams): Promise { const fileCreatedAt = new Date(assetFile.lastModified).toISOString(); @@ -183,27 +176,17 @@ async function fileUploader({ const queryParams = asQueryString(authManager.params); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_uploading') }); - if (replaceAssetId) { - const response = await uploadRequest({ - url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (queryParams ? `?${queryParams}` : ''), - method: 'PUT', - data: formData, - onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), - }); - responseData = response.data; - } else { - const response = await uploadRequest({ - url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''), - data: formData, - onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), - }); - - if (![200, 201].includes(response.status)) { - throw new Error($t('errors.unable_to_upload_file')); - } - - responseData = response.data; + const response = await uploadRequest({ + url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''), + data: formData, + onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), + }); + + if (![200, 201].includes(response.status)) { + throw new Error($t('errors.unable_to_upload_file')); } + + responseData = response.data; } if (responseData.status === AssetMediaStatus.Duplicate) { From 56e431226f6e0f4f02ff0a7e8222f481dcc6f21d Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Nov 2025 09:52:40 -0600 Subject: [PATCH 09/10] feat: show OCR bounding box (#23717) * feat: ocr bounding box * bounding boxes * pr feedback * pr feedback * allow copy across text boxes * pr feedback --- i18n/en.json | 3 + web/src/lib/actions/zoom-image.ts | 25 +++- .../asset-viewer/asset-viewer.svelte | 17 ++- .../asset-viewer/detail-panel.svelte | 2 +- .../asset-viewer/ocr-bounding-box.svelte | 36 +++++ .../components/asset-viewer/ocr-button.svelte | 17 +++ .../asset-viewer/photo-viewer.svelte | 23 ++- web/src/lib/stores/ocr.svelte.ts | 44 ++++++ web/src/lib/utils/ocr-utils.ts | 131 ++++++++++++++++++ 9 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/ocr-bounding-box.svelte create mode 100644 web/src/lib/components/asset-viewer/ocr-button.svelte create mode 100644 web/src/lib/stores/ocr.svelte.ts create mode 100644 web/src/lib/utils/ocr-utils.ts diff --git a/i18n/en.json b/i18n/en.json index 4e3f274340..276ca92891 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1158,6 +1158,7 @@ "hide_named_person": "Hide person {name}", "hide_password": "Hide password", "hide_person": "Hide person", + "hide_text_recognition": "Hide text recognition", "hide_unnamed_people": "Hide unnamed people", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", @@ -1967,6 +1968,7 @@ "show_slideshow_transition": "Show slideshow transition", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", + "show_text_recognition": "Show text recognition", "show_text_search_menu": "Show text search menu", "shuffle": "Shuffle", "sidebar": "Sidebar", @@ -2037,6 +2039,7 @@ "tags": "Tags", "tap_to_run_job": "Tap to run job", "template": "Template", + "text_recognition": "Text recognition", "theme": "Theme", "theme_selection": "Theme selection", "theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference", diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 29074fc7b0..e67d3e1928 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -2,7 +2,7 @@ import { photoZoomState } from '$lib/stores/zoom-image.store'; import { useZoomImageWheel } from '@zoom-image/svelte'; import { get } from 'svelte/store'; -export const zoomImageAction = (node: HTMLElement) => { +export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel(); createZoomImage(node, { @@ -14,9 +14,32 @@ export const zoomImageAction = (node: HTMLElement) => { setZoomImageState(state); } + // Store original event handlers so we can prevent them when disabled + const wheelHandler = (event: WheelEvent) => { + if (options?.disabled) { + event.stopImmediatePropagation(); + } + }; + + const pointerDownHandler = (event: PointerEvent) => { + if (options?.disabled) { + event.stopImmediatePropagation(); + } + }; + + // Add handlers at capture phase with higher priority + node.addEventListener('wheel', wheelHandler, { capture: true }); + node.addEventListener('pointerdown', pointerDownHandler, { capture: true }); + const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)]; + return { + update(newOptions?: { disabled?: boolean }) { + options = newOptions; + }, destroy() { + node.removeEventListener('wheel', wheelHandler, { capture: true }); + node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }); for (const unsubscribe of unsubscribes) { unsubscribe(); } diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index e26c85ad07..0af27e8373 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -13,6 +13,7 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; @@ -44,6 +45,7 @@ import CropArea from './editor/crop-tool/crop-area.svelte'; import EditorPanel from './editor/editor-panel.svelte'; import ImagePanoramaViewer from './image-panorama-viewer.svelte'; + import OcrButton from './ocr-button.svelte'; import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; @@ -392,9 +394,13 @@ handlePromiseError(activityManager.init(album.id, asset.id)); } }); + + let currentAssetId = $derived(asset.id); $effect(() => { - if (asset.id) { - handlePromiseError(handleGetAllAlbums()); + if (currentAssetId) { + untrack(() => handlePromiseError(handleGetAllAlbums())); + ocrManager.clear(); + handlePromiseError(ocrManager.getAssetOcr(currentAssetId)); } }); @@ -535,6 +541,7 @@ {playOriginalVideo} /> {/if} + {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
{/if} + + {#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData} +
+ +
+ {/if} {/key} {/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index a9c447e498..2ee4496830 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -503,7 +503,7 @@ {/if} {#if albums.length > 0} -
+

{$t('appears_in')}

{#each albums as album (album.id)} diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte new file mode 100644 index 0000000000..e64b674ac1 --- /dev/null +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -0,0 +1,36 @@ + + +
+ +
+ + +
+ {ocrBox.text} +
+
diff --git a/web/src/lib/components/asset-viewer/ocr-button.svelte b/web/src/lib/components/asset-viewer/ocr-button.svelte new file mode 100644 index 0000000000..9f8966e64a --- /dev/null +++ b/web/src/lib/components/asset-viewer/ocr-button.svelte @@ -0,0 +1,17 @@ + + + ocrManager.toggleOcrBoundingBox()} +/> diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index e37773fca5..261f194d34 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -2,12 +2,14 @@ import { shortcuts } from '$lib/actions/shortcut'; import { zoomImageAction } from '$lib/actions/zoom-image'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; + import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import { ocrManager } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; @@ -15,6 +17,7 @@ import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; + import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { getAltText } from '$lib/utils/thumbnail-util'; @@ -71,6 +74,14 @@ $boundingBoxesArray = []; }); + let ocrBoxes = $derived( + ocrManager.showOverlay && $photoViewerImgElement + ? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement) + : [], + ); + + let isOcrActive = $derived(ocrManager.showOverlay); + const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => { for (const preloadAsset of preloadAssets || []) { if (preloadAsset.isImage) { @@ -130,9 +141,15 @@ if ($photoZoomState.currentZoom > 1) { return; } + + if (ocrManager.showOverlay) { + return; + } + if (onNextAsset && event.detail.direction === 'left') { onNextAsset(); } + if (onPreviousAsset && event.detail.direction === 'right') { onPreviousAsset(); } @@ -235,7 +252,7 @@ {:else if !imageError}
{/each} + + {#each ocrBoxes as ocrBox (ocrBox.id)} + + {/each} {#if isFaceEditMode.value} diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts new file mode 100644 index 0000000000..4922f630ec --- /dev/null +++ b/web/src/lib/stores/ocr.svelte.ts @@ -0,0 +1,44 @@ +import { getAssetOcr } from '@immich/sdk'; + +export type OcrBoundingBox = { + id: string; + assetId: string; + x1: number; + y1: number; + x2: number; + y2: number; + x3: number; + y3: number; + x4: number; + y4: number; + boxScore: number; + textScore: number; + text: string; +}; + +class OcrManager { + #data = $state([]); + showOverlay = $state(false); + hasOcrData = $state(false); + + get data() { + return this.#data; + } + + async getAssetOcr(id: string) { + this.#data = await getAssetOcr({ id }); + this.hasOcrData = this.#data.length > 0; + } + + clear() { + this.#data = []; + this.showOverlay = false; + this.hasOcrData = false; + } + + toggleOcrBoundingBox() { + this.showOverlay = !this.showOverlay; + } +} + +export const ocrManager = new OcrManager(); diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts new file mode 100644 index 0000000000..97364d06f5 --- /dev/null +++ b/web/src/lib/utils/ocr-utils.ts @@ -0,0 +1,131 @@ +import type { OcrBoundingBox } from '$lib/stores/ocr.svelte'; +import type { ZoomImageWheelState } from '@zoom-image/core'; + +const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { + const ratio = img.naturalWidth / img.naturalHeight; + let width = img.height * ratio; + let height = img.height; + if (width > img.width) { + width = img.width; + height = img.width / ratio; + } + return { width, height }; +}; + +export interface OcrBox { + id: string; + points: { x: number; y: number }[]; + text: string; + confidence: number; +} + +export interface BoundingBoxDimensions { + minX: number; + maxX: number; + minY: number; + maxY: number; + width: number; + height: number; + centerX: number; + centerY: number; + rotation: number; + skewX: number; + skewY: number; +} + +/** + * Calculate bounding box dimensions and properties from OCR points + * @param points - Array of 4 corner points of the bounding box + * @returns Dimensions, rotation, and skew values for the bounding box + */ +export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => { + const [topLeft, topRight, bottomRight, bottomLeft] = points; + const minX = Math.min(...points.map(({ x }) => x)); + const maxX = Math.max(...points.map(({ x }) => x)); + const minY = Math.min(...points.map(({ y }) => y)); + const maxY = Math.max(...points.map(({ y }) => y)); + const width = maxX - minX; + const height = maxY - minY; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // Calculate rotation angle from the bottom edge (bottomLeft to bottomRight) + const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI); + + // Calculate skew angles to handle perspective distortion + // SkewX: compare left and right edges + const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x); + const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x); + const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI); + + // SkewY: compare top and bottom edges + const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x); + const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x); + const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI); + + return { + minX, + maxX, + minY, + maxY, + width, + height, + centerX, + centerY, + rotation, + skewX, + skewY, + }; +}; + +/** + * Convert normalized OCR coordinates to screen coordinates + * OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle + */ +export const getOcrBoundingBoxes = ( + ocrData: OcrBoundingBox[], + zoom: ZoomImageWheelState, + photoViewer: HTMLImageElement | null, +): OcrBox[] => { + const boxes: OcrBox[] = []; + + if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) { + return boxes; + } + + const clientHeight = photoViewer.clientHeight; + const clientWidth = photoViewer.clientWidth; + const { width, height } = getContainedSize(photoViewer); + + const imageWidth = photoViewer.naturalWidth; + const imageHeight = photoViewer.naturalHeight; + + for (const ocr of ocrData) { + // Convert normalized coordinates (0-1) to actual pixel positions + // OCR provides 4 corners of a potentially rotated rectangle + const points = [ + { x: ocr.x1, y: ocr.y1 }, + { x: ocr.x2, y: ocr.y2 }, + { x: ocr.x3, y: ocr.y3 }, + { x: ocr.x4, y: ocr.y4 }, + ].map((point) => ({ + x: + (width / imageWidth) * zoom.currentZoom * point.x * imageWidth + + ((clientWidth - width) / 2) * zoom.currentZoom + + zoom.currentPositionX, + y: + (height / imageHeight) * zoom.currentZoom * point.y * imageHeight + + ((clientHeight - height) / 2) * zoom.currentZoom + + zoom.currentPositionY, + })); + + boxes.push({ + id: ocr.id, + points, + text: ocr.text, + confidence: ocr.textScore, + }); + } + + return boxes; +}; From 8175b3b75b69f57d7a342e77a1e379ef725ccb7e Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 19 Nov 2025 17:00:01 +0100 Subject: [PATCH 10/10] fix: allow adding new translations files (#23998) --- .github/workflows/weblate-lock.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml index 1f0a7608d1..e37497b9bb 100644 --- a/.github/workflows/weblate-lock.yml +++ b/.github/workflows/weblate-lock.yml @@ -36,8 +36,7 @@ jobs: github-token: ${{ steps.token.outputs.token }} filters: | i18n: - - 'i18n/!(en)**\.json' - exclude-branches: 'chore/translations' + - modified: 'i18n/!(en)**\.json' skip-force-logic: 'true' enforce-lock: