diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index eb23d18c0dfc0..f965785021fc2 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -59,6 +59,14 @@ "type": "boolean", "default": true }, + "enableAltTextModelDownload": { + "type": "boolean", + "default": true + }, + "enableNewAltTextWhenAddingImage": { + "type": "boolean", + "default": true + }, "altTextLearnMoreUrl": { "type": "string", "default": "" diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 9d0d940fc772a..be2ddedc4a0b0 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -446,7 +446,7 @@ pdfjs-editor-new-alt-text-error-close-button = Close # $totalSize (Number) - the total size (in MB) of the AI model. # $downloadedSize (Number) - the downloaded size (in MB) of the AI model. # $percent (Number) - the percentage of the downloaded size. -pdfjs-editor-new-alt-text-ai-model-downloading-progress = +pdfjs-editor-new-alt-text-ai-model-downloading-progress = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB) .aria-valuemin = 0 .aria-valuemax = { $totalSize } .aria-valuenow = { $downloadedSize } @@ -465,3 +465,28 @@ pdfjs-editor-new-alt-text-to-review-button-label = Review alt text # Variables: # $generatedAltText (String) - the generated alt-text. pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = Created automatically: { $generatedAltText } + +## Image alt-text settings + +pdfjs-image-alt-text-settings-button = + .title = Image alt text settings +pdfjs-image-alt-text-settings-button-label = Image alt text settings + +pdfjs-editor-alt-text-settings-dialog-label = Image alt text settings +pdfjs-editor-alt-text-settings-automatic-title = Automatic alt text +pdfjs-editor-alt-text-settings-create-model-button-label = Create alt text automatically +pdfjs-editor-alt-text-settings-create-model-description = Suggests descriptions to help people who can’t see the image or when the image doesn’t load. + +# Variables: +# $totalSize (Number) - the total size (in MB) of the AI model. +pdfjs-editor-alt-text-settings-download-model-label = Alt text AI model ({ $totalSize } MB) + +pdfjs-editor-alt-text-settings-ai-model-description = Runs locally on your device so your data stays private. Required for automatic alt text. +pdfjs-editor-alt-text-settings-delete-model-button = Delete +pdfjs-editor-alt-text-settings-download-model-button = Download +pdfjs-editor-alt-text-settings-downloading-model-button = Downloading… + +pdfjs-editor-alt-text-settings-editor-title = Alt text editor +pdfjs-editor-alt-text-settings-show-dialog-button-label = Show alt text editor right away when adding an image +pdfjs-editor-alt-text-settings-show-dialog-description = Helps you make sure all your images have alt text. +pdfjs-editor-alt-text-settings-close-button = Close diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index 370e37fc0f1aa..fc4f6e41c68bb 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -126,7 +126,11 @@ class StampEditor extends AnnotationEditor { if (!this.#canvas) { return; } - if (this._uiManager.useNewAltTextFlow && this.#bitmap) { + if ( + this._uiManager.useNewAltTextWhenAddingImage && + this._uiManager.useNewAltTextFlow && + this.#bitmap + ) { this._editToolbar.hide(); this._uiManager.editAltText(this, /* firstTime = */ true); } else { @@ -341,7 +345,10 @@ class StampEditor extends AnnotationEditor { this._uiManager.enableWaiting(false); const canvas = (this.#canvas = document.createElement("canvas")); div.append(canvas); - if (!this._uiManager.useNewAltTextFlow) { + if ( + !this._uiManager.useNewAltTextWhenAddingImage || + !this._uiManager.useNewAltTextFlow + ) { div.hidden = false; } this.#drawBitmap(width, height); diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 585634941eae9..a45f3647144f0 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -564,6 +564,8 @@ class AnnotationEditorUIManager { #enableUpdatedAddImage = false; + #enableNewAltTextWhenAddingImage = false; + #filterFactory = null; #focusMainContainerTimeoutId = null; @@ -616,6 +618,8 @@ class AnnotationEditorUIManager { #boundOnScaleChanging = this.onScaleChanging.bind(this); + #boundOnSetPreference = this.onSetPreference.bind(this); + #boundOnRotationChanging = this.onRotationChanging.bind(this); #previousStates = { @@ -782,17 +786,21 @@ class AnnotationEditorUIManager { highlightColors, enableHighlightFloatingButton, enableUpdatedAddImage, + enableNewAltTextWhenAddingImage, mlManager ) { - this._signal = this.#abortController.signal; + const signal = (this._signal = this.#abortController.signal); this.#container = container; this.#viewer = viewer; this.#altTextManager = altTextManager; this._eventBus = eventBus; - this._eventBus._on("editingaction", this.#boundOnEditingAction); - this._eventBus._on("pagechanging", this.#boundOnPageChanging); - this._eventBus._on("scalechanging", this.#boundOnScaleChanging); - this._eventBus._on("rotationchanging", this.#boundOnRotationChanging); + this._eventBus._on("editingaction", this.#boundOnEditingAction, { signal }); + this._eventBus._on("pagechanging", this.#boundOnPageChanging, { signal }); + this._eventBus._on("scalechanging", this.#boundOnScaleChanging, { signal }); + this._eventBus._on("rotationchanging", this.#boundOnRotationChanging, { + signal, + }); + this._eventBus._on("setpreference", this.#boundOnSetPreference, { signal }); this.#addSelectionListener(); this.#addDragAndDropListeners(); this.#addKeyboardManager(); @@ -802,6 +810,7 @@ class AnnotationEditorUIManager { this.#highlightColors = highlightColors || null; this.#enableHighlightFloatingButton = enableHighlightFloatingButton; this.#enableUpdatedAddImage = enableUpdatedAddImage; + this.#enableNewAltTextWhenAddingImage = enableNewAltTextWhenAddingImage; this.#mlManager = mlManager || null; this.viewParameters = { realScale: PixelsPerInch.PDF_TO_CSS_UNITS, @@ -825,10 +834,6 @@ class AnnotationEditorUIManager { this.#abortController = null; this._signal = null; - this._eventBus._off("editingaction", this.#boundOnEditingAction); - this._eventBus._off("pagechanging", this.#boundOnPageChanging); - this._eventBus._off("scalechanging", this.#boundOnScaleChanging); - this._eventBus._off("rotationchanging", this.#boundOnRotationChanging); for (const layer of this.#allLayers.values()) { layer.destroy(); } @@ -871,6 +876,10 @@ class AnnotationEditorUIManager { return this.#enableUpdatedAddImage; } + get useNewAltTextWhenAddingImage() { + return this.#enableNewAltTextWhenAddingImage; + } + get hcmFilter() { return shadow( this, @@ -944,6 +953,14 @@ class AnnotationEditorUIManager { }); } + onSetPreference({ name, value }) { + switch (name) { + case "enableNewAltTextWhenAddingImage": + this.#enableNewAltTextWhenAddingImage = value; + break; + } + } + onPageChanging({ pageNumber }) { this.#currentPageIndex = pageNumber - 1; } diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 67d634ca3b2f5..9bb01389c8abf 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -1349,3 +1349,63 @@ } } } + +#altTextSettingsDialog { + padding: 16px; + + #altTextSettingsContainer { + display: flex; + width: 573px; + flex-direction: column; + gap: 16px; + + .mainContainer { + gap: 16px; + } + + .description { + color: var(--text-secondary-color); + } + + #aiModelSettings { + display: flex; + flex-direction: column; + gap: 12px; + + button { + width: fit-content; + } + + &.download { + #deleteModelButton { + display: none; + } + } + + &:not(.download) { + #downloadModelButton { + display: none; + } + } + } + + #automaticAltText, + #altTextEditor { + display: flex; + flex-direction: column; + gap: 8px; + } + + #createModelDescription, + #aiModelSettings, + #showAltTextDialogDescription { + padding-inline-start: 40px; + } + + #automaticSettings { + display: flex; + flex-direction: column; + gap: 16px; + } + } +} diff --git a/web/app.js b/web/app.js index 4f8a96f38015a..9c483fc244e17 100644 --- a/web/app.js +++ b/web/app.js @@ -59,12 +59,15 @@ import { import { AppOptions, OptionKind } from "./app_options.js"; import { EventBus, FirefoxEventBus } from "./event_utils.js"; import { ExternalServices, initCom, MLManager } from "web-external_services"; +import { + ImageAltTextSettings, + NewAltTextManager, +} from "web-new_alt_text_manager"; import { LinkTarget, PDFLinkService } from "./pdf_link_service.js"; import { AltTextManager } from "web-alt_text_manager"; import { AnnotationEditorParams } from "web-annotation_editor_params"; import { CaretBrowsingMode } from "./caret_browsing.js"; import { DownloadManager } from "web-download_manager"; -import { NewAltTextManager } from "web-new_alt_text_manager"; import { OverlayManager } from "./overlay_manager.js"; import { PasswordPrompt } from "./password_prompt.js"; import { PDFAttachmentViewer } from "web-pdf_attachment_viewer"; @@ -151,6 +154,8 @@ const PDFViewerApplication = { l10n: null, /** @type {AnnotationEditorParams} */ annotationEditorParams: null, + /** @type {ImageAltTextSettings} */ + imageAltTextSettings: null, isInitialViewSet: false, isViewerEmbedded: window.parent !== window, url: "", @@ -211,6 +216,9 @@ const PDFViewerApplication = { this.mlManager = MLManager.getFakeMLManager?.({ enableGuessAltText: AppOptions.get("enableGuessAltText"), + enableAltTextModelDownload: AppOptions.get( + "enableAltTextModelDownload" + ), }) || null; } } @@ -218,6 +226,9 @@ const PDFViewerApplication = { // We want to load the image-to-text AI engine as soon as possible. this.mlManager = new MLManager({ enableGuessAltText: AppOptions.get("enableGuessAltText"), + enableAltTextModelDownload: AppOptions.get( + "enableAltTextModelDownload" + ), altTextLearnMoreUrl: AppOptions.get("altTextLearnMoreUrl"), }); } @@ -390,12 +401,12 @@ const PDFViewerApplication = { externalServices, AppOptions.get("isInAutomation") ); - if (this.mlManager) { - this.mlManager.eventBus = eventBus; - } } else { eventBus = new EventBus(); } + if (this.mlManager) { + this.mlManager.eventBus = eventBus; + } this.eventBus = eventBus; this.overlayManager = new OverlayManager(); @@ -445,7 +456,11 @@ const PDFViewerApplication = { let altTextManager; if (AppOptions.get("enableUpdatedAddImage")) { altTextManager = appConfig.newAltTextDialog - ? new NewAltTextManager(appConfig.newAltTextDialog, this.overlayManager) + ? new NewAltTextManager( + appConfig.newAltTextDialog, + this.overlayManager, + eventBus + ) : null; } else { altTextManager = appConfig.altTextDialog @@ -479,6 +494,9 @@ const PDFViewerApplication = { "enableHighlightFloatingButton" ), enableUpdatedAddImage: AppOptions.get("enableUpdatedAddImage"), + enableNewAltTextWhenAddingImage: AppOptions.get( + "enableNewAltTextWhenAddingImage" + ), imageResourcesPath: AppOptions.get("imageResourcesPath"), enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"), @@ -539,6 +557,15 @@ const PDFViewerApplication = { } } + if (appConfig.secondaryToolbar?.imageAltTextSettingsButton) { + this.imageAltTextSettings = new ImageAltTextSettings( + appConfig.altTextSettingsDialog, + this.overlayManager, + eventBus, + this.mlManager + ); + } + if (appConfig.documentProperties) { this.pdfDocumentProperties = new PDFDocumentProperties( appConfig.documentProperties, @@ -579,6 +606,15 @@ const PDFViewerApplication = { } if (appConfig.secondaryToolbar) { + if (AppOptions.get("enableAltText")) { + appConfig.secondaryToolbar.imageAltTextSettingsButton?.classList.remove( + "hidden" + ); + appConfig.secondaryToolbar.imageAltTextSettingsSeparator?.classList.remove( + "hidden" + ); + } + this.secondaryToolbar = new SecondaryToolbar( appConfig.secondaryToolbar, eventBus @@ -1914,6 +1950,9 @@ const PDFViewerApplication = { eventBus._on("scrollmodechanged", webViewerScrollModeChanged, { signal }); eventBus._on("switchspreadmode", webViewerSwitchSpreadMode, { signal }); eventBus._on("spreadmodechanged", webViewerSpreadModeChanged, { signal }); + eventBus._on("imagealttextsettings", webViewerImageAltTextSettings, { + signal, + }); eventBus._on("documentproperties", webViewerDocumentProperties, { signal }); eventBus._on("findfromurlhash", webViewerFindFromUrlHash, { signal }); eventBus._on("updatefindmatchescount", webViewerUpdateFindMatchesCount, { @@ -1934,6 +1973,11 @@ const PDFViewerApplication = { { signal } ); eventBus._on("reporttelemetry", webViewerReportTelemetry, { signal }); + } + if ( + typeof PDFJSDev === "undefined" || + PDFJSDev.test("TESTING || MOZCENTRAL") + ) { eventBus._on("setpreference", webViewerSetPreference, { signal }); } }, @@ -2470,6 +2514,15 @@ function webViewerDocumentProperties() { PDFViewerApplication.pdfDocumentProperties?.open(); } +function webViewerImageAltTextSettings() { + PDFViewerApplication.imageAltTextSettings?.open({ + enableGuessAltText: AppOptions.get("enableGuessAltText"), + enableNewAltTextWhenAddingImage: AppOptions.get( + "enableNewAltTextWhenAddingImage" + ), + }); +} + function webViewerFindFromUrlHash(evt) { PDFViewerApplication.eventBus.dispatch("find", { source: evt.source, diff --git a/web/app_options.js b/web/app_options.js index 5f74abc52ee93..040a08221be7c 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -190,6 +190,11 @@ const defaultOptions = { value: false, kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableAltTextModelDownload: { + /** @type {boolean} */ + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableGuessAltText: { /** @type {boolean} */ value: true, @@ -211,6 +216,11 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableNewAltTextWhenAddingImage: { + /** @type {boolean} */ + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enablePermissions: { /** @type {boolean} */ value: false, diff --git a/web/dialog.css b/web/dialog.css index 7fabdd75561a7..2085d663c8a72 100644 --- a/web/dialog.css +++ b/web/dialog.css @@ -24,6 +24,7 @@ --focus-ring-outline: 2px solid var(--focus-ring-color); --link-fg-color: #0060df; --link-hover-fg-color: #0250bb; + --separator-color: #f0f0f4; --textarea-border-color: #8f8f9d; --textarea-bg-color: white; @@ -57,6 +58,7 @@ --hover-filter: brightness(1.4); --link-fg-color: #0df; --link-hover-fg-color: #80ebff; + --separator-color: #52525e; --textarea-bg-color: #42414d; @@ -79,6 +81,7 @@ --focus-ring-color: ButtonBorder; --link-fg-color: LinkText; --link-hover-fg-color: LinkText; + --separator-color: CanvasText; --textarea-border-color: ButtonBorder; --textarea-bg-color: Field; @@ -134,6 +137,13 @@ } } + .dialogSeparator { + width: 100%; + height: 1px; + margin-block: 4px; + background-color: var(--separator-color); + } + .dialogButtonsGroup { display: flex; gap: 12px; diff --git a/web/firefoxcom.js b/web/firefoxcom.js index 40b70e61085b0..881919820e9a1 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -314,34 +314,62 @@ class MLManager { eventBus = null; - constructor(options) { - this.enable({ ...options, listenToProgress: false }); + hasProgress = false; + + static #AI_ALT_TEXT_MODEL_NAME = "moz-image-to-text"; + + constructor({ + altTextLearnMoreUrl, + enableGuessAltText, + enableAltTextModelDownload, + }) { + // The `altTextLearnMoreUrl` is used to provide a link to the user to learn + // more about the "alt text" feature. + // The link is used in the Alt Text dialog or in the Image Settings. + this.altTextLearnMoreUrl = altTextLearnMoreUrl; + this.enableAltTextModelDownload = enableAltTextModelDownload; + this.enableGuessAltText = enableGuessAltText; + + if (enableAltTextModelDownload) { + this.#loadAltTextEngine(false); + } } async isEnabledFor(name) { - return !!(await this.#enabled?.get(name)); + return this.enableGuessAltText && !!(await this.#enabled?.get(name)); } isReady(name) { return this.#ready?.has(name) ?? false; } - deleteModel(service) { - return FirefoxCom.requestAsync("mlDelete", service); + async deleteModel(name) { + if (name !== "altText") { + return; + } + this.enableAltTextModelDownload = false; + this.#ready?.delete(name); + this.#enabled?.delete(name); + await Promise.all([ + this.toggleService("altText", false), + FirefoxCom.requestAsync("mlDelete", MLManager.#AI_ALT_TEXT_MODEL_NAME), + ]); } - guess(data) { - return FirefoxCom.requestAsync("mlGuess", data); + async downloadModel(name) { + if (name !== "altText") { + return null; + } + this.enableAltTextModelDownload = true; + return this.#loadAltTextEngine(true); } - enable({ altTextLearnMoreUrl, enableGuessAltText, listenToProgress }) { - if (enableGuessAltText) { - this.#loadAltTextEngine(listenToProgress); + async guess(data) { + if (data?.name !== "altText") { + return null; } - // The `altTextLearnMoreUrl` is used to provide a link to the user to learn - // more about the "alt text" feature. - // The link is used in the Alt Text dialog or in the Image Settings. - this.altTextLearnMoreUrl = altTextLearnMoreUrl; + data.service = MLManager.#AI_ALT_TEXT_MODEL_NAME; + return FirefoxCom.requestAsync("mlGuess", data); } async toggleService(name, enabled) { @@ -349,11 +377,9 @@ class MLManager { return; } - if (enabled) { + this.enableGuessAltText = enabled; + if (enabled && this.enableAltTextModelDownload) { await this.#loadAltTextEngine(false); - } else { - this.#enabled?.delete(name); - this.#ready?.delete(name); } } @@ -364,7 +390,7 @@ class MLManager { } this.#ready ||= new Set(); const promise = FirefoxCom.requestAsync("loadAIEngine", { - service: "moz-image-to-text", + service: MLManager.#AI_ALT_TEXT_MODEL_NAME, listenToProgress, }).then(ok => { if (ok) { @@ -374,22 +400,26 @@ class MLManager { }); (this.#enabled ||= new Map()).set("altText", promise); if (listenToProgress) { + this.hasProgress = true; const callback = ({ detail }) => { this.eventBus.dispatch("loadaiengineprogress", { source: this, detail, }); if (detail.finished) { + this.hasProgress = false; window.removeEventListener("loadAIEngineProgress", callback); } }; window.addEventListener("loadAIEngineProgress", callback); promise.then(ok => { if (!ok) { + this.hasProgress = false; window.removeEventListener("loadAIEngineProgress", callback); } }); } + await promise; } } diff --git a/web/genericcom.js b/web/genericcom.js index 4da24c1cbe7d2..0d1b58b9566ef 100644 --- a/web/genericcom.js +++ b/web/genericcom.js @@ -70,20 +70,57 @@ class MLManager { } class FakeMLManager { - constructor({ enableGuessAltText }) { + eventBus = null; + + hasProgress = false; + + constructor({ enableGuessAltText, enableAltTextModelDownload }) { this.enableGuessAltText = enableGuessAltText; + this.enableAltTextModelDownload = enableAltTextModelDownload; } async isEnabledFor(_name) { return this.enableGuessAltText; } - async deleteModel(_service) { + async deleteModel(_name) { + this.enableAltTextModelDownload = false; return null; } + async downloadModel(_name) { + // Simulate downloading the model but with progress. + // The progress can be seen in the new alt-text dialog. + this.hasProgress = true; + + const { promise, resolve } = Promise.withResolvers(); + const total = 1e8; + const end = 1.5 * total; + const increment = 5e6; + let loaded = 0; + const id = setInterval(() => { + loaded += increment; + if (loaded <= end) { + this.eventBus.dispatch("loadaiengineprogress", { + source: this, + detail: { + total, + totalLoaded: loaded, + finished: loaded + increment >= end, + }, + }); + return; + } + clearInterval(id); + this.hasProgress = false; + this.enableAltTextModelDownload = true; + resolve(true); + }, 900); + return promise; + } + isReady(_name) { - return true; + return this.enableAltTextModelDownload; } guess({ request: { data } }) { diff --git a/web/new_alt_text_manager.js b/web/new_alt_text_manager.js index e2c19b181e551..4a5012419f868 100644 --- a/web/new_alt_text_manager.js +++ b/web/new_alt_text_manager.js @@ -13,6 +13,8 @@ * limitations under the License. */ +import { noContextMenu } from "pdfjs-lib"; + class NewAltTextManager { #boundCancel = this.#cancel.bind(this); @@ -28,6 +30,12 @@ class NewAltTextManager { #disclaimer; + #downloadModel; + + #downloadModelDescription; + + #eventBus; + #firstTime = false; #guessedAltText; @@ -71,9 +79,12 @@ class NewAltTextManager { learnMore, errorCloseButton, createAutomaticallyButton, + downloadModel, + downloadModelDescription, title, }, - overlayManager + overlayManager, + eventBus ) { this.#cancelButton = cancelButton; this.#createAutomaticallyButton = createAutomaticallyButton; @@ -85,7 +96,10 @@ class NewAltTextManager { this.#textarea = textarea; this.#learnMore = learnMore; this.#title = title; + this.#downloadModel = downloadModel; + this.#downloadModelDescription = downloadModelDescription; this.#overlayManager = overlayManager; + this.#eventBus = eventBus; dialog.addEventListener("close", this.#close.bind(this)); dialog.addEventListener("contextmenu", event => { @@ -236,7 +250,7 @@ class NewAltTextManager { // When calling #mlGuessAltText we don't wait for it, so we must take care // that the alt text dialog can have been closed before the response is. const response = await this.#uiManager.mlGuess({ - service: "moz-image-to-text", + name: "altText", request: { data, width, @@ -274,6 +288,45 @@ class NewAltTextManager { this.#textarea.value = altText; } + #setProgress() { + // Show the download model progress. + this.#downloadModel.classList.toggle("hidden", false); + + const callback = async ({ detail: { finished, total, totalLoaded } }) => { + const ONE_MEGA_BYTES = 1e6; + // totalLoaded can be greater than total if the download is compressed. + // So we cheat to avoid any confusion. + totalLoaded = Math.min(0.99 * total, totalLoaded); + + // Update the progress. + this.#downloadModelDescription.setAttribute( + "data-l10n-args", + `{"totalSize": ${Math.round(total / ONE_MEGA_BYTES)}, "downloadedSize": ${Math.round(totalLoaded / ONE_MEGA_BYTES)}}` + ); + if (!finished) { + return; + } + + // We're done, remove the listener and hide the download model progress. + this.#eventBus._off("loadaiengineprogress", callback); + this.#downloadModel.classList.toggle("hidden", true); + + this.#toggleAI(true); + if (!this.#uiManager) { + return; + } + const { mlManager } = this.#uiManager; + + // The model has been downloaded, we can now enable the AI service. + mlManager.toggleService("altText", true); + this.#toggleGuessAltText( + await mlManager.isEnabledFor("altText"), + /* isInitial = */ true + ); + }; + this.#eventBus._on("loadaiengineprogress", callback); + } + async editAltText(uiManager, editor, firstTime) { if (this.#currentEditor || !editor) { return; @@ -286,9 +339,19 @@ class NewAltTextManager { this.#firstTime = firstTime; let { mlManager } = uiManager; - if (!mlManager?.isReady("altText")) { - mlManager = null; + let hasAI = !!mlManager; + + if (mlManager && !mlManager.isReady("altText")) { + hasAI = false; + if (mlManager.hasProgress) { + this.#setProgress(); + } else { + mlManager = null; + } + } else { + this.#downloadModel.classList.toggle("hidden", true); } + const isAltTextEnabledPromise = mlManager?.isEnabledFor("altText"); this.#currentEditor = editor; @@ -311,10 +374,12 @@ class NewAltTextManager { AI_MAX_IMAGE_DIMENSION, /* createImageData = */ true )); - this.#toggleGuessAltText( - await isAltTextEnabledPromise, - /* isInitial = */ true - ); + if (hasAI) { + this.#toggleGuessAltText( + await isAltTextEnabledPromise, + /* isInitial = */ true + ); + } } else { ({ canvas } = editor.copyCanvas( AI_MAX_IMAGE_DIMENSION, @@ -326,7 +391,7 @@ class NewAltTextManager { this.#imagePreview.append(canvas); this.#toggleNotNow(); - this.#toggleAI(!!mlManager); + this.#toggleAI(hasAI); this.#toggleError(false); try { @@ -396,4 +461,161 @@ class NewAltTextManager { } } -export { NewAltTextManager }; +class ImageAltTextSettings { + #aiModelSettings; + + #boundOnClickCreateModel; + + #createModelButton; + + #dialog; + + #eventBus; + + #mlManager; + + #overlayManager; + + #showAltTextDialogButton; + + constructor( + { + dialog, + createModelButton, + aiModelSettings, + learnMore, + closeButton, + deleteModelButton, + downloadModelButton, + showAltTextDialogButton, + }, + overlayManager, + eventBus, + mlManager + ) { + this.#dialog = dialog; + this.#aiModelSettings = aiModelSettings; + this.#createModelButton = createModelButton; + this.#showAltTextDialogButton = showAltTextDialogButton; + this.#overlayManager = overlayManager; + this.#eventBus = eventBus; + this.#mlManager = mlManager; + this.#boundOnClickCreateModel = this.#togglePref.bind( + this, + "enableGuessAltText" + ); + + const { altTextLearnMoreUrl } = mlManager; + if (altTextLearnMoreUrl) { + learnMore.href = altTextLearnMoreUrl; + } + + dialog.addEventListener("close", this.#close.bind(this)); + dialog.addEventListener("contextmenu", noContextMenu); + + createModelButton.addEventListener("click", async e => { + const checked = this.#togglePref("enableGuessAltText", e); + await mlManager.toggleService("altText", checked); + }); + + showAltTextDialogButton.addEventListener( + "click", + this.#togglePref.bind(this, "enableNewAltTextWhenAddingImage") + ); + + deleteModelButton.addEventListener("click", async () => { + await mlManager.deleteModel("altText"); + + aiModelSettings.classList.toggle("download", true); + createModelButton.removeEventListener( + "click", + this.#boundOnClickCreateModel + ); + createModelButton.setAttribute("aria-pressed", false); + this.#setPref("enableGuessAltText", false); + this.#setPref("enableAltTextModelDownload", false); + }); + + downloadModelButton.addEventListener("click", async () => { + downloadModelButton.disabled = true; + downloadModelButton.firstChild.setAttribute( + "data-l10n-id", + "pdfjs-editor-alt-text-settings-downloading-model-button" + ); + + await mlManager.downloadModel("altText"); + + aiModelSettings.classList.toggle("download", false); + downloadModelButton.firstChild.setAttribute( + "data-l10n-id", + "pdfjs-editor-alt-text-settings-download-model-button" + ); + createModelButton.addEventListener( + "click", + this.#boundOnClickCreateModel + ); + createModelButton.setAttribute("aria-pressed", true); + this.#setPref("enableGuessAltText", true); + mlManager.toggleService("altText", true); + this.#setPref("enableAltTextModelDownload", true); + downloadModelButton.disabled = false; + }); + + closeButton.addEventListener("click", this.#finish.bind(this)); + this.#overlayManager.register(dialog); + } + + async open({ enableGuessAltText, enableNewAltTextWhenAddingImage }) { + const { enableAltTextModelDownload } = this.#mlManager; + this.#createModelButton.disabled = !enableAltTextModelDownload; + this.#createModelButton.setAttribute( + "aria-pressed", + enableAltTextModelDownload && enableGuessAltText + ); + this.#showAltTextDialogButton.setAttribute( + "aria-pressed", + enableNewAltTextWhenAddingImage + ); + this.#aiModelSettings.classList.toggle( + "download", + !enableAltTextModelDownload + ); + + try { + await this.#overlayManager.open(this.#dialog); + } catch (ex) { + this.#close(); + throw ex; + } + } + + #togglePref(name, { target }) { + const checked = target.getAttribute("aria-pressed") !== "true"; + this.#setPref(name, checked); + target.setAttribute("aria-pressed", checked); + return checked; + } + + #setPref(name, value) { + this.#eventBus.dispatch("setpreference", { + source: this, + name, + value, + }); + } + + #finish() { + if (this.#overlayManager.active === this.#dialog) { + this.#overlayManager.close(this.#dialog); + } + } + + #close() { + this.#createModelButton.removeEventListener( + "click", + this.#boundOnClickCreateModel + ); + } +} + +export { ImageAltTextSettings, NewAltTextManager }; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 7f3e46825fadc..43089d007add6 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -221,6 +221,8 @@ class PDFViewer { #enableUpdatedAddImage = false; + #enableNewAltTextWhenAddingImage = false; + #eventAbortController = null; #mlManager = null; @@ -294,6 +296,8 @@ class PDFViewer { this.#enableHighlightFloatingButton = options.enableHighlightFloatingButton === true; this.#enableUpdatedAddImage = options.enableUpdatedAddImage === true; + this.#enableNewAltTextWhenAddingImage = + options.enableNewAltTextWhenAddingImage === true; this.imageResourcesPath = options.imageResourcesPath || ""; this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { @@ -894,6 +898,7 @@ class PDFViewer { this.#annotationEditorHighlightColors, this.#enableHighlightFloatingButton, this.#enableUpdatedAddImage, + this.#enableNewAltTextWhenAddingImage, this.#mlManager ); eventBus.dispatch("annotationeditoruimanager", { diff --git a/web/secondary_toolbar.js b/web/secondary_toolbar.js index 1206e4833fd45..8ba23f5304e5e 100644 --- a/web/secondary_toolbar.js +++ b/web/secondary_toolbar.js @@ -49,6 +49,8 @@ import { PagesCountLimit } from "./pdf_viewer.js"; * select tool. * @property {HTMLButtonElement} cursorHandToolButton - Button to enable the * hand tool. + * @property {HTMLButtonElement} imageAltTextSettingsButton - Button for opening + * the image alt-text settings dialog. * @property {HTMLButtonElement} documentPropertiesButton - Button for opening * the document properties dialog. */ @@ -137,6 +139,11 @@ class SecondaryToolbar { eventDetails: { mode: SpreadMode.EVEN }, close: true, }, + { + element: options.imageAltTextSettingsButton, + eventName: "imagealttextsettings", + close: true, + }, { element: options.documentPropertiesButton, eventName: "documentproperties", diff --git a/web/stubs-geckoview.js b/web/stubs-geckoview.js index 3c0669d0d731d..23b4ebb6af285 100644 --- a/web/stubs-geckoview.js +++ b/web/stubs-geckoview.js @@ -15,6 +15,7 @@ const AltTextManager = null; const AnnotationEditorParams = null; +const ImageAltTextSettings = null; const NewAltTextManager = null; const PDFAttachmentViewer = null; const PDFCursorTools = null; @@ -30,6 +31,7 @@ const SecondaryToolbar = null; export { AltTextManager, AnnotationEditorParams, + ImageAltTextSettings, NewAltTextManager, PDFAttachmentViewer, PDFCursorTools, diff --git a/web/viewer.css b/web/viewer.css index 9222edbfca843..c0895b7ad4b28 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -120,6 +120,9 @@ --secondaryToolbarButton-spreadNone-icon: url(images/secondaryToolbarButton-spreadNone.svg); --secondaryToolbarButton-spreadOdd-icon: url(images/secondaryToolbarButton-spreadOdd.svg); --secondaryToolbarButton-spreadEven-icon: url(images/secondaryToolbarButton-spreadEven.svg); + --secondaryToolbarButton-imageAltTextSettings-icon: var( + --toolbarButton-editorStamp-icon + ); --secondaryToolbarButton-documentProperties-icon: url(images/secondaryToolbarButton-documentProperties.svg); --editorParams-stampAddImage-icon: url(images/toolbarButton-zoomIn.svg); } @@ -1077,6 +1080,10 @@ a:is(.toolbarButton, .secondaryToolbarButton)[href="#"] { mask-image: var(--secondaryToolbarButton-documentProperties-icon); } +#imageAltTextSettings::before { + mask-image: var(--secondaryToolbarButton-imageAltTextSettings-icon); +} + .verticalToolbarSeparator { display: block; margin: 5px 2px; diff --git a/web/viewer.html b/web/viewer.html index ab8de74768345..3bcd43e0b0909 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -323,9 +323,14 @@ + + +
- @@ -593,6 +598,53 @@ + +
+
+ Image alt text settings +
+
+ Automatic alt text +
+
+
+ + +
+
+ Suggests descriptions to help people who can’t see the image or when the image doesn’t load. Learn more +
+
+
+
+ Alt text AI model (180MB) +
+ Runs locally on your device so your data stays private. Required for automatic alt text. +
+
+ + +
+
+
+
+
+ Alt text editor +
+
+ + +
+
+ Helps you make sure all your images have alt text. +
+
+
+
+ +
+
+
diff --git a/web/viewer.js b/web/viewer.js index 9f74733f10812..5f93fa321c489 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -93,6 +93,12 @@ function getViewerConfiguration() { spreadNoneButton: document.getElementById("spreadNone"), spreadOddButton: document.getElementById("spreadOdd"), spreadEvenButton: document.getElementById("spreadEven"), + imageAltTextSettingsButton: document.getElementById( + "imageAltTextSettings" + ), + imageAltTextSettingsSeparator: document.getElementById( + "imageAltTextSettingsSeparator" + ), documentPropertiesButton: document.getElementById("documentProperties"), }, sidebar: { @@ -188,6 +194,21 @@ function getViewerConfiguration() { notNowButton: document.getElementById("newAltTextNotNow"), saveButton: document.getElementById("newAltTextSave"), }, + altTextSettingsDialog: { + dialog: document.getElementById("altTextSettingsDialog"), + createModelButton: document.getElementById("createModelButton"), + aiModelSettings: document.getElementById("aiModelSettings"), + learnMore: document.getElementById("altTextSettingsLearnMore"), + deleteModelButton: document.getElementById("deleteModelButton"), + downloadModelButton: document.getElementById("downloadModelButton"), + showAltTextDialogButton: document.getElementById( + "showAltTextDialogButton" + ), + altTextSettingsCloseButton: document.getElementById( + "altTextSettingsCloseButton" + ), + closeButton: document.getElementById("altTextSettingsCloseButton"), + }, annotationEditorParams: { editorFreeTextFontSize: document.getElementById("editorFreeTextFontSize"), editorFreeTextColor: document.getElementById("editorFreeTextColor"),