diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties
index db404c896..56ccca1cd 100644
--- a/l10n/en-US/viewer.properties
+++ b/l10n/en-US/viewer.properties
@@ -263,3 +263,8 @@ editor_stamp_add_image.title=Add image
editor_free_text2_aria_label=Text Editor
editor_ink2_aria_label=Draw Editor
editor_ink_canvas_aria_label=User-created image
+
+# Alt-text dialog
+# LOCALIZATION NOTE (alt_text_button_label): Alternative text (alt text) helps
+# when people can't see the image.
+alt_text_button_label=Alt text
diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js
index 0641248fe..093402c66 100644
--- a/src/display/editor/editor.js
+++ b/src/display/editor/editor.js
@@ -34,6 +34,8 @@ import { FeatureTest, shadow, unreachable } from "../../shared/util.js";
* Base class for editors.
*/
class AnnotationEditor {
+ #altTextButton = null;
+
#keepAspectRatio = false;
#resizersDiv = null;
@@ -54,6 +56,8 @@ class AnnotationEditor {
_focusEventsAllowed = true;
+ _l10nPromise = null;
+
#isDraggable = false;
#zIndex = AnnotationEditor._zIndex++;
@@ -64,6 +68,10 @@ class AnnotationEditor {
static _zIndex = 1;
+ // When one of the dimensions of an editor is smaller than this value, the
+ // button to edit the alt text is visually moved outside of the editor.
+ static SMALL_EDITOR_SIZE = 0;
+
/**
* @param {AnnotationEditorParameters} parameters
*/
@@ -124,9 +132,17 @@ class AnnotationEditor {
/**
* Initialize the l10n stuff for this type of editor.
- * @param {Object} _l10n
+ * @param {Object} l10n
*/
- static initialize(_l10n) {
+ static initialize(l10n, options = null) {
+ AnnotationEditor._l10nPromise ||= new Map(
+ ["alt_text_button_label"].map(str => [str, l10n.get(str)])
+ );
+ if (options?.strings) {
+ for (const str of options.strings) {
+ AnnotationEditor._l10nPromise.set(str, l10n.get(str));
+ }
+ }
if (AnnotationEditor._borderLineWidth !== -1) {
return;
}
@@ -522,6 +538,11 @@ class AnnotationEditor {
if (!this.#keepAspectRatio) {
this.div.style.height = `${((100 * height) / parentHeight).toFixed(2)}%`;
}
+ this.#altTextButton?.classList.toggle(
+ "small",
+ width < AnnotationEditor.SMALL_EDITOR_SIZE ||
+ height < AnnotationEditor.SMALL_EDITOR_SIZE
+ );
}
fixDims() {
@@ -785,6 +806,40 @@ class AnnotationEditor {
this.fixAndSetPosition();
}
+ addAltTextButton() {
+ if (this.#altTextButton) {
+ return;
+ }
+ const altText = (this.#altTextButton = document.createElement("span"));
+ altText.className = "altText";
+ AnnotationEditor._l10nPromise.get("alt_text_button_label").then(msg => {
+ altText.textContent = msg;
+ });
+ altText.tabIndex = "0";
+ altText.addEventListener(
+ "click",
+ event => {
+ event.preventDefault();
+ },
+ { capture: true }
+ );
+ altText.addEventListener("keydown", event => {
+ if (event.target === altText && event.key === "Enter") {
+ event.preventDefault();
+ }
+ });
+ this.div.append(altText);
+ if (!AnnotationEditor.SMALL_EDITOR_SIZE) {
+ // We take the width of the alt text button and we add 40% to it to be
+ // sure to have enough space for it.
+ const PERCENT = 40;
+ AnnotationEditor.SMALL_EDITOR_SIZE = Math.min(
+ 128,
+ Math.round(altText.getBoundingClientRect().width * (1 + PERCENT / 100))
+ );
+ }
+ }
+
/**
* Render this editor in a div.
* @returns {HTMLDivElement}
@@ -1144,13 +1199,21 @@ class AnnotationEditor {
* When the user disables the editing mode some editors can change some of
* their properties.
*/
- disableEditing() {}
+ disableEditing() {
+ if (this.#altTextButton) {
+ this.#altTextButton.hidden = true;
+ }
+ }
/**
* When the user enables the editing mode some editors can change some of
* their properties.
*/
- enableEditing() {}
+ enableEditing() {
+ if (this.#altTextButton) {
+ this.#altTextButton.hidden = false;
+ }
+ }
/**
* The editor is about to be edited.
diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js
index 385492c0c..efad76948 100644
--- a/src/display/editor/freetext.js
+++ b/src/display/editor/freetext.js
@@ -56,8 +56,6 @@ class FreeTextEditor extends AnnotationEditor {
static _freeTextDefaultContent = "";
- static _l10nPromise;
-
static _internalPadding = 0;
static _defaultColor = null;
@@ -145,13 +143,9 @@ class FreeTextEditor extends AnnotationEditor {
/** @inheritdoc */
static initialize(l10n) {
- super.initialize(l10n);
- this._l10nPromise = new Map(
- ["free_text2_default_content", "editor_free_text2_aria_label"].map(
- str => [str, l10n.get(str)]
- )
- );
-
+ AnnotationEditor.initialize(l10n, {
+ strings: ["free_text2_default_content", "editor_free_text2_aria_label"],
+ });
const style = getComputedStyle(document.documentElement);
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
@@ -548,11 +542,11 @@ class FreeTextEditor extends AnnotationEditor {
this.editorDiv.setAttribute("id", this.#editorDivId);
this.enableEditing();
- FreeTextEditor._l10nPromise
+ AnnotationEditor._l10nPromise
.get("editor_free_text2_aria_label")
.then(msg => this.editorDiv?.setAttribute("aria-label", msg));
- FreeTextEditor._l10nPromise
+ AnnotationEditor._l10nPromise
.get("free_text2_default_content")
.then(msg => this.editorDiv?.setAttribute("default-content", msg));
this.editorDiv.contentEditable = true;
diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js
index 7651b5451..f56929b05 100644
--- a/src/display/editor/ink.js
+++ b/src/display/editor/ink.js
@@ -62,8 +62,6 @@ class InkEditor extends AnnotationEditor {
static _defaultThickness = 1;
- static _l10nPromise;
-
static _type = "ink";
constructor(params) {
@@ -84,13 +82,9 @@ class InkEditor extends AnnotationEditor {
/** @inheritdoc */
static initialize(l10n) {
- super.initialize(l10n);
- this._l10nPromise = new Map(
- ["editor_ink_canvas_aria_label", "editor_ink2_aria_label"].map(str => [
- str,
- l10n.get(str),
- ])
- );
+ AnnotationEditor.initialize(l10n, {
+ strings: ["editor_ink_canvas_aria_label", "editor_ink2_aria_label"],
+ });
}
/** @inheritdoc */
@@ -743,7 +737,7 @@ class InkEditor extends AnnotationEditor {
this.canvas.width = this.canvas.height = 0;
this.canvas.className = "inkEditorCanvas";
- InkEditor._l10nPromise
+ AnnotationEditor._l10nPromise
.get("editor_ink_canvas_aria_label")
.then(msg => this.canvas?.setAttribute("aria-label", msg));
this.div.append(this.canvas);
@@ -782,7 +776,7 @@ class InkEditor extends AnnotationEditor {
super.render();
- InkEditor._l10nPromise
+ AnnotationEditor._l10nPromise
.get("editor_ink2_aria_label")
.then(msg => this.div?.setAttribute("aria-label", msg));
diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js
index 61cb48fec..62651d51a 100644
--- a/src/display/editor/stamp.js
+++ b/src/display/editor/stamp.js
@@ -50,6 +50,11 @@ class StampEditor extends AnnotationEditor {
this.#bitmapFile = params.bitmapFile;
}
+ /** @inheritdoc */
+ static initialize(l10n) {
+ AnnotationEditor.initialize(l10n);
+ }
+
static get supportedTypes() {
// See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
// to know which types are supported by the browser.
@@ -306,6 +311,7 @@ class StampEditor extends AnnotationEditor {
this.parent.addUndoableEditor(this);
this.#hasBeenAddedInUndoStack = true;
}
+ this.addAltTextButton();
}
/**
diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js
index f0177494d..47c26e88d 100644
--- a/src/display/editor/tools.js
+++ b/src/display/editor/tools.js
@@ -741,6 +741,14 @@ class AnnotationEditorUIManager {
);
}
+ get direction() {
+ return shadow(
+ this,
+ "direction",
+ getComputedStyle(this.#container).direction
+ );
+ }
+
onPageChanging({ pageNumber }) {
this.#currentPageIndex = pageNumber - 1;
}
diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css
index abf1a2a11..2b7e3d621 100644
--- a/web/annotation_editor_layer_builder.css
+++ b/web/annotation_editor_layer_builder.css
@@ -37,6 +37,22 @@
/*#else*/
--editorInk-editing-cursor: url(images/cursor-editorInk.svg) 0 16, pointer;
/*#endif*/
+
+ --alt-text-add-image: url(images/altText_add.svg);
+ --alt-text-done-image: url(images/altText_done.svg);
+ --alt-text-bg-color: #2b2a33;
+ --alt-text-fg-color: #fbfbfe;
+ --alt-text-border-color: var(--alt-text-bg-color);
+ --alt-text-hover-bg-color: #52525e;
+ --alt-text-hover-fg-color: var(--alt-text-fg-color);
+ --alt-text-hover-border-color: var(--alt-text-hover-bg-color);
+ --alt-text-active-bg-color: #5b5b66;
+ --alt-text-active-fg-color: var(--alt-text-fg-color);
+ --alt-text-active-border-color: var(--alt-text-hover-bg-color);
+ --alt-text-focus-outline-color: #0060df;
+ --alt-text-focus-border-color: #f0f0f4;
+ --alt-text-shadow: 0 2px 6px 0 rgba(28, 27, 34, 0.5);
+ --alt-text-opacity: 0.8;
}
@media (min-resolution: 1.1dppx) {
@@ -53,6 +69,20 @@
--outline-color: CanvasText;
--outline-around-color: ButtonFace;
--resizer-bg-color: ButtonText;
+
+ --alt-text-bg-color: Canvas;
+ --alt-text-fg-color: ButtonText;
+ --alt-text-border-color: ButtonText;
+ --alt-text-hover-bg-color: Canvas;
+ --alt-text-hover-fg-color: SelectedItem;
+ --alt-text-hover-border-color: SelectedItem;
+ --alt-text-active-bg-color: ButtonFace;
+ --alt-text-active-fg-color: SelectedItem;
+ --alt-text-active-border-color: ButtonText;
+ --alt-text-focus-outline-color: CanvasText;
+ --alt-text-focus-border-color: ButtonText;
+ --alt-text-shadow: none;
+ --alt-text-opacity: 1;
}
}
@@ -331,4 +361,174 @@
}
}
}
+
+ &
+ :is(
+ [data-main-rotation="0"] [data-editor-rotation="90"],
+ [data-main-rotation="90"] [data-editor-rotation="0"],
+ [data-main-rotation="180"] [data-editor-rotation="270"],
+ [data-main-rotation="270"] [data-editor-rotation="180"],
+
+ ) {
+ & .altText {
+ rotate: 270deg;
+
+ &:dir(ltr) {
+ inset-inline-start: calc(100% - 8px);
+
+ &.small {
+ inset-inline-start: calc(100% + 8px);
+ inset-block-start: 100%;
+ }
+ }
+
+ &:dir(rtl) {
+ inset-block-end: calc(100% - 8px);
+
+ &.small {
+ inset-inline-start: -8px;
+ inset-block-start: 0;
+ }
+ }
+ }
+ }
+
+ &
+ :is(
+ [data-main-rotation="0"] [data-editor-rotation="180"],
+ [data-main-rotation="90"] [data-editor-rotation="90"],
+ [data-main-rotation="180"] [data-editor-rotation="0"],
+ [data-main-rotation="270"] [data-editor-rotation="270"],
+
+ ) {
+ & .altText {
+ rotate: 180deg;
+
+ inset-block-end: calc(100% - 8px);
+ inset-inline-start: calc(100% - 8px);
+
+ &.small {
+ inset-inline-start: 100%;
+ inset-block-start: -8px;
+ }
+ }
+ }
+
+ &
+ :is(
+ [data-main-rotation="0"] [data-editor-rotation="270"],
+ [data-main-rotation="90"] [data-editor-rotation="180"],
+ [data-main-rotation="180"] [data-editor-rotation="90"],
+ [data-main-rotation="270"] [data-editor-rotation="0"],
+
+ ) {
+ & .altText {
+ rotate: 90deg;
+
+ &:dir(ltr) {
+ inset-block-end: calc(100% - 8px);
+
+ &.small {
+ inset-inline-start: -8px;
+ inset-block-start: 0;
+ }
+ }
+
+ &:dir(rtl) {
+ inset-inline-start: calc(100% - 8px);
+
+ &.small {
+ inset-inline-start: calc(100% + 8px);
+ inset-block-start: 100%;
+ }
+ }
+ }
+ }
+}
+
+.altText {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: auto;
+ height: 24px;
+ min-width: 88px;
+ z-index: 1;
+ pointer-events: all;
+
+ color: var(--alt-text-fg-color);
+ font: menu;
+ font-size: 12px;
+ border-radius: 4px;
+ border: 1px solid var(--alt-text-border-color);
+ opacity: var(--alt-text-opacity);
+ background-color: var(--alt-text-bg-color);
+ box-shadow: var(--alt-text-shadow);
+
+ position: absolute;
+ inset-block-end: 8px;
+ inset-inline-start: 8px;
+
+ &:dir(ltr) {
+ transform-origin: 0 100%;
+ }
+ &:dir(rtl) {
+ transform-origin: 100% 100%;
+ }
+
+ &.small {
+ &:dir(ltr) {
+ transform-origin: 0 0;
+ }
+ &:dir(rtl) {
+ transform-origin: 100% 0;
+ }
+
+ inset-block-end: unset;
+ inset-inline-start: 0;
+ inset-block-start: calc(100% + 8px);
+ }
+
+ &:hover {
+ background-color: var(--alt-text-hover-bg-color);
+ border-color: var(--alt-text-hover-border-color);
+ color: var(--alt-text-hover-fg-color);
+ cursor: pointer;
+
+ &::before {
+ background-color: var(--alt-text-hover-fg-color);
+ }
+ }
+
+ &:active {
+ background-color: var(--alt-text-active-bg-color);
+ border-color: var(--alt-text-active-border-color);
+ color: var(--alt-text-active-fg-color);
+
+ &::before {
+ background-color: var(--alt-text-active-fg-color);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--alt-text-focus-outline-color);
+ border-color: var(--alt-text-focus-border-color);
+ }
+
+ &::before {
+ content: "";
+ mask-image: var(--alt-text-add-image);
+ mask-size: cover;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ background-color: var(--alt-text-fg-color);
+ margin-inline-end: 4px;
+ }
+
+ &.done::before {
+ mask-image: var(--alt-text-done-image);
+ }
}
diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js
index a15ffa79a..a6f1bbe08 100644
--- a/web/annotation_editor_layer_builder.js
+++ b/web/annotation_editor_layer_builder.js
@@ -82,6 +82,7 @@ class AnnotationEditorLayerBuilder {
div.className = "annotationEditorLayer";
div.tabIndex = 0;
div.hidden = true;
+ div.dir = this.#uiManager.direction;
this.pageDiv.append(div);
this.annotationEditorLayer = new AnnotationEditorLayer({
diff --git a/web/images/altText_add.svg b/web/images/altText_add.svg
new file mode 100644
index 000000000..3451b536c
--- /dev/null
+++ b/web/images/altText_add.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/images/altText_done.svg b/web/images/altText_done.svg
new file mode 100644
index 000000000..f54924ebf
--- /dev/null
+++ b/web/images/altText_done.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/l10n_utils.js b/web/l10n_utils.js
index 11663c22f..cc8b7d5c2 100644
--- a/web/l10n_utils.js
+++ b/web/l10n_utils.js
@@ -82,6 +82,7 @@ const DEFAULT_L10N_STRINGS = {
editor_free_text2_aria_label: "Text Editor",
editor_ink2_aria_label: "Draw Editor",
editor_ink_canvas_aria_label: "User-created image",
+ alt_text_button_label: "Alt text",
};
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
DEFAULT_L10N_STRINGS.print_progress_percent = "{{progress}}%";