diff --git a/navigator-html-injectables/CHANGELOG.MD b/navigator-html-injectables/CHANGELOG.MD index 02143553..6874b06e 100644 --- a/navigator-html-injectables/CHANGELOG.MD +++ b/navigator-html-injectables/CHANGELOG.MD @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2025-08-29 + +### Changed + +- Updated shared models. + ## [2.0.0] - 2025-08-01 ### Added diff --git a/navigator-html-injectables/package.json b/navigator-html-injectables/package.json index 20d3eb40..ceea4b01 100644 --- a/navigator-html-injectables/package.json +++ b/navigator-html-injectables/package.json @@ -1,6 +1,6 @@ { "name": "@readium/navigator-html-injectables", - "version": "2.0.0", + "version": "2.1.0", "type": "module", "description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator", "author": "readium", diff --git a/navigator/CHANGELOG.MD b/navigator/CHANGELOG.MD index 04e42eb7..55db8d77 100644 --- a/navigator/CHANGELOG.MD +++ b/navigator/CHANGELOG.MD @@ -5,11 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.0.1] - 2025-08-29 +## [2.1.0] - 2025-08-29 + +### Added + +- `Publication.metadata` now has an `accessibility` property. + +### Changed + +- Updated shared models. ### Fixed -- Fix `n > 1` discrepancy in ReadiumCSS `paginate` method when falling back to 1 column +- Fix `n > 1` discrepancy in ReadiumCSS `paginate` method when falling back to 1 column. ## [2.0.0] - 2025-08-01 diff --git a/navigator/package.json b/navigator/package.json index c3f5df89..9044dee6 100644 --- a/navigator/package.json +++ b/navigator/package.json @@ -1,6 +1,6 @@ { "name": "@readium/navigator", - "version": "2.0.1", + "version": "2.1.0", "type": "module", "description": "Next generation SDK for publications in Web Apps", "author": "readium", diff --git a/shared/CHANGELOG.MD b/shared/CHANGELOG.MD index b33dca9e..4c4f65ec 100644 --- a/shared/CHANGELOG.MD +++ b/shared/CHANGELOG.MD @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2025-08-29 + +### Added + +- `Accessibility` class has been added to the default profile. +- `Accessibility` property has been added to `Metadata`. +- `AccessibilityMetadataDisplayGuide` class has been added as a helper to handle display of accessibility metadata for user-facing features. + ## [2.0.0] - 2025-08-01 ### Added diff --git a/shared/package.json b/shared/package.json index bea32c7f..751d0c4e 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@readium/shared", - "version": "2.0.0", + "version": "2.1.0", "type": "module", "description": "Shared models to be used across other Readium projects and implementations in Typescript", "author": "readium", @@ -47,6 +47,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "update-accessibility-locales": "node update-accessibility-locales.mjs", + "postinstall": "npm run update-accessibility-locales", "test": "jest", "size": "size-limit" }, diff --git a/shared/src/publication/Metadata.ts b/shared/src/publication/Metadata.ts index cf7b669c..349bd07a 100644 --- a/shared/src/publication/Metadata.ts +++ b/shared/src/publication/Metadata.ts @@ -1,4 +1,4 @@ -/* Copyright 2021 Readium Foundation. All rights reserved. +/* Copyright 2025 Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license, * available in the LICENSE file present in the Github repository of the project. */ @@ -16,6 +16,7 @@ import { LocalizedString } from './LocalizedString'; import { Profile } from './Profiles'; import { ReadingProgression } from './ReadingProgression'; import { Subjects } from './Subject'; +import { Accessibility } from './accessibility/Accessibility'; import { TDM } from './TDM'; /** @@ -59,6 +60,7 @@ export class Metadata { public readingProgression?: ReadingProgression; public duration?: number; public numberOfPages?: number; + public accessibility?: Accessibility; public tdm?: TDM; public otherMetadata?: { [key: string]: any }; @@ -94,6 +96,7 @@ export class Metadata { 'readingProgression', 'duration', 'numberOfPages', + 'accessibility', 'tdm' ]; @@ -131,6 +134,7 @@ export class Metadata { readingProgression?: ReadingProgression; duration?: number; numberOfPages?: number; + accessibility?: Accessibility; tdm?: TDM; otherMetadata?: { [key: string]: any }; }) { @@ -184,6 +188,7 @@ export class Metadata { this.readingProgression = values.readingProgression; this.duration = values.duration; this.numberOfPages = values.numberOfPages; + this.accessibility = values.accessibility; this.tdm = values.tdm; this.otherMetadata = values.otherMetadata; } @@ -222,6 +227,7 @@ export class Metadata { const modified = datefromJSON(json.modified); const subjects = Subjects.deserialize(json.subject); const belongsTo = BelongsTo.deserialize(json.belongsTo); + const accessibility = Accessibility.deserialize(json.accessibility); const layout = json.layout; const readingProgression = json.readingProgression; const duration = positiveNumberfromJSON(json.duration); @@ -265,6 +271,7 @@ export class Metadata { readingProgression, duration, numberOfPages, + accessibility, tdm, otherMetadata }); @@ -308,6 +315,7 @@ export class Metadata { if (this.duration !== undefined) json.duration = this.duration; if (this.numberOfPages !== undefined) json.numberOfPages = this.numberOfPages; + if (this.accessibility) json.accessibility = this.accessibility.serialize(); if (this.tdm) json.tdm = this.tdm.serialize(); if (this.otherMetadata) { diff --git a/shared/src/publication/accessibility/Accessibility.ts b/shared/src/publication/accessibility/Accessibility.ts new file mode 100644 index 00000000..1482c088 --- /dev/null +++ b/shared/src/publication/accessibility/Accessibility.ts @@ -0,0 +1,860 @@ +/* Copyright 2025 Readium Foundation. All rights reserved. + * Use of this source code is governed by a BSD-style license, + * available in the LICENSE file present in the Github repository of the project. + */ + +/** + * Holds the accessibility metadata of a Publication. + * + * https://www.w3.org/2021/a11y-discov-vocab/latest/ + * https://readium.org/webpub-manifest/schema/a11y.schema.json + */ +export class Accessibility { + /** + * An established standard to which the described resource conforms. + */ + public conformsTo: AccessibilityProfile[]; + + /** + * Certification of accessible publications. + */ + public certification: Certification | null; + + /** + * A human-readable summary of specific accessibility features or deficiencies. + */ + public summary: string | null; + + /** + * The human sensory perceptual system through which a person may process or perceive information. + */ + public accessMode: AccessMode[]; + + /** + * A list of single or combined accessModes that are sufficient to understand all the intellectual content. + */ + public accessModeSufficient: PrimaryAccessMode[]; + + /** + * Content features of the resource. + */ + public feature: Feature[]; + + /** + * A characteristic of the described resource that is physiologically dangerous to some users. + */ + public hazard: Hazard[]; + + /** + * Justifications for non-conformance based on exemptions in a given jurisdiction. + */ + public exemption: Exemption[]; + + constructor(values: { + conformsTo?: AccessibilityProfile[], + certification?: Certification | null, + summary?: string | null, + accessMode?: AccessMode[], + accessModeSufficient?: PrimaryAccessMode[], + feature?: Feature[], + hazard?: Hazard[], + exemption?: Exemption[] + } = {}) { + this.conformsTo = values.conformsTo ?? []; + this.certification = values.certification ?? null; + this.summary = values.summary ?? null; + this.accessMode = values.accessMode ?? []; + this.accessModeSufficient = values.accessModeSufficient ?? []; + this.feature = values.feature ?? []; + this.hazard = values.hazard ?? []; + this.exemption = values.exemption ?? []; + } + + /** + * Parses an [Accessibility] from its RWPM JSON representation. + */ + public static deserialize(json: Record | string): Accessibility | undefined { + if (!json || typeof json !== 'object') return; + + type AccessibilityJson = { + conformsTo?: string[]; + certification?: { + certifiedBy: string; + credential: string; + report: string; + }; + summary?: string; + accessMode?: string[]; + accessModeSufficient?: string[][]; + feature?: string[]; + hazard?: string[]; + exemption?: string[]; + }; + + const accessibilityJson = json as AccessibilityJson; + + return new Accessibility({ + conformsTo: accessibilityJson.conformsTo + ? accessibilityJson.conformsTo.map(uri => AccessibilityProfile.deserialize(uri)) + .filter((profile): profile is AccessibilityProfile => profile !== undefined) + : undefined, + certification: accessibilityJson.certification + ? Certification.deserialize(accessibilityJson.certification) + : undefined, + summary: accessibilityJson.summary, + accessMode: accessibilityJson.accessMode + ? accessibilityJson.accessMode.map(value => AccessMode.deserialize(value)) + .filter((mode): mode is AccessMode => mode !== undefined) + : undefined, + accessModeSufficient: accessibilityJson.accessModeSufficient + ? accessibilityJson.accessModeSufficient.map(modes => PrimaryAccessMode.deserialize(modes)) + .filter((mode): mode is PrimaryAccessMode => mode !== undefined) + : undefined, + feature: accessibilityJson.feature + ? accessibilityJson.feature.map(value => Feature.deserialize(value)) + .filter((feature): feature is Feature => feature !== undefined) + : undefined, + hazard: accessibilityJson.hazard + ? accessibilityJson.hazard.map(value => Hazard.deserialize(value)) + .filter((hazard): hazard is Hazard => hazard !== undefined) + : undefined, + exemption: accessibilityJson.exemption + ? accessibilityJson.exemption.map(value => Exemption.deserialize(value)) + .filter((exemption): exemption is Exemption => exemption !== undefined) + : undefined + }); + } + + /** + * Serializes an [Accessibility] to its RWPM JSON representation. + */ + public serialize(): Record { + const result: Record = {}; + + if (this.conformsTo?.length > 0) { + result.conformsTo = this.conformsTo.map(profile => profile.serialize()); + } + if (this.certification !== undefined && this.certification !== null) { + result.certification = this.certification.serialize(); + } + if (this.summary !== undefined && this.summary !== null) { + result.summary = this.summary; + } + if (this.accessMode?.length > 0) { + result.accessMode = this.accessMode.map(mode => mode.serialize()); + } + if (this.accessModeSufficient?.length > 0) { + result.accessModeSufficient = this.accessModeSufficient.map(mode => mode.serialize()); + } + if (this.feature?.length > 0) { + result.feature = this.feature.map(feature => feature.serialize()); + } + if (this.hazard?.length > 0) { + result.hazard = this.hazard.map(hazard => hazard.serialize()); + } + if (this.exemption?.length > 0) { + result.exemption = this.exemption.map(exemption => exemption.serialize()); + } + + return result; + } +} + +export class AccessibilityProfile { + public readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + /** + * Parses an [AccessibilityProfile] from its RWPM JSON representation. + */ + public static deserialize(json: any): AccessibilityProfile | undefined { + if (!json || typeof json !== 'string') return; + return new AccessibilityProfile(json); + } + + /** + * Serializes an [AccessibilityProfile] to its RWPM JSON representation. + */ + public serialize(): any { + return this.uri; + } + + /** + * EPUB Accessibility 1.0 WCAG 2.0 A – http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-a + */ + public static readonly EPUB_A11Y_10_WCAG_20_A = new AccessibilityProfile('http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-a'); + + /** + * EPUB Accessibility 1.0 WCAG 2.0 AA – http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa + */ + public static readonly EPUB_A11Y_10_WCAG_20_AA = new AccessibilityProfile('http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa'); + + /** + * EPUB Accessibility 1.0 WCAG 2.0 AAA – http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aaa + */ + public static readonly EPUB_A11Y_10_WCAG_20_AAA = new AccessibilityProfile('http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aaa'); + + /** + * EPUB Accessibility 1.1 WCAG 2.0 A – https://www.w3.org/TR/epub-a11y-11#wcag-2.0-a + */ + public static readonly EPUB_A11Y_11_WCAG_20_A = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.0-a'); + + /** + * EPUB Accessibility 1.1 WCAG 2.0 AA – https://www.w3.org/TR/epub-a11y-11#wcag-2.0-aa + */ + public static readonly EPUB_A11Y_11_WCAG_20_AA = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.0-aa'); + + /** + * EPUB Accessibility 1.1 WCAG 2.0 AAA – https://www.w3.org/TR/epub-a11y-11#wcag-2.0-aaa + */ + public static readonly EPUB_A11Y_11_WCAG_20_AAA = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.0-aaa'); + + /** + * EPUB Accessibility 1.1 WCAG 2.1 A – https://www.w3.org/TR/epub-a11y-11#wcag-2.1-a + */ + public static readonly EPUB_A11Y_11_WCAG_21_A = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.1-a'); + + /** + * EPUB Accessibility 1.1 WCAG 2.1 AA – https://www.w3.org/TR/epub-a11y-11#wcag-2.1-aa + */ + public static readonly EPUB_A11Y_11_WCAG_21_AA = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.1-aa'); + + /** + * EPUB Accessibility 1.1 WCAG 2.1 AAA – https://www.w3.org/TR/epub-a11y-11#wcag-2.1-aaa + */ + public static readonly EPUB_A11Y_11_WCAG_21_AAA = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.1-aaa'); + + /** + * EPUB Accessibility 1.1 WCAG 2.2 A – https://www.w3.org/TR/epub-a11y-11#wcag-2.2-a + */ + public static readonly EPUB_A11Y_11_WCAG_22_A = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.2-a'); + + /** + * EPUB Accessibility 1.1 WCAG 2.2 AA – https://www.w3.org/TR/epub-a11y-11#wcag-2.2-aa + */ + public static readonly EPUB_A11Y_11_WCAG_22_AA = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.2-aa'); + + /** + * EPUB Accessibility 1.1 WCAG 2.2 AAA – https://www.w3.org/TR/epub-a11y-11#wcag-2.2-aaa + */ + public static readonly EPUB_A11Y_11_WCAG_22_AAA = new AccessibilityProfile('https://www.w3.org/TR/epub-a11y-11#wcag-2.2-aaa'); + + /** + * Returns true if the profile is a WCAG Level A profile. + */ + public get isWCAGLevelA(): boolean { + return this === AccessibilityProfile.EPUB_A11Y_10_WCAG_20_A || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_20_A || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_21_A || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_22_A; + } + + /** + * Returns true if the profile is a WCAG Level AA profile. + */ + public get isWCAGLevelAA(): boolean { + return this === AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_20_AA || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AA || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_22_AA; + } + + /** + * Returns true if the profile is a WCAG Level AAA profile. + */ + public get isWCAGLevelAAA(): boolean { + return this === AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AAA || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_20_AAA || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AAA || + this === AccessibilityProfile.EPUB_A11Y_11_WCAG_22_AAA; + } +} + +export class Certification { + public readonly certifiedBy: string | null; + public readonly credential: string | null; + public readonly report: string | null; + + constructor( + certifiedBy: string | null = null, + credential: string | null = null, + report: string | null = null + ) { + this.certifiedBy = certifiedBy; + this.credential = credential; + this.report = report; + } + + /** + * Parses a [Certification] from its RWPM JSON representation. + */ + public static deserialize(json: any): Certification | undefined { + if (!json || typeof json !== 'object') return; + return new Certification( + json.certifiedBy, + json.credential, + json.report + ); + } + + /** + * Serializes a [Certification] to its RWPM JSON representation. + */ + public serialize(): Record { + const json: any = {}; + if (this.certifiedBy) { + json.certifiedBy = this.certifiedBy; + } + if (this.credential) { + json.credential = this.credential; + } + if (this.report) { + json.report = this.report; + } + return json; + } +} + +export class AccessMode { + public readonly value: string; + + constructor(value: string) { + this.value = value; + } + + /** + * Parses an [AccessMode] from its RWPM JSON representation. + */ + public static deserialize(json: any): AccessMode | undefined { + if (!json || typeof json !== 'string') return; + return new AccessMode(json); + } + + /** + * Serializes an [AccessMode] to its RWPM JSON representation. + */ + public serialize(): any { + return this.value; + } + + /** + * Access mode for auditory content. + */ + public static readonly AUDITORY = new AccessMode('auditory'); + + /** + * Access mode for chart on visual content. + */ + public static readonly CHART_ON_VISUAL = new AccessMode('chartOnVisual'); + + /** + * Access mode for chemical on visual content. + */ + public static readonly CHEM_ON_VISUAL = new AccessMode('chemOnVisual'); + + /** + * Access mode for color dependent content. + */ + public static readonly COLOR_DEPENDENT = new AccessMode('colorDependent'); + + /** + * Access mode for diagram on visual content. + */ + public static readonly DIAGRAM_ON_VISUAL = new AccessMode('diagramOnVisual'); + + /** + * Access mode for math on visual content. + */ + public static readonly MATH_ON_VISUAL = new AccessMode('mathOnVisual'); + + /** + * Access mode for music on visual content. + */ + public static readonly MUSIC_ON_VISUAL = new AccessMode('musicOnVisual'); + + /** + * Access mode for tactile content. + */ + public static readonly TACTILE = new AccessMode('tactile'); + + /** + * Access mode for text on visual content. + */ + public static readonly TEXT_ON_VISUAL = new AccessMode('textOnVisual'); + + /** + * Access mode for textual content. + */ + public static readonly TEXTUAL = new AccessMode('textual'); + + /** + * Access mode for visual content. + */ + public static readonly VISUAL = new AccessMode('visual'); +} + +export class PrimaryAccessMode { + public readonly value!: string | string[]; + private static readonly VALID_MODES = new Set(['auditory', 'tactile', 'textual', 'visual']); + + constructor(value: string | string[]) { + if (typeof value === 'string') { + if (!PrimaryAccessMode.VALID_MODES.has(value.toLowerCase())) { + return; + } + this.value = value.toLowerCase(); + } else { + // Filter out invalid modes and duplicates + const validModes = value.filter(mode => + PrimaryAccessMode.VALID_MODES.has(mode.toLowerCase()) + ); + + if (validModes.length === 0) { + return; + } + + this.value = Array.from(new Set(validModes)); + } + } + + /** + * Parses a [PrimaryAccessMode] from its RWPM JSON representation. + */ + public static deserialize(json: any): PrimaryAccessMode | undefined { + if (!json) return; + + if (typeof json === 'string') { + return new PrimaryAccessMode(json); + } + + if (!Array.isArray(json)) return undefined; + + // Create a new array with only valid modes + const validModes = json.filter(mode => { + if (!mode) { + return false; + } + return PrimaryAccessMode.VALID_MODES.has(mode.toLowerCase()); + }); + + if (validModes.length === 0) { + return undefined; + } + + return new PrimaryAccessMode(validModes); + } + + /** + * Serializes a [PrimaryAccessMode] to its RWPM JSON representation. + */ + public serialize(): string | string[] { + return this.value; + } + + /** + * Primary access mode for auditory content. + */ + public static readonly AUDITORY = new PrimaryAccessMode('auditory'); + + /** + * Primary access mode for tactile content. + */ + public static readonly TACTILE = new PrimaryAccessMode('tactile'); + + /** + * Primary access mode for textual content. + */ + public static readonly TEXTUAL = new PrimaryAccessMode('textual'); + + /** + * Primary access mode for visual content. + */ + public static readonly VISUAL = new PrimaryAccessMode('visual'); +} + +export class Feature { + public readonly value: string; + + constructor(value: string) { + this.value = value; + } + + /** + * Parses a [Feature] from its RWPM JSON representation. + */ + public static deserialize(json: any): Feature | undefined { + if (!json || typeof json !== 'string') return; + return new Feature(json); + } + + /** + * Serializes a [Feature] to its RWPM JSON representation. + */ + public serialize(): any { + return this.value; + } + + /** + * Feature for no accessibility features. + */ + public static readonly NONE = new Feature('none'); + + /** + * Feature for annotations. + */ + public static readonly ANNOTATIONS = new Feature('annotations'); + + /** + * Feature for ARIA. + */ + public static readonly ARIA = new Feature('ARIA'); + + /** + * Feature for index. + */ + public static readonly INDEX = new Feature('index'); + + /** + * Feature for page break markers. + */ + public static readonly PAGE_BREAK_MARKERS = new Feature('pageBreakMarkers'); + + /** + * Feature for page navigation. + */ + public static readonly PAGE_NAVIGATION = new Feature('pageNavigation'); + + /** + * Feature for print page numbers. + */ + public static readonly PRINT_PAGE_NUMBERS = new Feature('printPageNumbers'); + + /** + * Feature for reading order. + */ + public static readonly READING_ORDER = new Feature('readingOrder'); + + /** + * Feature for structural navigation. + */ + public static readonly STRUCTURAL_NAVIGATION = new Feature('structuralNavigation'); + + /** + * Feature for table of contents. + */ + public static readonly TABLE_OF_CONTENTS = new Feature('tableOfContents'); + + /** + * Feature for tagged PDF. + */ + public static readonly TAGGED_PDF = new Feature('taggedPDF'); + + /** + * Feature for alternative text. + */ + public static readonly ALTERNATIVE_TEXT = new Feature('alternativeText'); + + /** + * Feature for audio description. + */ + public static readonly AUDIO_DESCRIPTION = new Feature('audioDescription'); + + /** + * Feature for captions. + */ + public static readonly CAPTIONS = new Feature('captions'); + + /** + * Feature for closed captions. + */ + public static readonly CLOSED_CAPTIONS = new Feature('closedCaptions'); + + /** + * Feature for described math. + */ + public static readonly DESCRIBED_MATH = new Feature('describedMath'); + + /** + * Feature for long description. + */ + public static readonly LONG_DESCRIPTION = new Feature('longDescription'); + + /** + * Feature for open captions. + */ + public static readonly OPEN_CAPTIONS = new Feature('openCaptions'); + + /** + * Feature for sign language. + */ + public static readonly SIGN_LANGUAGE = new Feature('signLanguage'); + + /** + * Feature for transcript. + */ + public static readonly TRANSCRIPT = new Feature('transcript'); + + /** + * Feature for display transformability. + */ + public static readonly DISPLAY_TRANSFORMABILITY = new Feature('displayTransformability'); + + /** + * Feature for synchronized audio text. + */ + public static readonly SYNCHRONIZED_AUDIO_TEXT = new Feature('synchronizedAudioText'); + + /** + * Feature for timing control. + */ + public static readonly TIMING_CONTROL = new Feature('timingControl'); + + /** + * Feature for unlocked. + */ + public static readonly UNLOCKED = new Feature('unlocked'); + + /** + * Feature for ChemML. + */ + public static readonly CHEM_ML = new Feature('ChemML'); + + /** + * Feature for LaTeX. + */ + public static readonly LATEX = new Feature('latex'); + + /** + * Feature for LaTeX chemistry. + */ + public static readonly LATEX_CHEMISTRY = new Feature('latex-chemistry'); + + /** + * Feature for MathML. + */ + public static readonly MATH_ML = new Feature('MathML'); + + /** + * Feature for MathML chemistry. + */ + public static readonly MATH_ML_CHEMISTRY = new Feature('MathML-chemistry'); + + /** + * Feature for TTS markup. + */ + public static readonly TTS_MARKUP = new Feature('ttsMarkup'); + + /** + * Feature for high contrast audio. + */ + public static readonly HIGH_CONTRAST_AUDIO = new Feature('highContrastAudio'); + + /** + * Feature for high contrast display. + */ + public static readonly HIGH_CONTRAST_DISPLAY = new Feature('highContrastDisplay'); + + /** + * Feature for large print. + */ + public static readonly LARGE_PRINT = new Feature('largePrint'); + + /** + * Feature for braille. + */ + public static readonly BRAILLE = new Feature('braille'); + + /** + * Feature for tactile graphic. + */ + public static readonly TACTILE_GRAPHIC = new Feature('tactileGraphic'); + + /** + * Feature for tactile object. + */ + public static readonly TACTILE_OBJECT = new Feature('tactileObject'); + + /** + * Feature for full ruby annotations. + */ + public static readonly FULL_RUBY_ANNOTATIONS = new Feature('fullRubyAnnotations'); + + /** + * Feature for horizontal writing. + */ + public static readonly HORIZONTAL_WRITING = new Feature('horizontalWriting'); + + /** + * Feature for ruby annotations. + */ + public static readonly RUBY_ANNOTATIONS = new Feature('rubyAnnotations'); + + /** + * Feature for vertical writing. + */ + public static readonly VERTICAL_WRITING = new Feature('verticalWriting'); + + /** + * Feature for additional word segmentation. + */ + public static readonly WITH_ADDITIONAL_WORD_SEGMENTATION = new Feature('withAdditionalWordSegmentation'); + + /** + * Feature for lack of additional word segmentation. + */ + public static readonly WITHOUT_ADDITIONAL_WORD_SEGMENTATION = new Feature('withoutAdditionalWordSegmentation'); +} + +export class Hazard { + public readonly value: string; + + constructor(value: string) { + this.value = value; + } + + /** + * Parses a [Hazard] from its RWPM JSON representation. + */ + public static deserialize(json: any): Hazard | undefined { + if (!json || typeof json !== 'string') return; + return new Hazard(json); + } + + /** + * Serializes a [Hazard] to its RWPM JSON representation. + */ + public serialize(): any { + return this.value; + } + + /** + * Hazard for flashing. + */ + public static readonly FLASHING = new Hazard('flashing'); + + /** + * Hazard for no flashing hazard. + */ + public static readonly NO_FLASHING_HAZARD = new Hazard('noFlashingHazard'); + + /** + * Hazard for unknown flashing hazard. + */ + public static readonly UNKNOWN_FLASHING_HAZARD = new Hazard('unknownFlashingHazard'); + + /** + * Hazard for motion simulation. + */ + public static readonly MOTION_SIMULATION = new Hazard('motionSimulation'); + + /** + * Hazard for no motion simulation hazard. + */ + public static readonly NO_MOTION_SIMULATION_HAZARD = new Hazard('noMotionSimulationHazard'); + + /** + * Hazard for unknown motion simulation hazard. + */ + public static readonly UNKNOWN_MOTION_SIMULATION_HAZARD = new Hazard('unknownMotionSimulationHazard'); + + /** + * Hazard for sound. + */ + public static readonly SOUND = new Hazard('sound'); + + /** + * Hazard for no sound hazard. + */ + public static readonly NO_SOUND_HAZARD = new Hazard('noSoundHazard'); + + /** + * Hazard for unknown sound hazard. + */ + public static readonly UNKNOWN_SOUND_HAZARD = new Hazard('unknownSoundHazard'); + + /** + * Hazard for unknown hazard. + */ + public static readonly UNKNOWN = new Hazard('unknown'); + + /** + * Hazard for no hazard. + */ + public static readonly NONE = new Hazard('none'); +} + +export class Exemption { + public readonly value: string; + + constructor(value: string) { + this.value = value; + } + + /** + * Parses an [Exemption] from its RWPM JSON representation. + */ + public static deserialize(json: any): Exemption | undefined { + if (!json || typeof json !== 'string') return; + return new Exemption(json); + } + + /** + * Serializes an [Exemption] to its RWPM JSON representation. + */ + public serialize(): any { + return this.value; + } + + /** + * None exemption. + */ + public static readonly NONE = new Exemption('none'); + + /** + * Documented exemption. + */ + public static readonly DOCUMENTED = new Exemption('documented'); + + /** + * Legal exemption. + */ + public static readonly LEGAL = new Exemption('legal'); + + /** + * Temporary exemption. + */ + public static readonly TEMPORARY = new Exemption('temporary'); + + /** + * Technical exemption. + */ + public static readonly TECHNICAL = new Exemption('technical'); + + /** + * EAA disproportionate burden exemption. + */ + public static readonly EAA_DISPROPORTIONATE_BURDEN = new Exemption('eaa-disproportionate-burden'); + + /** + * EAA fundamental alteration exemption. + */ + public static readonly EAA_FUNDAMENTAL_ALTERATION = new Exemption('eaa-fundamental-alteration'); + + /** + * EAA microenterprise exemption. + */ + public static readonly EAA_MICROENTERPRISE = new Exemption('eaa-microenterprise'); + + /** + * EAA technical impossibility exemption. + */ + public static readonly EAA_TECHNICAL_IMPOSSIBILITY = new Exemption('eaa-technical-impossibility'); + + /** + * EAA temporary exemption. + */ + public static readonly EAA_TEMPORARY = new Exemption('eaa-temporary'); +} diff --git a/shared/src/publication/accessibility/AccessibilityMetadataDisplayGuide.ts b/shared/src/publication/accessibility/AccessibilityMetadataDisplayGuide.ts new file mode 100644 index 00000000..b8481b73 --- /dev/null +++ b/shared/src/publication/accessibility/AccessibilityMetadataDisplayGuide.ts @@ -0,0 +1,1055 @@ +import { Accessibility, Feature, AccessMode, PrimaryAccessMode, Hazard } from './Accessibility'; +import { Publication } from '../Publication'; +import { AccessibilityProfile } from './Accessibility'; + +import { Localization } from './Localization'; +import { Layout } from '../Layout'; + +/** + * Represents a single accessibility claim + */ +export interface AccessibilityDisplayStatement { + id: string; + compactString?: string; + descriptiveString?: string; +} + +/** +* Represents a collection of related accessibility claims which should be +* displayed together in a section +*/ +export interface AccessibilityDisplayField { + // Unique identifier for this display field + id: string; + + // Title for this display field + title: string; + + // List of accessibility claims to display for this field + statements: AccessibilityDisplayStatement[]; + + // Indicates whether this display field should be rendered + shouldDisplay: boolean; +} + +/** + * Represents the different ways visual adjustments can be made + */ +export enum VisualAdjustments { + Unknown = 'unknown', + Modifiable = 'modifiable', + Unmodifiable = 'unmodifiable' +} + +/** + * Represents the different types of non-visual reading + */ +export enum NonvisualReading { + NoMetadata = 'noMetadata', + Readable = 'readable', + NotFully = 'notFully', + Unreadable = 'unreadable' +} + +/** + * Represents the different types of prerecorded audio + */ +export enum PrerecordedAudio { + NoMetadata = 'noMetadata', + Synchronized = 'synchronized', + AudioOnly = 'audioOnly', + AudioComplementary = 'audioComplementary' +} + +/** + * The ways of reading display field is a banner heading that groups + * together the following information about how the content facilitates + * access. + */ +export class WaysOfReading implements AccessibilityDisplayField { + public readonly id = "ways-of-reading.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + + public readonly visualAdjustments: VisualAdjustments; + public readonly nonvisualReading: NonvisualReading; + public readonly nonvisualReadingAltText: boolean; + public readonly prerecordedAudio: PrerecordedAudio; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor( + visualAdjustments: VisualAdjustments = VisualAdjustments.Unknown, + nonvisualReading: NonvisualReading = NonvisualReading.NoMetadata, + nonvisualReadingAltText: boolean = false, + prerecordedAudio: PrerecordedAudio = PrerecordedAudio.NoMetadata + ) { + this.visualAdjustments = visualAdjustments; + this.nonvisualReading = nonvisualReading; + this.nonvisualReadingAltText = nonvisualReadingAltText; + this.prerecordedAudio = prerecordedAudio; + + // This should be displayed even if there is no metadata + this.shouldDisplay = true; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.statements = []; + + // Visual Adjustments + const visualAdjustmentsKey = visualAdjustments === VisualAdjustments.Modifiable + ? "ways-of-reading.visual-adjustments.modifiable" + : visualAdjustments === VisualAdjustments.Unmodifiable + ? "ways-of-reading.visual-adjustments.unmodifiable" + : "ways-of-reading.visual-adjustments.unknown"; + + const visualAdjustmentsLocale = Localization.getString(visualAdjustmentsKey); + this.statements.push({ + id: visualAdjustmentsKey, + compactString: visualAdjustmentsLocale.compact, + descriptiveString: visualAdjustmentsLocale.descriptive + }); + + // Non-visual Reading + let nonvisualReadingKey = ""; + if (nonvisualReading === NonvisualReading.Readable) { + nonvisualReadingKey = "ways-of-reading.nonvisual-reading.readable"; + } else if (nonvisualReading === NonvisualReading.NotFully) { + nonvisualReadingKey = "ways-of-reading.nonvisual-reading.not-fully"; + } else if (nonvisualReading === NonvisualReading.Unreadable) { + nonvisualReadingKey = "ways-of-reading.nonvisual-reading.none"; + } else if (nonvisualReading === NonvisualReading.NoMetadata) { + nonvisualReadingKey = "ways-of-reading.nonvisual-reading.no-metadata"; + } + + const nonvisualReadingLocale = Localization.getString(nonvisualReadingKey); + this.statements.push({ + id: nonvisualReadingKey, + compactString: nonvisualReadingLocale.compact, + descriptiveString: nonvisualReadingLocale.descriptive + }); + + // Non-visual Reading Alt Text + if (nonvisualReadingAltText) { + const altTextLocale = Localization.getString("ways-of-reading.nonvisual-reading.alt-text"); + this.statements.push({ + id: "ways-of-reading.nonvisual-reading.alt-text", + compactString: altTextLocale.compact, + descriptiveString: altTextLocale.descriptive + }); + } + + // Prerecorded Audio + let prerecordedAudioKey = ""; + if (prerecordedAudio === PrerecordedAudio.Synchronized) { + prerecordedAudioKey = "ways-of-reading.prerecorded-audio.synchronized"; + } else if (prerecordedAudio === PrerecordedAudio.AudioOnly) { + prerecordedAudioKey = "ways-of-reading.prerecorded-audio.only"; + } else if (prerecordedAudio === PrerecordedAudio.AudioComplementary) { + prerecordedAudioKey = "ways-of-reading.prerecorded-audio.complementary"; + } else if (prerecordedAudio === PrerecordedAudio.NoMetadata) { + prerecordedAudioKey = "ways-of-reading.prerecorded-audio.no-metadata"; + } + + const prerecordedAudioLocale = Localization.getString(prerecordedAudioKey); + this.statements.push({ + id: prerecordedAudioKey, + compactString: prerecordedAudioLocale.compact, + descriptiveString: prerecordedAudioLocale.descriptive + }); + } + + public static fromPublication(publication: Publication): WaysOfReading { + const a11y = publication.metadata.accessibility ?? new Accessibility(); + const features = a11y.feature ?? []; + const isFXL = publication.metadata.layout === Layout.fixed; + + const visualAdjustments = features.some(f => f.value === Feature.DISPLAY_TRANSFORMABILITY.value) + ? VisualAdjustments.Modifiable + : isFXL + ? VisualAdjustments.Unmodifiable + : VisualAdjustments.Unknown; + + const accessModes = a11y.accessMode ?? []; + const accessModeSufficient = a11y.accessModeSufficient ?? []; + + const allText = accessModes.length > 0 && accessModes.every((m: AccessMode) => m.value === AccessMode.TEXTUAL.value) || + accessModeSufficient.some((modes: PrimaryAccessMode) => { + const modeValue = modes.value; + if (Array.isArray(modeValue)) { + return modeValue.every(mode => mode === AccessMode.TEXTUAL.value); + } + return modeValue === AccessMode.TEXTUAL.value; + }); + + const someText = accessModes.some((m: AccessMode) => m.value === AccessMode.TEXTUAL.value) || + accessModeSufficient.some((modes: PrimaryAccessMode) => { + const modeValue = modes.value; + if (Array.isArray(modeValue)) { + return modeValue.some(mode => mode === AccessMode.TEXTUAL.value); + } + return modeValue === AccessMode.TEXTUAL.value; + }); + + const noText = !(accessModes.length === 0 && accessModeSufficient.length === 0) && + !accessModes.some((m: AccessMode) => m.value === AccessMode.TEXTUAL.value) && + !accessModeSufficient.some((modes: PrimaryAccessMode) => { + const modeValue = modes.value; + if (Array.isArray(modeValue)) { + return modeValue.some(mode => mode === AccessMode.TEXTUAL.value); + } + return modeValue === AccessMode.TEXTUAL.value; + }); + + const hasTextAlt = features.some(f => [ + Feature.LONG_DESCRIPTION.value, + Feature.ALTERNATIVE_TEXT.value, + Feature.DESCRIBED_MATH.value, + Feature.TRANSCRIPT.value + ].includes(f.value)); + + const nonvisualReading = allText + ? NonvisualReading.Readable + : (someText || hasTextAlt) + ? NonvisualReading.NotFully + : noText + ? NonvisualReading.Unreadable + : NonvisualReading.NoMetadata; + + const nonvisualReadingAltText = hasTextAlt; + + const prerecordedAudio = features.some(f => f.value === Feature.SYNCHRONIZED_AUDIO_TEXT.value) + ? PrerecordedAudio.Synchronized + : accessModeSufficient.some((modes: PrimaryAccessMode) => { + const modeValue = modes.value; + if (Array.isArray(modeValue)) { + return modeValue.some(mode => mode === AccessMode.AUDITORY.value); + } + return modeValue === AccessMode.AUDITORY.value; + }) + ? PrerecordedAudio.AudioOnly + : accessModes.some((m: AccessMode) => m.value === AccessMode.AUDITORY.value) + ? PrerecordedAudio.AudioComplementary + : PrerecordedAudio.NoMetadata; + + return new WaysOfReading( + visualAdjustments, + nonvisualReading, + nonvisualReadingAltText, + prerecordedAudio + ); + } +} + +/** + * Navigation features of the content + */ +export class Navigation implements AccessibilityDisplayField { + public readonly id = "navigation.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + + public readonly noMetadata: boolean; + public readonly tableOfContents: boolean; + public readonly index: boolean; + public readonly headings: boolean; + public readonly page: boolean; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor( + tableOfContents: boolean = false, + index: boolean = false, + headings: boolean = false, + page: boolean = false + ) { + this.tableOfContents = tableOfContents; + this.index = index; + this.headings = headings; + this.page = page; + this.noMetadata = !tableOfContents && !index && !headings && !page; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.shouldDisplay = !this.noMetadata; + + this.statements = []; + if (tableOfContents) { + const tocLocale = Localization.getString("navigation.toc"); + this.statements.push({ + id: "navigation.toc", + compactString: tocLocale.compact, + descriptiveString: tocLocale.descriptive + }); + } + if (index) { + const indexLocale = Localization.getString("navigation.index"); + this.statements.push({ + id: "navigation.index", + compactString: indexLocale.compact, + descriptiveString: indexLocale.descriptive + }); + } + if (headings) { + const headingsLocale = Localization.getString("navigation.structural"); + this.statements.push({ + id: "navigation.structural", + compactString: headingsLocale.compact, + descriptiveString: headingsLocale.descriptive + }); + } + if (page) { + const pageNavLocale = Localization.getString("navigation.page-navigation"); + this.statements.push({ + id: "navigation.page-navigation", + compactString: pageNavLocale.compact, + descriptiveString: pageNavLocale.descriptive + }); + } + + if (this.statements.length === 0) { + const noNavLocale = Localization.getString("navigation.no-metadata"); + this.statements.push({ + id: "navigation.no-metadata", + compactString: noNavLocale.compact, + descriptiveString: noNavLocale.descriptive + }); + } + } + + public static fromPublication(publication: Publication): Navigation { + const a11y = publication.metadata.accessibility ?? new Accessibility(); + const features = a11y.feature ?? []; + + return new Navigation( + features.some(f => f.value === Feature.TABLE_OF_CONTENTS.value), + features.some(f => f.value === Feature.INDEX.value), + features.some(f => f.value === Feature.STRUCTURAL_NAVIGATION.value), + features.some(f => f.value === Feature.PAGE_NAVIGATION.value) + ); + } +} + +/** + * Represents the different types of rich content + */ +export enum RichContentType { + Math = 'math', + Chemistry = 'chemistry', + Music = 'music', + Diagram = 'diagram', + Chart = 'chart', + Graph = 'graph', + Table = 'table', + Image = 'image' +} + +/** + * Rich content features of the content + */ +export class RichContent implements AccessibilityDisplayField { + public readonly id = "rich-content.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + + public readonly noMetadata: boolean; + public readonly extendedAltTextDescriptions: boolean; + public readonly mathFormula: boolean; + public readonly mathFormulaAsMathML: boolean; + public readonly mathFormulaAsLaTeX: boolean; + public readonly chemicalFormulaAsMathML: boolean; + public readonly chemicalFormulaAsLaTeX: boolean; + public readonly closedCaptions: boolean; + public readonly openCaptions: boolean; + public readonly transcript: boolean; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor( + extendedAltTextDescriptions: boolean = false, + mathFormula: boolean = false, + mathFormulaAsMathML: boolean = false, + mathFormulaAsLaTeX: boolean = false, + chemicalFormulaAsMathML: boolean = false, + chemicalFormulaAsLaTeX: boolean = false, + closedCaptions: boolean = false, + openCaptions: boolean = false, + transcript: boolean = false + ) { + this.extendedAltTextDescriptions = extendedAltTextDescriptions; + this.mathFormula = mathFormula; + this.mathFormulaAsMathML = mathFormulaAsMathML; + this.mathFormulaAsLaTeX = mathFormulaAsLaTeX; + this.chemicalFormulaAsMathML = chemicalFormulaAsMathML; + this.chemicalFormulaAsLaTeX = chemicalFormulaAsLaTeX; + this.closedCaptions = closedCaptions; + this.openCaptions = openCaptions; + this.transcript = transcript; + this.noMetadata = !extendedAltTextDescriptions && !mathFormula && !mathFormulaAsMathML && + !mathFormulaAsLaTeX && !chemicalFormulaAsMathML && !chemicalFormulaAsLaTeX && + !closedCaptions && !openCaptions && !transcript; + + this.shouldDisplay = !this.noMetadata; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.statements = []; + if (extendedAltTextDescriptions) { + const extendedLocale = Localization.getString("rich-content.extended-descriptions"); + this.statements.push({ + id: "rich-content.extended-descriptions", + compactString: extendedLocale.compact, + descriptiveString: extendedLocale.descriptive + }); + } + if (mathFormula) { + const mathLocale = Localization.getString("rich-content.accessible-math-described"); + this.statements.push({ + id: "rich-content.accessible-math-described", + compactString: mathLocale.compact, + descriptiveString: mathLocale.descriptive + }); + } + if (mathFormulaAsMathML) { + const mathMLLocale = Localization.getString("rich-content.math-as-mathml"); + this.statements.push({ + id: "rich-content.math-as-mathml", + compactString: mathMLLocale.compact, + descriptiveString: mathMLLocale.descriptive + }); + } + if (mathFormulaAsLaTeX) { + const latexLocale = Localization.getString("rich-content.accessible-math-as-latex"); + this.statements.push({ + id: "rich-content.accessible-math-as-latex", + compactString: latexLocale.compact, + descriptiveString: latexLocale.descriptive + }); + } + if (chemicalFormulaAsMathML) { + const chemMLLocale = Localization.getString("rich-content.accessible-chemistry-as-mathml"); + this.statements.push({ + id: "rich-content.accessible-chemistry-as-mathml", + compactString: chemMLLocale.compact, + descriptiveString: chemMLLocale.descriptive + }); + } + if (chemicalFormulaAsLaTeX) { + const chemLatexLocale = Localization.getString("rich-content.accessible-chemistry-as-latex"); + this.statements.push({ + id: "rich-content.accessible-chemistry-as-latex", + compactString: chemLatexLocale.compact, + descriptiveString: chemLatexLocale.descriptive + }); + } + if (closedCaptions) { + const ccLocale = Localization.getString("rich-content.closed-captions"); + this.statements.push({ + id: "rich-content.closed-captions", + compactString: ccLocale.compact, + descriptiveString: ccLocale.descriptive + }); + } + if (openCaptions) { + const ocLocale = Localization.getString("rich-content.open-captions"); + this.statements.push({ + id: "rich-content.open-captions", + compactString: ocLocale.compact, + descriptiveString: ocLocale.descriptive + }); + } + if (transcript) { + const transcriptLocale = Localization.getString("rich-content.transcript"); + this.statements.push({ + id: "rich-content.transcript", + compactString: transcriptLocale.compact, + descriptiveString: transcriptLocale.descriptive + }); + } + + if (this.statements.length === 0) { + const unknownLocale = Localization.getString("rich-content.unknown"); + this.statements.push({ + id: "rich-content.unknown", + compactString: unknownLocale.compact, + descriptiveString: unknownLocale.descriptive + }); + } + } + + public static fromPublication(publication: Publication): RichContent { + const a11y = publication.metadata.accessibility ?? new Accessibility(); + const features = a11y.feature ?? []; + + return new RichContent( + features.some(f => f.value === Feature.LONG_DESCRIPTION.value), + features.some(f => f.value === Feature.DESCRIBED_MATH.value), + features.some(f => f.value === Feature.MATH_ML.value), + features.some(f => f.value === Feature.LATEX.value), + features.some(f => f.value === Feature.MATH_ML_CHEMISTRY.value), + features.some(f => f.value === Feature.LATEX_CHEMISTRY.value), + features.some(f => f.value === Feature.CLOSED_CAPTIONS.value), + features.some(f => f.value === Feature.OPEN_CAPTIONS.value), + features.some(f => f.value === Feature.TRANSCRIPT.value) + ); + } +} + +/** + * Represents additional accessibility information + */ +export class AdditionalInformation implements AccessibilityDisplayField { + public readonly id = "additional-accessibility-information.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + + public readonly noMetadata: boolean; + public readonly pageBreakMarkers: boolean; + public readonly aria: boolean; + public readonly audioDescriptions: boolean; + public readonly braille: boolean; + public readonly rubyAnnotations: boolean; + public readonly fullRubyAnnotations: boolean; + public readonly highAudioContrast: boolean; + public readonly highDisplayContrast: boolean; + public readonly largePrint: boolean; + public readonly signLanguage: boolean; + public readonly tactileGraphics: boolean; + public readonly tactileObjects: boolean; + public readonly textToSpeechHinting: boolean; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor( + pageBreakMarkers: boolean = false, + aria: boolean = false, + audioDescriptions: boolean = false, + braille: boolean = false, + rubyAnnotations: boolean = false, + fullRubyAnnotations: boolean = false, + highAudioContrast: boolean = false, + highDisplayContrast: boolean = false, + largePrint: boolean = false, + signLanguage: boolean = false, + tactileGraphics: boolean = false, + tactileObjects: boolean = false, + textToSpeechHinting: boolean = false + ) { + this.pageBreakMarkers = pageBreakMarkers; + this.aria = aria; + this.audioDescriptions = audioDescriptions; + this.braille = braille; + this.rubyAnnotations = rubyAnnotations; + this.fullRubyAnnotations = fullRubyAnnotations; + this.highAudioContrast = highAudioContrast; + this.highDisplayContrast = highDisplayContrast; + this.largePrint = largePrint; + this.signLanguage = signLanguage; + this.tactileGraphics = tactileGraphics; + this.tactileObjects = tactileObjects; + this.textToSpeechHinting = textToSpeechHinting; + this.noMetadata = !pageBreakMarkers && !aria && !audioDescriptions && + !braille && !rubyAnnotations && !fullRubyAnnotations && + !highAudioContrast && !highDisplayContrast && !largePrint && + !signLanguage && !tactileGraphics && !tactileObjects && !textToSpeechHinting; + + this.shouldDisplay = !this.noMetadata; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.statements = []; + if (pageBreakMarkers) { + const pageBreaksLocale = Localization.getString("additional-accessibility-information.page-breaks"); + this.statements.push({ + id: "additional-accessibility-information.page-breaks", + compactString: pageBreaksLocale.compact, + descriptiveString: pageBreaksLocale.descriptive + }); + } + if (aria) { + const ariaLocale = Localization.getString("additional-accessibility-information.aria"); + this.statements.push({ + id: "additional-accessibility-information.aria", + compactString: ariaLocale.compact, + descriptiveString: ariaLocale.descriptive + }); + } + if (audioDescriptions) { + const audioDescLocale = Localization.getString("additional-accessibility-information.audio-descriptions"); + this.statements.push({ + id: "additional-accessibility-information.audio-descriptions", + compactString: audioDescLocale.compact, + descriptiveString: audioDescLocale.descriptive + }); + } + if (braille) { + const brailleLocale = Localization.getString("additional-accessibility-information.braille"); + this.statements.push({ + id: "additional-accessibility-information.braille", + compactString: brailleLocale.compact, + descriptiveString: brailleLocale.descriptive + }); + } + if (rubyAnnotations) { + const rubyLocale = Localization.getString("additional-accessibility-information.ruby-annotations"); + this.statements.push({ + id: "additional-accessibility-information.ruby-annotations", + compactString: rubyLocale.compact, + descriptiveString: rubyLocale.descriptive + }); + } + if (fullRubyAnnotations) { + const fullRubyLocale = Localization.getString("additional-accessibility-information.full-ruby-annotations"); + this.statements.push({ + id: "additional-accessibility-information.full-ruby-annotations", + compactString: fullRubyLocale.compact, + descriptiveString: fullRubyLocale.descriptive + }); + } + if (highAudioContrast) { + const audioContrastLocale = Localization.getString("additional-accessibility-information.high-contrast-between-foreground-and-background-audio"); + this.statements.push({ + id: "additional-accessibility-information.high-contrast-between-foreground-and-background-audio", + compactString: audioContrastLocale.compact, + descriptiveString: audioContrastLocale.descriptive + }); + } + if (highDisplayContrast) { + const displayContrastLocale = Localization.getString("additional-accessibility-information.high-contrast-between-text-and-background"); + this.statements.push({ + id: "additional-accessibility-information.high-contrast-between-text-and-background", + compactString: displayContrastLocale.compact, + descriptiveString: displayContrastLocale.descriptive + }); + } + if (largePrint) { + const largePrintLocale = Localization.getString("additional-accessibility-information.large-print"); + this.statements.push({ + id: "additional-accessibility-information.large-print", + compactString: largePrintLocale.compact, + descriptiveString: largePrintLocale.descriptive + }); + } + if (signLanguage) { + const signLanguageLocale = Localization.getString("additional-accessibility-information.sign-language"); + this.statements.push({ + id: "additional-accessibility-information.sign-language", + compactString: signLanguageLocale.compact, + descriptiveString: signLanguageLocale.descriptive + }); + } + if (tactileGraphics) { + const tactileGraphicsLocale = Localization.getString("additional-accessibility-information.tactile-graphics"); + this.statements.push({ + id: "additional-accessibility-information.tactile-graphics", + compactString: tactileGraphicsLocale.compact, + descriptiveString: tactileGraphicsLocale.descriptive + }); + } + if (tactileObjects) { + const tactileObjectsLocale = Localization.getString("additional-accessibility-information.tactile-objects"); + this.statements.push({ + id: "additional-accessibility-information.tactile-objects", + compactString: tactileObjectsLocale.compact, + descriptiveString: tactileObjectsLocale.descriptive + }); + } + if (textToSpeechHinting) { + const ttsHintingLocale = Localization.getString("additional-accessibility-information.text-to-speech-hinting"); + this.statements.push({ + id: "additional-accessibility-information.text-to-speech-hinting", + compactString: ttsHintingLocale.compact, + descriptiveString: ttsHintingLocale.descriptive + }); + } + } + + public static fromPublication(publication: Publication): AdditionalInformation { + const a11y = publication.metadata.accessibility ?? new Accessibility(); + const features = a11y.feature ?? []; + + return new AdditionalInformation( + features.some(f => f.value === Feature.PAGE_BREAK_MARKERS.value || f.value === Feature.PRINT_PAGE_NUMBERS.value), + features.some(f => f.value === Feature.ARIA.value), + features.some(f => f.value === Feature.AUDIO_DESCRIPTION.value), + features.some(f => f.value === Feature.BRAILLE.value), + features.some(f => f.value === Feature.RUBY_ANNOTATIONS.value), + features.some(f => f.value === Feature.FULL_RUBY_ANNOTATIONS.value), + features.some(f => f.value === Feature.HIGH_CONTRAST_AUDIO.value), + features.some(f => f.value === Feature.HIGH_CONTRAST_DISPLAY.value), + features.some(f => f.value === Feature.LARGE_PRINT.value), + features.some(f => f.value === Feature.SIGN_LANGUAGE.value), + features.some(f => f.value === Feature.TACTILE_GRAPHIC.value), + features.some(f => f.value === Feature.TACTILE_OBJECT.value), + features.some(f => f.value === Feature.TTS_MARKUP.value) + ); + } +} + +export enum HazardType { + yes = 'yes', + no = 'no', + unknown = 'unknown', + noMetadata = 'noMetadata' +} + +/** + * Represents potential hazards in the content + */ +export class Hazards implements AccessibilityDisplayField { + public readonly id = "hazards.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + public readonly noMetadata: boolean; + public readonly noHazards: boolean; + public readonly unknown: boolean; + public readonly flashing: HazardType; + public readonly motion: HazardType; + public readonly sound: HazardType; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor( + flashing: HazardType = HazardType.unknown, + motion: HazardType = HazardType.unknown, + sound: HazardType = HazardType.unknown + ) { + this.flashing = flashing; + this.motion = motion; + this.sound = sound; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.noMetadata = flashing === HazardType.noMetadata && motion === HazardType.noMetadata && sound === HazardType.noMetadata; + this.noHazards = flashing === HazardType.no && motion === HazardType.no && sound === HazardType.no; + this.unknown = flashing === HazardType.unknown && motion === HazardType.unknown && sound === HazardType.unknown; + + this.shouldDisplay = !this.noMetadata; + + this.statements = []; + if (this.noHazards) { + const noneLocale = Localization.getString("hazards.none"); + this.statements.push({ + id: "hazards.none", + compactString: noneLocale.compact, + descriptiveString: noneLocale.descriptive + }); + } else if (this.unknown) { + const unknownLocale = Localization.getString("hazards.unknown"); + this.statements.push({ + id: "hazards.unknown", + compactString: unknownLocale.compact, + descriptiveString: unknownLocale.descriptive + }); + } else if (this.noMetadata) { + const noMetadataLocale = Localization.getString("hazards.no-metadata"); + this.statements.push({ + id: "hazards.no-metadata", + compactString: noMetadataLocale.compact, + descriptiveString: noMetadataLocale.descriptive + }); + } else { + if (flashing === HazardType.yes) { + const flashingLocale = Localization.getString("hazards.flashing"); + this.statements.push({ + id: "hazards.flashing", + compactString: flashingLocale.compact, + descriptiveString: flashingLocale.descriptive + }); + } else if (flashing === HazardType.unknown) { + const flashingUnknownLocale = Localization.getString("hazards.flashing-unknown"); + this.statements.push({ + id: "hazards.flashing-unknown", + compactString: flashingUnknownLocale.compact, + descriptiveString: flashingUnknownLocale.descriptive + }); + } else if (flashing === HazardType.no) { + const flashingNoneLocale = Localization.getString("hazards.flashing-none"); + this.statements.push({ + id: "hazards.flashing-none", + compactString: flashingNoneLocale.compact, + descriptiveString: flashingNoneLocale.descriptive + }); + } + if (motion === HazardType.yes) { + const motionLocale = Localization.getString("hazards.motion"); + this.statements.push({ + id: "hazards.motion", + compactString: motionLocale.compact, + descriptiveString: motionLocale.descriptive + }); + } else if (motion === HazardType.unknown) { + const motionUnknownLocale = Localization.getString("hazards.motion-unknown"); + this.statements.push({ + id: "hazards.motion-unknown", + compactString: motionUnknownLocale.compact, + descriptiveString: motionUnknownLocale.descriptive + }); + } else if (motion === HazardType.no) { + const motionNoneLocale = Localization.getString("hazards.motion-none"); + this.statements.push({ + id: "hazards.motion-none", + compactString: motionNoneLocale.compact, + descriptiveString: motionNoneLocale.descriptive + }); + } + if (sound === HazardType.yes) { + const soundLocale = Localization.getString("hazards.sound"); + this.statements.push({ + id: "hazards.sound", + compactString: soundLocale.compact, + descriptiveString: soundLocale.descriptive + }); + } else if (sound === HazardType.unknown) { + const soundUnknownLocale = Localization.getString("hazards.sound-unknown"); + this.statements.push({ + id: "hazards.sound-unknown", + compactString: soundUnknownLocale.compact, + descriptiveString: soundUnknownLocale.descriptive + }); + } else if (sound === HazardType.no) { + const soundNoneLocale = Localization.getString("hazards.sound-none"); + this.statements.push({ + id: "hazards.sound-none", + compactString: soundNoneLocale.compact, + descriptiveString: soundNoneLocale.descriptive + }); + } + } + } + + public static fromPublication(publication: Publication): Hazards { + const hazards = publication.metadata.accessibility?.hazard ?? []; + + let fallback: HazardType; + if (hazards.some(h => h.value === Hazard.NONE.value)) { + fallback = HazardType.no; + } else if (hazards.some(h => h.value === Hazard.UNKNOWN.value)) { + fallback = HazardType.unknown; + } else { + fallback = HazardType.noMetadata; + } + + let flashing: HazardType; + if (hazards.some(h => h.value === Hazard.FLASHING.value)) { + flashing = HazardType.yes; + } else if (hazards.some(h => h.value === Hazard.NO_FLASHING_HAZARD.value)) { + flashing = HazardType.no; + } else if (hazards.some(h => h.value === Hazard.UNKNOWN_FLASHING_HAZARD.value)) { + flashing = HazardType.unknown; + } else { + flashing = fallback; + } + + let motion: HazardType; + if (hazards.some(h => h.value === Hazard.MOTION_SIMULATION.value)) { + motion = HazardType.yes; + } else if (hazards.some(h => h.value === Hazard.NO_MOTION_SIMULATION_HAZARD.value)) { + motion = HazardType.no; + } else if (hazards.some(h => h.value === Hazard.UNKNOWN_MOTION_SIMULATION_HAZARD.value)) { + motion = HazardType.unknown; + } else { + motion = fallback; + } + + let sound: HazardType; + if (hazards.some(h => h.value === Hazard.SOUND.value)) { + sound = HazardType.yes; + } else if (hazards.some(h => h.value === Hazard.NO_SOUND_HAZARD.value)) { + sound = HazardType.no; + } else if (hazards.some(h => h.value === Hazard.UNKNOWN_SOUND_HAZARD.value)) { + sound = HazardType.unknown; + } else { + sound = fallback; + } + + return new Hazards(flashing, motion, sound); + } +} + +/** +* Represents conformance to accessibility standards +*/ +export class Conformance implements AccessibilityDisplayField { + public readonly id = "conformance.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + public readonly profiles: AccessibilityProfile[]; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor(profiles: AccessibilityProfile[] = []) { + this.profiles = profiles; + + // This should be displayed even if there is no metadata + this.shouldDisplay = true; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.statements = []; + if (profiles.length === 0) { + const noConformanceLocale = Localization.getString("conformance.no"); + this.statements.push({ + id: "conformance.no", + compactString: noConformanceLocale.compact, + descriptiveString: noConformanceLocale.descriptive + }); + return; + } + + if (profiles.some(profile => profile.isWCAGLevelAAA)) { + const aaaLocale = Localization.getString("conformance.aaa"); + this.statements.push({ + id: "conformance.aaa", + compactString: aaaLocale.compact, + descriptiveString: aaaLocale.descriptive + }); + } else if (profiles.some(profile => profile.isWCAGLevelAA)) { + const aaLocale = Localization.getString("conformance.aa"); + this.statements.push({ + id: "conformance.aa", + compactString: aaLocale.compact, + descriptiveString: aaLocale.descriptive + }); + } else if (profiles.some(profile => profile.isWCAGLevelA)) { + const aLocale = Localization.getString("conformance.a"); + this.statements.push({ + id: "conformance.a", + compactString: aLocale.compact, + descriptiveString: aLocale.descriptive + }); + } else { + const unknownLocale = Localization.getString("conformance.unknown-standard"); + this.statements.push({ + id: "conformance.unknown-standard", + compactString: unknownLocale.compact, + descriptiveString: unknownLocale.descriptive + }); + } + } + + public static fromPublication(publication: Publication): Conformance { + const profiles = publication.metadata.accessibility?.conformsTo ?? []; + return new Conformance(profiles); + } +} +/** + * Represents legal exemptions + */ +export class Legal implements AccessibilityDisplayField { + public readonly id = "legal-considerations.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + public readonly exemption: boolean; + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor( + exemption: boolean = false + ) { + this.exemption = exemption; + this.shouldDisplay = this.exemption; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + this.statements = []; + if (exemption) { + const exemptLocale = Localization.getString("legal-considerations.exempt"); + this.statements.push({ + id: "legal-considerations.exempt", + compactString: exemptLocale.compact, + descriptiveString: exemptLocale.descriptive + }); + } else { + const noMetadataLocale = Localization.getString("legal-considerations.no-metadata"); + this.statements.push({ + id: "legal-considerations.no-metadata", + compactString: noMetadataLocale.compact, + descriptiveString: noMetadataLocale.descriptive + }); + } + } + + public static fromPublication(publication: Publication): Legal { + const exemptions = publication.metadata.accessibility?.exemption ?? []; + const hasExemption = exemptions.length > 0; + return new Legal(hasExemption); + } +} + +/** +* Represents the accessibility summary +*/ +export class AccessibilitySummary implements AccessibilityDisplayField { + public readonly id = "accessibility-summary.title"; + public readonly title: string; + public readonly shouldDisplay: boolean; + + public readonly statements: AccessibilityDisplayStatement[]; + + private constructor(publication: Publication) { + this.shouldDisplay = true; + + const titleLocale = Localization.getString(this.id); + this.title = titleLocale.compact; + + const summary = publication.metadata.accessibility?.summary; + this.statements = []; + + if (this.shouldDisplay && summary) { + this.statements.push({ + id: "accessibility-summary.summary", + compactString: summary, + descriptiveString: summary + }); + } else { + const locale = Localization.getString("accessibility-summary.no-metadata"); + this.statements.push({ + id: "accessibility-summary.no-metadata", + compactString: locale.compact, + descriptiveString: locale.descriptive + }); + } + } + + public static fromPublication(publication: Publication): AccessibilitySummary { + return new AccessibilitySummary(publication); + } +} + +/** + * Main accessibility metadata display guide + */ +export class AccessibilityMetadataDisplayGuide { + public readonly waysOfReading: WaysOfReading; + public readonly navigation: Navigation; + public readonly richContent: RichContent; + public readonly additionalInformation: AdditionalInformation; + public readonly hazards: Hazards; + public readonly conformance: Conformance; + public readonly legal: Legal; + public readonly accessibilitySummary: AccessibilitySummary; + public readonly fields: AccessibilityDisplayField[]; + + constructor(publication: Publication) { + this.waysOfReading = WaysOfReading.fromPublication(publication); + this.navigation = Navigation.fromPublication(publication); + this.richContent = RichContent.fromPublication(publication); + this.additionalInformation = AdditionalInformation.fromPublication(publication); + this.hazards = Hazards.fromPublication(publication); + this.conformance = Conformance.fromPublication(publication); + this.legal = Legal.fromPublication(publication); + this.accessibilitySummary = AccessibilitySummary.fromPublication(publication); + + this.fields = [ + this.waysOfReading, + this.navigation, + this.richContent, + this.additionalInformation, + this.hazards, + this.conformance, + this.legal, + this.accessibilitySummary + ]; + } +} \ No newline at end of file diff --git a/shared/src/publication/accessibility/Localization.md b/shared/src/publication/accessibility/Localization.md new file mode 100644 index 00000000..3242b2af --- /dev/null +++ b/shared/src/publication/accessibility/Localization.md @@ -0,0 +1,203 @@ +# Accessibility Metadata Localization + +This document explains how to use and customize the localization system for accessibility metadata in the `@readium/shared` package. + +## Overview + +The localization system allows consumers to provide their own translations for accessibility metadata strings while falling back to English if none is provided. The system uses a singleton pattern for easy access throughout the application and supports multiple registered locales. + +## Default Behavior + +By default, the system uses English (`en`) locale strings that are bundled with the package. These strings are included in the package and cover all accessibility metadata display needs. + +## Using the Localization System + +### Registering Custom Locales + +You can register new locales or override existing ones using the `registerLocale` method: + +```typescript +// Register a new locale +Localization.registerLocale('es', { + conformance: { + aaa: { + compact: "WCAG 2.1 Nivel AAA", + descriptive: "Esta publicación cumple con WCAG 2.1 Nivel AAA." + } + } +}); + +// Or override an existing locale +Localization.registerLocale('en', { + conformance: { + aaa: { + compact: "WCAG 2.1 Level AAA (Custom)", + descriptive: "This publication conforms to WCAG 2.1 Level AAA (Custom)." + } + } +}); +``` + +### Setting the Current Locale + +```typescript +// Set the current locale by language code +Localization.setLocale('es'); + +// Check if the locale was set successfully +if (Localization.setLocale('es')) { + console.log('Locale set successfully'); +} else { + console.log('Locale not available'); +} +``` + +### Getting Localized Strings + +```typescript +// Get a localized string +const text = Localization.getString('conformance.aaa'); +// Returns: { compact: "WCAG 2.1 Nivel AAA", descriptive: "..." } +``` + +### Getting Available Locales + +```typescript +// Get list of available locale codes +const availableLocales = Localization.getAvailableLocales(); +// Returns: ['en', 'fr', 'es', ...] + +// Get current locale code +const currentLocale = Localization.getCurrentLocale(); +// Returns: 'es' +``` + +## Locale Object Structure + +The locale object is a nested structure where: +- Keys are nested objects representing the path to the value +- Values can be either: + - A string, which will be used for both `compact` and `descriptive` values + - An object with `compact` and `descriptive` string properties + +Example of valid locale values: + +```typescript +const locale = { + conformance: { + aaa: "This publication conforms to WCAG 2.1 Level AAA", + hazards: { + none: { + compact: "No hazards", + descriptive: "This content is known to be free of hazards." + } + } + } +}; +``` + +## Fallback Behavior + +If a key is not found in the custom locale, the system will fall back to the English locale. If the key is not found in either locale, a warning will be logged to the console and an empty string will be returned. + +## Available Locale Keys + +The following keys are used throughout the accessibility metadata system: + +### Conformance +- `conformance.no` +- `conformance.a` +- `conformance.aa` +- `conformance.aaa` +- `conformance.unknown-standard` + +### Hazards +- `hazards.none` +- `hazards.unknown` +- `hazards.no-metadata` +- `hazards.flashing` +- `hazards.flashing-unknown` +- `hazards.flashing-none` +- `hazards.motion` +- `hazards.motion-unknown` +- `hazards.motion-none` +- `hazards.sound` +- `hazards.sound-unknown` +- `hazards.sound-none` + +### Navigation +- `navigation.toc` +- `navigation.index` +- `navigation.structural` +- `navigation.page-navigation` +- `navigation.no-metadata` + +### Rich Content +- `rich-content.extended-descriptions` +- `rich-content.accessible-math-described` +- `rich-content.accessible-math-as-mathml` +- `rich-content.accessible-math-as-latex` +- `rich-content.accessible-chemistry-as-mathml` +- `rich-content.accessible-chemistry-as-latex` +- `rich-content.closed-captions` +- `rich-content.open-captions` +- `rich-content.transcript` +- `rich-content.unknown` + +### Additional Accessibility Information +- `additional-accessibility-information.page-breaks` +- `additional-accessibility-information.aria` +- `additional-accessibility-information.audio-descriptions` +- `additional-accessibility-information.braille` +- `additional-accessibility-information.ruby-annotations` +- `additional-accessibility-information.full-ruby-annotations` +- `additional-accessibility-information.high-contrast-between-foreground-and-background-audio` +- `additional-accessibility-information.high-contrast-between-text-and-background` +- `additional-accessibility-information.large-print` +- `additional-accessibility-information.sign-language` +- `additional-accessibility-information.tactile-graphics` +- `additional-accessibility-information.tactile-objects` +- `additional-accessibility-information.text-to-speech-hinting` + +### Legal Considerations +- `legal-considerations.exempt` +- `legal-considerations.no-metadata` + +### Accessibility Summary +- `accessibility-summary.no-metadata` + +## Best Practices + +1. **Provide both compact and descriptive versions** when possible by using an object with both properties. This allows for more precise control over the display text. +2. **Test your custom locales** to ensure all necessary keys are provided. +3. **Handle missing translations gracefully** - the system will log warnings for missing keys. + +## TypeScript Support + +When using TypeScript, the locale object should match the following structure: + +```typescript +type LocalizedValue = string | { + compact: string; + descriptive: string; +}; + +type LocaleObject = { + [key: string]: LocalizedValue; +}; + +// Example usage: +const customLocale = { + conformance: { + aaa: "This publication conforms to WCAG 2.1 Level AAA", + ... + }, + hazards: { + none: { + compact: "No hazards", + descriptive: "This content is known to be free of hazards." + }, + ... + } +}; +``` diff --git a/shared/src/publication/accessibility/Localization.ts b/shared/src/publication/accessibility/Localization.ts new file mode 100644 index 00000000..c1451bd2 --- /dev/null +++ b/shared/src/publication/accessibility/Localization.ts @@ -0,0 +1,115 @@ +// Localization.ts +import enUS from './locales/en.json'; +import frFR from './locales/fr.json'; + +export interface L10nString { + compact: string; + descriptive: string; +} + +type LocaleData = Record; + +class LocalizationImpl { + private static instance: LocalizationImpl; + private currentLocaleCode: string = 'en'; + private locale: LocaleData = enUS; + private availableLocales: Record = { + 'en': enUS, + 'fr': frFR + }; + + private constructor() {} + + public static getInstance(): LocalizationImpl { + if (!LocalizationImpl.instance) { + LocalizationImpl.instance = new LocalizationImpl(); + } + return LocalizationImpl.instance; + } + + /** + * Registers a new locale or updates an existing one + * @param localeCode BCP 47 language code (e.g., 'en', 'fr-FR') + * @param localeData The locale data to register + */ + public registerLocale(localeCode: string, localeData: LocaleData): void { + if (!localeCode || typeof localeCode !== 'string') { + throw new Error('Locale code must be a non-empty string'); + } + this.availableLocales[localeCode] = localeData; + } + + /** + * Sets the current locale by language code + * @param localeCode BCP 47 language code (e.g., 'en', 'fr-FR') + * @returns boolean indicating if the locale was set successfully + */ + public setLocale(localeCode: string): boolean { + if (!(localeCode in this.availableLocales)) { + console.warn(`Locale '${localeCode}' is not available`); + return false; + } + this.locale = this.availableLocales[localeCode]; + this.currentLocaleCode = localeCode; + return true; + } + + /** + * Gets the current locale code (BCP 47) + */ + public getCurrentLocale(): string { + return this.currentLocaleCode; + } + + /** + * Gets a list of available locale codes + */ + public getAvailableLocales(): string[] { + return Object.keys(this.availableLocales); + } + + private getNestedValue(obj: any, path: string): string | L10nString | undefined { + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined; + } + current = current[part]; + } + + return current; + } + + /** + * Gets a localized string by key + * @param key The key for the string to retrieve + * @returns The localized string as a [L10nString], or an empty string if not found + */ + public getString(key: string): L10nString { + // First try the current locale + let value = this.getNestedValue(this.locale, key); + + // If not found and current locale is not English, try falling back to English + if (value === undefined && this.currentLocaleCode !== 'en') { + value = this.getNestedValue(this.availableLocales['en'], key); + } + + // If we have a value, return it with proper formatting + if (value !== undefined) { + return typeof value === 'string' + ? { compact: value, descriptive: value } + : value; + } + + // If we get here, the key wasn't found in either locale + console.warn(`Missing localization for key: ${key}`); + return { compact: '', descriptive: '' }; + } +} + +/** + * The singleton instance of the [Localization] class. + */ +export const Localization = LocalizationImpl.getInstance(); \ No newline at end of file diff --git a/shared/src/publication/accessibility/index.ts b/shared/src/publication/accessibility/index.ts new file mode 100644 index 00000000..dac1d0cf --- /dev/null +++ b/shared/src/publication/accessibility/index.ts @@ -0,0 +1,3 @@ +export * from './Accessibility'; +export * from './AccessibilityMetadataDisplayGuide'; +export * from './Localization'; \ No newline at end of file diff --git a/shared/src/publication/accessibility/locales/en.json b/shared/src/publication/accessibility/locales/en.json new file mode 100644 index 00000000..df649d43 --- /dev/null +++ b/shared/src/publication/accessibility/locales/en.json @@ -0,0 +1,231 @@ +{ + "ways-of-reading": { + "title": "Ways of reading", + "nonvisual-reading": { + "alt-text": { + "compact": "Has alternative text", + "descriptive": "Has alternative text descriptions for images" + }, + "no-metadata": "No information about nonvisual reading is available", + "none": { + "compact": "Not readable in read aloud or dynamic braille", + "descriptive": "The content is not readable as read aloud speech or dynamic braille" + }, + "not-fully": { + "compact": "Not fully readable in read aloud or dynamic braille", + "descriptive": "Not all of the content will be readable as read aloud speech or dynamic braille" + }, + "readable": { + "compact": "Readable in read aloud or dynamic braille", + "descriptive": "All content can be read as read aloud speech or dynamic braille" + } + }, + "prerecorded-audio": { + "complementary": { + "compact": "Prerecorded audio clips", + "descriptive": "Prerecorded audio clips are embedded in the content" + }, + "no-metadata": "No information about prerecorded audio is available", + "only": { + "compact": "Prerecorded audio only", + "descriptive": "Audiobook with no text alternative" + }, + "synchronized": { + "compact": "Prerecorded audio synchronized with text", + "descriptive": "All the content is available as prerecorded audio synchronized with text" + } + }, + "visual-adjustments": { + "modifiable": { + "compact": "Appearance can be modified", + "descriptive": "Appearance of the text and page layout can be modified according to the capabilities of the reading system (font family and font size, spaces between paragraphs, sentences, words, and letters, as well as color of background and text)" + }, + "unknown": "No information about appearance modifiability is available", + "unmodifiable": { + "compact": "Appearance cannot be modified", + "descriptive": "Text and page layout cannot be modified as the reading experience is close to a print version, but reading systems can still provide zooming options" + } + } + }, + "conformance": { + "title": "Conformance", + "details-title": "Detailed conformance information", + "a": { + "compact": "This publication meets minimum accessibility standards", + "descriptive": "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level A standard" + }, + "aa": { + "compact": "This publication meets accepted accessibility standards", + "descriptive": "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level AA standard" + }, + "aaa": { + "compact": "This publication exceeds accepted accessibility standards", + "descriptive": "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level AAA standard" + }, + "no": "No information is available", + "unknown-standard": "Conformance to accepted standards for accessibility of this publication cannot be determined", + "certifier": "The publication was certified by ", + "certifier-credentials": "The certifier's credential is ", + "details": { + "certification-info": "The publication was certified on ", + "certifier-report": "For more information refer to the certifier's report", + "claim": "This publication claims to meet", + "epub-accessibility-1-0": "EPUB Accessibility 1.0", + "epub-accessibility-1-1": "EPUB Accessibility 1.1", + "level-a": "Level A", + "level-aa": "Level AA", + "level-aaa": "Level AAA", + "wcag-2-0": { + "compact": "WCAG 2.0", + "descriptive": "Web Content Accessibility Guidelines (WCAG) 2.0" + }, + "wcag-2-1": { + "compact": "WCAG 2.1", + "descriptive": "Web Content Accessibility Guidelines (WCAG) 2.1" + }, + "wcag-2-2": { + "compact": "WCAG 2.2", + "descriptive": "Web Content Accessibility Guidelines (WCAG) 2.2" + } + } + }, + "navigation": { + "title": "Navigation", + "index": { + "compact": "Index", + "descriptive": "Index with links to referenced entries" + }, + "no-metadata": "No information is available", + "page-navigation": { + "compact": "Go to page", + "descriptive": "Page list to go to pages from the print source version" + }, + "structural": { + "compact": "Headings", + "descriptive": "Elements such as headings, tables, etc for structured navigation" + }, + "toc": { + "compact": "Table of contents", + "descriptive": "Table of contents to all chapters of the text via links" + } + }, + "rich-content": { + "title": "Rich content", + "accessible-chemistry-as-latex": { + "compact": "Chemical formulas in LaTeX", + "descriptive": "Chemical formulas in accessible format (LaTeX)" + }, + "accessible-chemistry-as-mathml": { + "compact": "Chemical formulas in MathML", + "descriptive": "Chemical formulas in accessible format (MathML)" + }, + "accessible-math-as-latex": { + "compact": "Math as LaTeX", + "descriptive": "Math formulas in accessible format (LaTeX)" + }, + "math-as-mathml": { + "compact": "Math as MathML", + "descriptive": "Math formulas in accessible format (MathML)" + }, + "accessible-math-described": "Text descriptions of math are provided", + "closed-captions": { + "compact": "Videos have closed captions", + "descriptive": "Videos included in publications have closed captions" + }, + "extended-descriptions": "Information-rich images are described by extended descriptions", + "open-captions": { + "compact": "Videos have open captions", + "descriptive": "Videos included in publications have open captions" + }, + "transcript": "Transcript(s) provided", + "unknown": "No information is available" + }, + "hazards": { + "title": "Hazards", + "flashing": { + "compact": "Flashing content", + "descriptive": "The publication contains flashing content that can cause photosensitive seizures" + }, + "flashing-none": { + "compact": "No flashing hazards", + "descriptive": "The publication does not contain flashing content that can cause photosensitive seizures" + }, + "flashing-unknown": { + "compact": "Flashing hazards not known", + "descriptive": "The presence of flashing content that can cause photosensitive seizures could not be determined" + }, + "motion": { + "compact": "Motion simulation", + "descriptive": "The publication contains motion simulations that can cause motion sickness" + }, + "motion-none": { + "compact": "No motion simulation hazards", + "descriptive": "The publication does not contain motion simulations that can cause motion sickness" + }, + "motion-unknown": { + "compact": "Motion simulation hazards not known", + "descriptive": "The presence of motion simulations that can cause motion sickness could not be determined" + }, + "no-metadata": "No information is available", + "none": { + "compact": "No hazards", + "descriptive": "The publication contains no hazards" + }, + "sound": { + "compact": "Sounds", + "descriptive": "The publication contains sounds that can cause sensitivity issues" + }, + "sound-none": { + "compact": "No sound hazards", + "descriptive": "The publication does not contain sounds that can cause sensitivity issues" + }, + "sound-unknown": { + "compact": "Sound hazards not known", + "descriptive": "The presence of sounds that can cause sensitivity issues could not be determined" + }, + "unknown": "The presence of hazards is unknown" + }, + "accessibility-summary": { + "title": "Accessibility summary", + "no-metadata": "No information is available", + "publisher-contact": "For more information about the accessibility of this product, please contact the publisher: " + }, + "legal-considerations": { + "title": "Legal considerations", + "exempt": { + "compact": "Claims an accessibility exemption in some jurisdictions", + "descriptive": "This publication claims an accessibility exemption in some jurisdictions" + }, + "no-metadata": "No information is available" + }, + "additional-accessibility-information": { + "title": "Additional accessibility information", + "aria": { + "compact": "ARIA roles included", + "descriptive": "Content is enhanced with ARIA roles to optimize organization and facilitate navigation" + }, + "audio-descriptions": "Audio descriptions", + "braille": "Braille", + "color-not-sole-means-of-conveying-information": "Color is not the sole means of conveying information", + "dyslexia-readability": "Dyslexia readability", + "full-ruby-annotations": "Full ruby annotations", + "high-contrast-between-foreground-and-background-audio": "High contrast between foreground and background audio", + "high-contrast-between-text-and-background": "High contrast between foreground text and background", + "large-print": "Large print", + "page-breaks": { + "compact": "Page breaks included", + "descriptive": "Page breaks included from the original print source" + }, + "ruby-annotations": "Some Ruby annotations", + "sign-language": "Sign language", + "tactile-graphics": { + "compact": "Tactile graphics included", + "descriptive": "Tactile graphics have been integrated to facilitate access to visual elements for blind people" + }, + "tactile-objects": "Tactile 3D objects", + "text-to-speech-hinting": "Text-to-speech hinting provided", + "ultra-high-contrast-between-text-and-background": "Ultra high contrast between text and background", + "visible-page-numbering": "Visible page numbering", + "without-background-sounds": "Without background sounds" + } +} diff --git a/shared/src/publication/accessibility/locales/fr.json b/shared/src/publication/accessibility/locales/fr.json new file mode 100644 index 00000000..073cb4d1 --- /dev/null +++ b/shared/src/publication/accessibility/locales/fr.json @@ -0,0 +1,231 @@ +{ + "ways-of-reading": { + "title": "Lisibilité", + "nonvisual-reading": { + "alt-text": { + "compact": "Images décrites", + "descriptive": "Les images sont décrites par un texte" + }, + "no-metadata": "Aucune information pour la lecture en voix de synthèse ou en braille", + "none": { + "compact": "Non lisible en voix de synthèse ou en braille", + "descriptive": "Le contenu n'est pas lisible en voix de synthèse ou en braille" + }, + "not-fully": { + "compact": "Pas entièrement lisible en voix de synthèse ou en braille", + "descriptive": "Tous les contenus ne pourront pas être lus à haute voix ou en braille" + }, + "readable": { + "compact": "Entièrement lisible en voix de synthèse ou en braille", + "descriptive": "Tous les contenus peuvent être lus en voix de synthèse ou en braille" + } + }, + "prerecorded-audio": { + "complementary": { + "compact": "Clips audio préenregistrés", + "descriptive": "Des clips audio préenregistrés sont intégrés au contenu" + }, + "no-metadata": "Aucune information sur les enregistrements audio", + "only": { + "compact": "Audio préenregistré uniquement", + "descriptive": "Livre audio sans texte alternatif" + }, + "synchronized": { + "compact": "Audio préenregistré synchronisé avec du texte", + "descriptive": "Tous les contenus sont disponibles comme audio préenregistrés synchronisés avec le texte" + } + }, + "visual-adjustments": { + "modifiable": { + "compact": "L'affichage peut être adapté", + "descriptive": "L'apparence du texte et la mise en page peuvent être modifiées en fonction des capacités du système de lecture (famille et taille des polices, espaces entre les paragraphes, les phrases, les mots et les lettres, ainsi que la couleur de l'arrière-plan et du texte)" + }, + "unknown": "Aucune information sur les possibilités d'adaptation de l'affichage", + "unmodifiable": { + "compact": "L'affichage ne peut pas être adapté", + "descriptive": "Le texte et la mise en page ne peuvent pas être adaptés étant donné que l'expérience de lecture est proche de celle de la version imprimée, mais l'application de lecture peut tout de même proposer la capacité de zoomer" + } + } + }, + "conformance": { + "title": "Règles d'accessibilité", + "details-title": "Information détaillée", + "a": { + "compact": "Cette publication répond aux règles minimales d'accessibilité", + "descriptive": "La publication indique qu'elle respecte les règles d'accessibilité EPUB et WCAG 2 niveau A" + }, + "aa": { + "compact": "Cette publication répond aux règles d'accessibilité reconnues", + "descriptive": "La publication indique qu'elle respecte les règles d'accessibilité EPUB et WCAG 2 niveau AA" + }, + "aaa": { + "compact": "Cette publication dépasse les règles d'accessibilité reconnues", + "descriptive": "La publication indique qu'elle respecte les règles d'accessibilité EPUB et WCAG 2 niveau AAA" + }, + "no": "Aucune information disponible", + "unknown-standard": "Aucune indication concernant les normes d'accessibilité", + "certifier": "Accessibilité évaluée par ", + "certifier-credentials": "L'évaluateur est accrédité par ", + "details": { + "certification-info": "Cette publication a été certifié le", + "certifier-report": "Pour plus d'information, veuillez consulter le rapport de certification", + "claim": "Cette publication indique respecter", + "epub-accessibility-1-0": "EPUB Accessibilité 1.0", + "epub-accessibility-1-1": "EPUB Accessibilité 1.1", + "level-a": "Niveau A", + "level-aa": "Niveau AA", + "level-aaa": "Niveau AAA", + "wcag-2-0": { + "compact": "WCAG 2.0", + "descriptive": "Règles pour l’accessibilité des contenus Web (WCAG) 2.0" + }, + "wcag-2-1": { + "compact": "WCAG 2.1", + "descriptive": "Règles pour l’accessibilité des contenus Web (WCAG) 2.1" + }, + "wcag-2-2": { + "compact": "WCAG 2.2", + "descriptive": "Règles pour l’accessibilité des contenus Web (WCAG) 2.2" + } + } + }, + "navigation": { + "title": "Points de repère", + "index": { + "compact": "Index", + "descriptive": "Index comportant des liens vers les entrées référencées" + }, + "no-metadata": "Aucune information disponible", + "page-navigation": { + "compact": "Aller à la page", + "descriptive": "Permet d'accéder aux pages de la version source imprimée" + }, + "structural": { + "compact": "Titres", + "descriptive": "Contient des titres pour une navigation structurée" + }, + "toc": { + "compact": "Table des matières", + "descriptive": "Table des matières" + } + }, + "rich-content": { + "title": "Contenus spécifiques", + "accessible-chemistry-as-latex": { + "compact": "Formules chimiques en LaTeX", + "descriptive": "Formules chimiques en format accessible (LaTeX)" + }, + "accessible-chemistry-as-mathml": { + "compact": "Formules chimiques en MathML", + "descriptive": "Formules chimiques en format accessible (MathML)" + }, + "accessible-math-as-latex": { + "compact": "Mathématiques en LaTeX", + "descriptive": "Formules mathématiques en format accessible (LaTeX)" + }, + "math-as-mathml": { + "compact": "Mathématiques en MathML", + "descriptive": "Formules mathématiques en format accessible (MathML)" + }, + "accessible-math-described": "Des descriptions textuelles des formules mathématiques sont fournies", + "closed-captions": { + "compact": "Sous-titres disponibles pour les vidéos", + "descriptive": "Des sous titres sont disponibles pour les vidéos" + }, + "extended-descriptions": "Les images porteuses d'informations complexes sont décrites par des descriptions longues", + "open-captions": { + "compact": "Sous-titres incrustés", + "descriptive": "Des sous titres sont incrustés pour les vidéos" + }, + "transcript": "Transcriptions fournies", + "unknown": "Aucune information disponible" + }, + "hazards": { + "title": "Points d'attention", + "flashing": { + "compact": "Flashs lumineux", + "descriptive": "La publication contient des flashs lumineux qui peuvent provoquer des crises d’épilepsie" + }, + "flashing-none": { + "compact": "Pas de flashs lumineux", + "descriptive": "La publication ne contient pas de flashs lumineux susceptibles de provoquer des crises d’épilepsie" + }, + "flashing-unknown": { + "compact": "Pas d'information concernant la présence de flashs lumineux", + "descriptive": "La présence de flashs lumineux susceptibles de provoquer des crises d’épilepsie n'a pas pu être déterminée" + }, + "motion": { + "compact": "Sensations de mouvement", + "descriptive": "La publication contient des images en mouvement qui peuvent provoquer des nausées, des vertiges et des maux de tête" + }, + "motion-none": { + "compact": "Pas de sensations de mouvement", + "descriptive": "La publication ne contient pas d'images en mouvement qui pourraient provoquer des nausées, des vertiges et des maux de tête" + }, + "motion-unknown": { + "compact": "Pas d'information concernant la présence d'images en mouvement", + "descriptive": "La présence d'images en mouvement susceptibles de provoquer des nausées, des vertiges et des maux de tête n'a pas pu être déterminée" + }, + "no-metadata": "Aucune information disponible", + "none": { + "compact": "Aucun points d'attention", + "descriptive": "La publication ne présente aucun risque lié à la présence de flashs lumineux, de sensations de mouvement ou de sons" + }, + "sound": { + "compact": "Sons", + "descriptive": "La publication contient des sons qui peuvent causer des troubles de la sensibilité" + }, + "sound-none": { + "compact": "Pas de risques sonores", + "descriptive": "La publication ne contient pas de sons susceptibles de provoquer des troubles de la sensibilité" + }, + "sound-unknown": { + "compact": "Pas d'information concernant la présence de sons", + "descriptive": "La présence de sons susceptibles de causer des troubles de sensibilité n'a pas pu être déterminée" + }, + "unknown": "La présence de risques est inconnue" + }, + "accessibility-summary": { + "title": "Informations d'accessibilité supplémentaires fournies par l'éditeur", + "no-metadata": "Aucune information disponible", + "publisher-contact": "Pour plus d'information à propos de l'accessibilité de cette publication, veuillez contacter l'éditeur : " + }, + "legal-considerations": { + "title": "Considérations légales", + "exempt": { + "compact": "Déclare être sous le coup d'une exemption dans certaines juridictions", + "descriptive": "Cette publication dééclare être sous le coup d'une exemption dans certaines juridictions" + }, + "no-metadata": "Aucune information disponible" + }, + "additional-accessibility-information": { + "title": "Informations complémentaires sur l'accessibilité", + "aria": { + "compact": "Information enrichie pour les technologies d'assistances", + "descriptive": "La structure est enrichi de rôles ARIA afin d'optimiser l'organisation et de faciliter la navigation via les technologies d'assistances" + }, + "audio-descriptions": "Description audio", + "braille": "Braille", + "color-not-sole-means-of-conveying-information": "La couleur n'est pas la seule manière de communiquer de l'information", + "dyslexia-readability": "Lisibilité adapté aux publics dys", + "full-ruby-annotations": "Annotations complètes au format ruby (langues asiatiques)", + "high-contrast-between-foreground-and-background-audio": "Contraste sonore amélioré entre les différents plans", + "high-contrast-between-text-and-background": "Contraste élevé entre le texte et l'arrière-plan", + "large-print": "Grands caractères", + "page-breaks": { + "compact": "Pagination identique à l'imprimé", + "descriptive": "Contient une pagination identique à la version imprimée" + }, + "ruby-annotations": "Annotations partielles au format ruby (langues asiatiques)", + "sign-language": "Langue des signes", + "tactile-graphics": { + "compact": "Graphiques tactiles", + "descriptive": "Des graphiques tactiles ont été intégrés pour faciliter l'accès des personnes aveugles aux éléments visuels" + }, + "tactile-objects": "Objets 3D ou tactiles", + "text-to-speech-hinting": "Prononciation améliorée pour la synthèse vocale", + "ultra-high-contrast-between-text-and-background": "Contraste très élevé entre le texte et l'arrière-plan", + "visible-page-numbering": "Numérotation de page visible", + "without-background-sounds": "Aucun bruit de fond" + } +} diff --git a/shared/src/publication/index.ts b/shared/src/publication/index.ts index e73af168..3379e62b 100644 --- a/shared/src/publication/index.ts +++ b/shared/src/publication/index.ts @@ -1,3 +1,4 @@ +export * from './accessibility'; export * from './encryption'; export * from './epub'; export * from './html'; diff --git a/shared/test/Metadata.test.ts b/shared/test/Metadata.test.ts index 77fc8fc9..a7ec9dde 100644 --- a/shared/test/Metadata.test.ts +++ b/shared/test/Metadata.test.ts @@ -11,8 +11,9 @@ import { Subjects, TDM, TDMReservation, - AltIdentifier, + AltIdentifier } from '../src'; +import { Accessibility, AccessMode, AccessibilityProfile, Feature, Hazard, Exemption, PrimaryAccessMode, Certification } from '../src/publication/accessibility/Accessibility'; describe('Metadata Tests', () => { it('parse minimal JSON', () => { @@ -65,6 +66,23 @@ describe('Metadata Tests', () => { }, 'other-metadata1': 'value', 'other-metadata2': [42], + accessibility: { + conformsTo: ['http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa'], + accessMode: ['textual', 'visual'], + accessModeSufficient: [ + ["textual"], + ["visual"] + ], + feature: ['alternativeText', 'ARIA'], + hazard: ['noFlashingHazard'], + exemption: ['eaa-disproportionate-burden'], + certification: { + certifiedBy: 'Certifier', + credential: 'Certification', + report: 'https://example.com/report' + }, + summary: 'This publication is accessible with text-to-speech and screen reader support' + } }) ).toEqual( new Metadata({ @@ -172,6 +190,23 @@ describe('Metadata Tests', () => { 'other-metadata1': 'value', 'other-metadata2': [42], }, + accessibility: new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA], + certification: new Certification( + 'Certifier', + 'Certification', + 'https://example.com/report' + ), + summary: 'This publication is accessible with text-to-speech and screen reader support', + accessMode: [new AccessMode('textual'), new AccessMode('visual')], + accessModeSufficient: [ + new PrimaryAccessMode(['textual']), + new PrimaryAccessMode(['visual']) + ], + feature: [new Feature('alternativeText'), new Feature('ARIA')], + hazard: [new Hazard('noFlashingHazard')], + exemption: [new Exemption('eaa-disproportionate-burden')] + }), }) ); }); @@ -238,6 +273,7 @@ describe('Metadata Tests', () => { en: 'Title', fr: 'Titre', }), + subtitle: new LocalizedString({ en: 'Subtitle', fr: 'Sous-titre', @@ -327,6 +363,23 @@ describe('Metadata Tests', () => { 'other-metadata1': 'value', 'other-metadata2': [42], }, + accessibility: new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA], + certification: new Certification( + 'Certifier', + 'Certification', + 'https://example.com/report' + ), + summary: 'This publication is accessible with text-to-speech and screen reader support', + accessMode: [new AccessMode('textual'), new AccessMode('visual')], + accessModeSufficient: [ + new PrimaryAccessMode(['textual']), + new PrimaryAccessMode(['visual']) + ], + feature: [new Feature('alternativeText'), new Feature('ARIA')], + hazard: [new Hazard('noFlashingHazard')], + exemption: [new Exemption('eaa-disproportionate-burden')] + }) }).serialize() ).toEqual({ identifier: '1234', @@ -372,6 +425,23 @@ describe('Metadata Tests', () => { }, 'other-metadata1': 'value', 'other-metadata2': [42], + accessibility: { + conformsTo: ['http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa'], + accessMode: ['textual', 'visual'], + accessModeSufficient: [ + ["textual"], + ["visual"] + ], + feature: ['alternativeText', 'ARIA'], + hazard: ['noFlashingHazard'], + exemption: ['eaa-disproportionate-burden'], + certification: { + certifiedBy: 'Certifier', + credential: 'Certification', + report: 'https://example.com/report' + }, + summary: 'This publication is accessible with text-to-speech and screen reader support' + } }); }); diff --git a/shared/test/accessibility/Accessibility.test.ts b/shared/test/accessibility/Accessibility.test.ts new file mode 100644 index 00000000..e5ec3cf3 --- /dev/null +++ b/shared/test/accessibility/Accessibility.test.ts @@ -0,0 +1,410 @@ +import { Accessibility, AccessibilityProfile, AccessMode, Feature, Hazard, Exemption, PrimaryAccessMode, Certification } from '../../src/publication/accessibility/Accessibility'; + +describe('Accessibility Tests', () => { + it('parse undefined JSON', () => { + expect(Accessibility.deserialize(undefined as unknown as Record | string)).toBeUndefined(); + }); + + it('parse minimal JSON', () => { + expect(Accessibility.deserialize({})).toEqual( + new Accessibility({}) + ); + }); + + it('parse full JSON', () => { + const json = { + conformsTo: [ + "https://www.w3.org/TR/epub-a11y-11#wcag-2.1-aa", + "http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa" + ], + certification: { + certifiedBy: "certifier", + credential: "Certification", + report: "https://example.com/report" + }, + summary: "This is the accessibility summary. This EPUB is for testing purposes, accessibility metadata presented are not true but only present to check how they would display into the reading system.", + accessMode: [ + "textual", + "visual" + ], + accessModeSufficient: [ + ['textual'], + ['visual'], + ['auditory'] + ], + feature: [ + "tableOfContents", + "readingOrder", + "alternativeText", + "captions", + "braille", + "ChemML", + "describedMath", + "displayTransformability", + "highContrastAudio", + "highContrastDisplay", + "index", + "largePrint", + "latex", + "longDescription", + "MathML", + "none", + "pageNavigation", + "printPageNumbers", + "rubyAnnotations", + "annotations", + "signLanguage", + "structuralNavigation", + "synchronizedAudioText", + "tableOfContents", + "tactileGraphic", + "tactileObject", + "timingControl", + "transcript", + "ttsMarkup", + "aria", + "unlocked" + ], + hazard: [ + "none", + "none", + "flashing", + "noFlashingHazard", + "MotionSimulation", + "noMotionSimulationHazard", + "sound", + "noSoundHazard", + "unknown" + ] + }; + + const expected = new Accessibility({ + conformsTo: [ + AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AA, + AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA + ], + certification: new Certification( + "certifier", + "Certification", + "https://example.com/report" + ), + summary: "This is the accessibility summary. This EPUB is for testing purposes, accessibility metadata presented are not true but only present to check how they would display into the reading system.", + accessMode: [ + new AccessMode("textual"), + new AccessMode("visual") + ], + accessModeSufficient: [ + new PrimaryAccessMode(["textual"]), + new PrimaryAccessMode(["visual"]), + new PrimaryAccessMode(["auditory"]) + ], + feature: [ + new Feature("tableOfContents"), + new Feature("readingOrder"), + new Feature("alternativeText"), + new Feature("captions"), + new Feature("braille"), + new Feature("ChemML"), + new Feature("describedMath"), + new Feature("displayTransformability"), + new Feature("highContrastAudio"), + new Feature("highContrastDisplay"), + new Feature("index"), + new Feature("largePrint"), + new Feature("latex"), + new Feature("longDescription"), + new Feature("MathML"), + new Feature("none"), + new Feature("pageNavigation"), + new Feature("printPageNumbers"), + new Feature("rubyAnnotations"), + new Feature("annotations"), + new Feature("signLanguage"), + new Feature("structuralNavigation"), + new Feature("synchronizedAudioText"), + new Feature("tableOfContents"), + new Feature("tactileGraphic"), + new Feature("tactileObject"), + new Feature("timingControl"), + new Feature("transcript"), + new Feature("ttsMarkup"), + new Feature("aria"), + new Feature("unlocked") + ], + hazard: [ + new Hazard("none"), + new Hazard("none"), + new Hazard("flashing"), + new Hazard("noFlashingHazard"), + new Hazard("MotionSimulation"), + new Hazard("noMotionSimulationHazard"), + new Hazard("sound"), + new Hazard("noSoundHazard"), + new Hazard("unknown") + ] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + }); + + it('parse JSON with multiple access modes', () => { + const json = { + accessMode: ['textual', 'visual', 'textual'], + accessModeSufficient: [['textual'], ['visual']] + }; + + const expected = new Accessibility({ + accessMode: [new AccessMode('textual'), new AccessMode('visual'), new AccessMode('textual')], + accessModeSufficient: [ + new PrimaryAccessMode(['textual']), + new PrimaryAccessMode(['visual']) + ] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + }); + + it('parse PrimaryAccessMode with a single access mode', () => { + const json = { + accessModeSufficient: ['textual'] + }; + + const expected = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode('textual')] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + }); + + it('parse PrimaryAccessMode with multiple access modes', () => { + const json = { + accessModeSufficient: [['textual', 'visual']] + }; + + const expected = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual', 'visual'])] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + }); + + it('parse PrimaryAccessMode with multiple arrays', () => { + const json = { + accessModeSufficient: [['textual', 'visual'], 'auditory'] + }; + + const expected = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual', 'visual']), new PrimaryAccessMode('auditory')] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + }); + + it('parse requires valid PrimaryAccessMode values', () => { + const json = { + accessModeSufficient: [['invalid'], ['textual']] + }; + + const expected = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual'])] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + + const json2 = { + accessModeSufficient: [['textual', 'visual']] + }; + + const expected2 = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual', 'visual'])] + }); + + expect(Accessibility.deserialize(json2)).toEqual(expected2); + }); + + it('parse PrimaryAccessMode with multiple arrays', () => { + const json = { + accessModeSufficient: [['textual', 'visual'], ['auditory']] + }; + + const expected = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual', 'visual']), new PrimaryAccessMode(['auditory'])] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + }); + + it('parse requires valid PrimaryAccessMode values', () => { + const json = { + accessModeSufficient: [['invalid'], ['textual']] + }; + + const expected = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual'])] + }); + + expect(Accessibility.deserialize(json)).toEqual(expected); + + const json2 = { + accessModeSufficient: [['textual', 'visual']] + }; + + const expected2 = new Accessibility({ + accessModeSufficient: [new PrimaryAccessMode(['textual', 'visual'])] + }); + + expect(Accessibility.deserialize(json2)).toEqual(expected2); + }); + + it('serialize Profile', () => { + const accessibility = new Accessibility({ + conformsTo: [ + AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AA, + AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA + ] + }); + + expect(accessibility.serialize()).toEqual({ + conformsTo: [ + "https://www.w3.org/TR/epub-a11y-11#wcag-2.1-aa", + "http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa" + ] + }); + }); + + it('serialize Certification', () => { + const accessibility = new Accessibility({ + certification: new Certification( + 'Certifier', + 'Certification', + 'https://example.com/report' + ) + }); + + expect(accessibility.serialize()).toEqual({ + certification: { + certifiedBy: 'Certifier', + credential: 'Certification', + report: 'https://example.com/report' + } + }); + }); + + it('serialize AccessMode', () => { + const accessibility = new Accessibility({ + accessMode: [new AccessMode('textual'), new AccessMode('visual')] + }); + + expect(accessibility.serialize()).toEqual({ + accessMode: ['textual', 'visual'] + }); + }); + + it('serialize Feature', () => { + const accessibility = new Accessibility({ + feature: [new Feature('tableOfContents'), new Feature('ARIA')] + }); + + expect(accessibility.serialize()).toEqual({ + feature: ['tableOfContents', 'ARIA'] + }); + }); + + it('serialize Hazard', () => { + const accessibility = new Accessibility({ + hazard: [new Hazard('noFlashingHazard')] + }); + + expect(accessibility.serialize()).toEqual({ + hazard: ['noFlashingHazard'] + }); + }); + + it('serialize Exemption', () => { + const accessibility = new Accessibility({ + exemption: [new Exemption('eaa-disproportionate-burden')] + }); + + expect(accessibility.serialize()).toEqual({ + exemption: ['eaa-disproportionate-burden'] + }); + }); + + it('serialize PrimaryAccessMode', () => { + const accessibility = new Accessibility({ + accessModeSufficient: [ + new PrimaryAccessMode(['textual', 'visual']), + new PrimaryAccessMode(['auditory']) + ] + }); + + expect(accessibility.serialize()).toEqual({ + accessModeSufficient: [ + ["textual", "visual"], + ["auditory"] + ] + }); + }); + + it('serialize minimal JSON', () => { + expect(new Accessibility().serialize()).toEqual({}); + }); + + it('serialize full JSON', () => { + const accessibility = new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA], + certification: new Certification('Certifier', 'Certification', 'https://example.com/report'), + summary: 'This publication is accessible with text-to-speech and screen reader support', + accessMode: [new AccessMode('textual'), new AccessMode('visual')], + accessModeSufficient: [ + new PrimaryAccessMode(['textual']), + new PrimaryAccessMode(['visual']) + ], + feature: [new Feature('alternativeText'), new Feature('ARIA')], + hazard: [new Hazard('noFlashingHazard')], + exemption: [new Exemption('eaa-disproportionate-burden')] + }); + + const expected = { + conformsTo: ['http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa'], + accessMode: ['textual', 'visual'], + accessModeSufficient: [ + ["textual"], + ["visual"] + ], + feature: ['alternativeText', 'ARIA'], + hazard: ['noFlashingHazard'], + exemption: ['eaa-disproportionate-burden'], + certification: { + certifiedBy: 'Certifier', + credential: 'Certification', + report: 'https://example.com/report' + }, + summary: 'This publication is accessible with text-to-speech and screen reader support' + }; + + expect(accessibility.serialize()).toEqual(expected); + }); + + it('serialize handles empty arrays', () => { + const accessibility = new Accessibility({}); + + expect(accessibility.serialize()).toEqual({}); + }); + + it('serialize handles undefined values', () => { + const accessibility = new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_10_WCAG_20_AA] + }); + + expect(accessibility.serialize()).toEqual({ + conformsTo: ['http://www.idpf.org/epub/a11y/accessibility-20170105.html#wcag-aa'] + }); + }); + + it('serialize handles undefined values', () => { + const accessibility = new Accessibility({}); + + expect(accessibility.serialize()).toEqual({}); + }); +}); diff --git a/shared/test/accessibility/AccessibilityMetadataDisplayGuide.test.ts b/shared/test/accessibility/AccessibilityMetadataDisplayGuide.test.ts new file mode 100644 index 00000000..7c92e47b --- /dev/null +++ b/shared/test/accessibility/AccessibilityMetadataDisplayGuide.test.ts @@ -0,0 +1,1453 @@ +import { Publication } from '../../src/publication/Publication'; +import { AccessibilityMetadataDisplayGuide } from '../../src/publication/accessibility/AccessibilityMetadataDisplayGuide'; +import { Feature, Hazard, AccessibilityProfile, Exemption, AccessMode, Accessibility } from '../../src/publication/accessibility/Accessibility'; +import { Manifest, Metadata, LocalizedString, Links, ReadingProgression, Layout } from '../../src/publication'; + +// Factory function to create test publications +function createPublication(values?: { + title?: string; + language?: string; + layout?: Layout; + readingProgression?: ReadingProgression; + links?: Links; + readingOrder?: Links; + resources?: Links; + accessibility?: Accessibility; +}): Publication { + // Create fresh instances + const links = values?.links || new Links([]); + const readingOrder = values?.readingOrder || new Links([]); + const resources = values?.resources || new Links([]); + const metadata = new Metadata({ + title: new LocalizedString(values?.title || 'Title'), + languages: [values?.language || 'en'], + readingProgression: values?.readingProgression || ReadingProgression.auto, + accessibility: values?.accessibility || new Accessibility({}), + layout: values?.layout || Layout.reflowable, + }); + + return new Publication({ + manifest: new Manifest({ + metadata, + links, + readingOrder, + resources + }), + }); +} + +describe('AccessibilityMetadataDisplayGuide', () => { + + describe('WaysOfReading', () => { + describe('Visual Adjustments', () => { + it('should handle display transformability (modifiable)', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.DISPLAY_TRANSFORMABILITY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.visual-adjustments.modifiable")).toBe(true); + }); + + it('should handle fixed layout (unmodifiable)', () => { + // Given + const publication = createPublication({ + layout: Layout.fixed, + accessibility: new Accessibility({ + feature: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.visual-adjustments.unmodifiable")).toBe(true); + }); + + it('should handle unknown visual adjustments', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.visual-adjustments.unknown")).toBe(true); + }); + }); + + describe('Non-visual Reading', () => { + it('should handle all text content (readable)', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + accessMode: [AccessMode.TEXTUAL] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.nonvisual-reading.readable")).toBe(true); + }); + + it('should handle some text content with alt text (not fully readable)', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + accessMode: [AccessMode.TEXTUAL, AccessMode.AUDITORY], + feature: [Feature.ALTERNATIVE_TEXT] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.nonvisual-reading.not-fully")).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.nonvisual-reading.alt-text")).toBe(true); + }); + + it('should handle no text content (unreadable)', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + accessMode: [AccessMode.AUDITORY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.nonvisual-reading.none")).toBe(true); + }); + + it('should handle no metadata', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({}) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.nonvisual-reading.no-metadata")).toBe(true); + }); + }); + + describe('Prerecorded Audio', () => { + it('should handle synchronized audio text', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.SYNCHRONIZED_AUDIO_TEXT] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.prerecorded-audio.synchronized")).toBe(true); + }); + + it('should handle audio only', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + accessModeSufficient: [{ + value: AccessMode.AUDITORY.value, + serialize: () => AccessMode.AUDITORY.value + }] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.prerecorded-audio.only")).toBe(true); + }); + + it('should handle audio complementary', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + accessMode: [AccessMode.AUDITORY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.prerecorded-audio.complementary")).toBe(true); + }); + + it('should handle no metadata', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + accessMode: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const waysOfReading = guide.waysOfReading; + + // Then + expect(waysOfReading.shouldDisplay).toBe(true); + expect(waysOfReading.statements.some(s => s.id === "ways-of-reading.prerecorded-audio.no-metadata")).toBe(true); + }); + }); + }); + + describe('Navigation', () => { + describe('Navigation Features', () => { + it('should detect table of contents', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.TABLE_OF_CONTENTS] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(true); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.toc"); + }); + + it('should detect index', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.INDEX] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(true); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.index"); + }); + + it('should detect headings (structural navigation)', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.STRUCTURAL_NAVIGATION] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(true); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.structural"); + }); + + it('should detect page navigation', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.PAGE_NAVIGATION] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(true); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.page-navigation"); + }); + + it('should combine multiple navigation features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [ + Feature.TABLE_OF_CONTENTS, + Feature.INDEX, + Feature.STRUCTURAL_NAVIGATION, + Feature.PAGE_NAVIGATION + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(true); + expect(navigation.statements.length).toBe(4); + expect(navigation.statements[0].id).toBe("navigation.toc"); + expect(navigation.statements[1].id).toBe("navigation.index"); + expect(navigation.statements[2].id).toBe("navigation.structural"); + expect(navigation.statements[3].id).toBe("navigation.page-navigation"); + }); + }); + + describe('No Metadata', () => { + it('should show no metadata when no navigation features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(false); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.no-metadata"); + }); + + it('should show no metadata when no accessibility metadata', () => { + // Given + const publication = createPublication({ + accessibility: undefined + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(false); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.no-metadata"); + }); + }); + + it('should show no metadata when no navigation features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const navigation = guide.navigation; + + // Then + expect(navigation.shouldDisplay).toBe(false); + expect(navigation.statements.length).toBe(1); + expect(navigation.statements[0].id).toBe("navigation.no-metadata"); + }); + }); + + describe('RichContent', () => { + describe('Content Features', () => { + it('should detect extended alt text descriptions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.LONG_DESCRIPTION] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.extended-descriptions"); + }); + + describe('Math Content', () => { + it('should detect described math', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.DESCRIBED_MATH] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.accessible-math-described"); + }); + + it('should detect math as MathML', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.MATH_ML] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.math-as-mathml"); + }); + + it('should detect math as LaTeX', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.LATEX] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.accessible-math-as-latex"); + }); + + it('should combine math features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [ + Feature.DESCRIBED_MATH, + Feature.MATH_ML, + Feature.LATEX + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(3); + expect(richContent.statements[0].id).toBe("rich-content.accessible-math-described"); + expect(richContent.statements[1].id).toBe("rich-content.math-as-mathml"); + expect(richContent.statements[2].id).toBe("rich-content.accessible-math-as-latex"); + }); + }); + + describe('Chemical Content', () => { + it('should detect chemical formulas as MathML', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.MATH_ML_CHEMISTRY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.accessible-chemistry-as-mathml"); + }); + + it('should detect chemical formulas as LaTeX', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.LATEX_CHEMISTRY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.accessible-chemistry-as-latex"); + }); + }); + + describe('Captions', () => { + it('should detect closed captions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.CLOSED_CAPTIONS] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.closed-captions"); + }); + + it('should detect open captions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.OPEN_CAPTIONS] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.open-captions"); + }); + }); + + it('should detect transcripts', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.TRANSCRIPT] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.transcript"); + }); + + it('should combine multiple rich content features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [ + Feature.LONG_DESCRIPTION, + Feature.DESCRIBED_MATH, + Feature.MATH_ML, + Feature.LATEX, + Feature.MATH_ML_CHEMISTRY, + Feature.LATEX_CHEMISTRY, + Feature.CLOSED_CAPTIONS, + Feature.OPEN_CAPTIONS, + Feature.TRANSCRIPT + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(true); + expect(richContent.statements.length).toBe(9); + expect(richContent.statements[0].id).toBe("rich-content.extended-descriptions"); + expect(richContent.statements[1].id).toBe("rich-content.accessible-math-described"); + expect(richContent.statements[2].id).toBe("rich-content.math-as-mathml"); + expect(richContent.statements[3].id).toBe("rich-content.accessible-math-as-latex"); + expect(richContent.statements[4].id).toBe("rich-content.accessible-chemistry-as-mathml"); + expect(richContent.statements[5].id).toBe("rich-content.accessible-chemistry-as-latex"); + expect(richContent.statements[6].id).toBe("rich-content.closed-captions"); + expect(richContent.statements[7].id).toBe("rich-content.open-captions"); + expect(richContent.statements[8].id).toBe("rich-content.transcript"); + }); + }); + + describe('No Metadata', () => { + it('should show unknown when no rich content features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(false); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.unknown"); + }); + + it('should show unknown when no accessibility metadata', () => { + // Given + const publication = createPublication({ + accessibility: undefined + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const richContent = guide.richContent; + + // Then + expect(richContent.shouldDisplay).toBe(false); + expect(richContent.statements.length).toBe(1); + expect(richContent.statements[0].id).toBe("rich-content.unknown"); + }); + }); + }); + + describe('AdditionalInformation', () => { + describe('Content Features', () => { + it('should detect page breaks and print page numbers', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.PAGE_BREAK_MARKERS, Feature.PRINT_PAGE_NUMBERS] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.page-breaks"); + }); + + it('should detect ARIA', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.ARIA] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.aria"); + }); + + it('should detect audio descriptions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.AUDIO_DESCRIPTION] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.audio-descriptions"); + }); + + it('should detect braille', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.BRAILLE] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.braille"); + }); + + describe('Ruby Annotations', () => { + it('should detect regular ruby annotations', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.RUBY_ANNOTATIONS] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.ruby-annotations"); + }); + + it('should detect full ruby annotations', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.FULL_RUBY_ANNOTATIONS] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.full-ruby-annotations"); + }); + }); + + describe('High Contrast', () => { + it('should detect high contrast audio', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.HIGH_CONTRAST_AUDIO] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.high-contrast-between-foreground-and-background-audio"); + }); + + it('should detect high contrast display', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.HIGH_CONTRAST_DISPLAY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.high-contrast-between-text-and-background"); + }); + }); + + it('should detect large print', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.LARGE_PRINT] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.large-print"); + }); + + it('should detect sign language', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.SIGN_LANGUAGE] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.sign-language"); + }); + + describe('Tactile Features', () => { + it('should detect tactile graphics', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.TACTILE_GRAPHIC] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.tactile-graphics"); + }); + + it('should detect tactile objects', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.TACTILE_OBJECT] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.tactile-objects"); + }); + }); + + it('should detect text-to-speech hinting', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [Feature.TTS_MARKUP] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(1); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.text-to-speech-hinting"); + }); + + it('should combine multiple additional information features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [ + Feature.PAGE_BREAK_MARKERS, + Feature.ARIA, + Feature.AUDIO_DESCRIPTION, + Feature.BRAILLE, + Feature.RUBY_ANNOTATIONS, + Feature.FULL_RUBY_ANNOTATIONS, + Feature.HIGH_CONTRAST_AUDIO, + Feature.HIGH_CONTRAST_DISPLAY, + Feature.LARGE_PRINT, + Feature.SIGN_LANGUAGE, + Feature.TACTILE_GRAPHIC, + Feature.TACTILE_OBJECT, + Feature.TTS_MARKUP + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(true); + expect(additionalInformation.statements.length).toBe(13); + expect(additionalInformation.statements[0].id).toBe("additional-accessibility-information.page-breaks"); + expect(additionalInformation.statements[1].id).toBe("additional-accessibility-information.aria"); + expect(additionalInformation.statements[2].id).toBe("additional-accessibility-information.audio-descriptions"); + expect(additionalInformation.statements[3].id).toBe("additional-accessibility-information.braille"); + expect(additionalInformation.statements[4].id).toBe("additional-accessibility-information.ruby-annotations"); + expect(additionalInformation.statements[5].id).toBe("additional-accessibility-information.full-ruby-annotations"); + expect(additionalInformation.statements[6].id).toBe("additional-accessibility-information.high-contrast-between-foreground-and-background-audio"); + expect(additionalInformation.statements[7].id).toBe("additional-accessibility-information.high-contrast-between-text-and-background"); + expect(additionalInformation.statements[8].id).toBe("additional-accessibility-information.large-print"); + expect(additionalInformation.statements[9].id).toBe("additional-accessibility-information.sign-language"); + expect(additionalInformation.statements[10].id).toBe("additional-accessibility-information.tactile-graphics"); + expect(additionalInformation.statements[11].id).toBe("additional-accessibility-information.tactile-objects"); + expect(additionalInformation.statements[12].id).toBe("additional-accessibility-information.text-to-speech-hinting"); + }); + }); + + describe('No Metadata', () => { + it('should show no metadata when no additional information features', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + feature: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(false); + expect(additionalInformation.statements.length).toBe(0); + }); + + it('should show no metadata when no accessibility metadata', () => { + // Given + const publication = createPublication({ + accessibility: undefined + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const additionalInformation = guide.additionalInformation; + + // Then + expect(additionalInformation.shouldDisplay).toBe(false); + expect(additionalInformation.statements.length).toBe(0); + }); + }); + }); + + describe('Hazards', () => { + it('should detect flashing hazard', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + hazard: [Hazard.FLASHING] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(true); + expect(hazards.statements.some(s => s.id === "hazards.flashing")).toBe(true); + expect(hazards.flashing).toBe('yes'); + }); + + it('should detect motion simulation hazard', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + hazard: [Hazard.MOTION_SIMULATION] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(true); + expect(hazards.statements.some(s => s.id === "hazards.motion")).toBe(true); + expect(hazards.motion).toBe('yes'); + }); + + it('should detect sound hazard', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + hazard: [Hazard.SOUND] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(true); + expect(hazards.statements.some(s => s.id === "hazards.sound")).toBe(true); + expect(hazards.sound).toBe('yes'); + }); + + it('should handle no hazards', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + hazard: [Hazard.NONE] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(true); + expect(hazards.statements.some(s => s.id === "hazards.none")).toBe(true); + expect(hazards.noHazards).toBe(true); + }); + + it('should handle unknown hazards', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + hazard: [Hazard.UNKNOWN] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(true); + expect(hazards.statements.some(s => s.id === "hazards.unknown")).toBe(true); + expect(hazards.unknown).toBe(true); + }); + + it('should handle multiple hazards', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + hazard: [ + Hazard.FLASHING, + Hazard.MOTION_SIMULATION, + Hazard.SOUND + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(true); + expect(hazards.statements.length).toBe(3); + }); + + it('should handle no metadata', () => { + // Given + const publication = createPublication({ + accessibility: undefined + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const hazards = guide.hazards; + + // Then + expect(hazards.shouldDisplay).toBe(false); + expect(hazards.statements.some(s => s.id === "hazards.no-metadata")).toBe(true); + expect(hazards.noMetadata).toBe(true); + }); + }); + + describe('Conformance', () => { + it('should detect WCAG AAA conformance', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AAA] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const conformance = guide.conformance; + + // Then + expect(conformance.shouldDisplay).toBe(true); + expect(conformance.statements.length).toBe(1); + expect(conformance.statements[0].id).toBe("conformance.aaa"); + }); + + it('should detect WCAG AA conformance', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AA] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const conformance = guide.conformance; + + // Then + expect(conformance.shouldDisplay).toBe(true); + expect(conformance.statements.length).toBe(1); + expect(conformance.statements[0].id).toBe("conformance.aa"); + }); + + it('should detect WCAG A conformance', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + conformsTo: [AccessibilityProfile.EPUB_A11Y_11_WCAG_21_A] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const conformance = guide.conformance; + + // Then + expect(conformance.shouldDisplay).toBe(true); + expect(conformance.statements.length).toBe(1); + expect(conformance.statements[0].id).toBe("conformance.a"); + }); + + it('should show no conformance when no profiles', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + conformsTo: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const conformance = guide.conformance; + + // Then + expect(conformance.shouldDisplay).toBe(true); + expect(conformance.statements.length).toBe(1); + expect(conformance.statements[0].id).toBe("conformance.no"); + }); + + it('should handle multiple conformance profiles', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + conformsTo: [ + AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AAA, + AccessibilityProfile.EPUB_A11Y_11_WCAG_21_AA + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const conformance = guide.conformance; + + // Then + expect(conformance.shouldDisplay).toBe(true); + expect(conformance.statements.length).toBe(1); + expect(conformance.statements[0].id).toBe("conformance.aaa"); + }); + }); + + describe('Legal', () => { + it('should handle no metadata case', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility() + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(false); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.no-metadata"); + }); + + it('should handle no exemptions case', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(false); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.no-metadata"); + }); + + it('should handle NONE exemption', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [Exemption.NONE] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should handle DOCUMENTED exemption', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [Exemption.DOCUMENTED] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should handle LEGAL exemption', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [Exemption.LEGAL] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should handle TEMPORARY exemption', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [Exemption.TEMPORARY] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should handle TECHNICAL exemption', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [Exemption.TECHNICAL] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should handle EU Accessibility Act exemptions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [ + Exemption.EAA_DISPROPORTIONATE_BURDEN, + Exemption.EAA_FUNDAMENTAL_ALTERATION, + Exemption.EAA_MICROENTERPRISE, + Exemption.EAA_TECHNICAL_IMPOSSIBILITY, + Exemption.EAA_TEMPORARY + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should handle multiple exemptions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [ + Exemption.DOCUMENTED, + Exemption.LEGAL, + Exemption.TEMPORARY + ] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(true); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.exempt"); + }); + + it('should show no metadata when no exemptions', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + exemption: [] + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const legal = guide.legal; + + // Then + expect(legal.shouldDisplay).toBe(false); + expect(legal.statements.length).toBe(1); + expect(legal.statements[0].id).toBe("legal-considerations.no-metadata"); + }); + }); + + describe('AccessibilitySummary', () => { + it('should handle no summary case', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility() + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const summary = guide.accessibilitySummary; + + // Then + expect(summary.shouldDisplay).toBe(true); + expect(summary.statements.length).toBe(1); + expect(summary.statements[0].id).toBe("accessibility-summary.no-metadata"); + }); + + it('should handle empty summary case', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + summary: '' + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const summary = guide.accessibilitySummary; + + // Then + expect(summary.shouldDisplay).toBe(true); + expect(summary.statements.length).toBe(1); + expect(summary.statements[0].id).toBe("accessibility-summary.no-metadata"); + }); + + it('should handle valid summary', () => { + // Given + const publication = createPublication({ + accessibility: new Accessibility({ + summary: 'Some summary text' + }) + }); + + // When + const guide = new AccessibilityMetadataDisplayGuide(publication); + const summary = guide.accessibilitySummary; + + // Then + expect(summary.shouldDisplay).toBe(true); + expect(summary.statements.length).toBe(1); + expect(summary.statements[0].compactString).toBe('Some summary text'); + }); + }); +}); \ No newline at end of file diff --git a/shared/test/accessibility/Localization.test.ts b/shared/test/accessibility/Localization.test.ts new file mode 100644 index 00000000..37c67db8 --- /dev/null +++ b/shared/test/accessibility/Localization.test.ts @@ -0,0 +1,101 @@ +import { Localization } from '../../src/publication/accessibility/Localization'; + +describe('Localization', () => { + // Reset the singleton instance before each test + beforeEach(() => { + // @ts-ignore - Accessing private member for testing + Localization.instance = undefined; + }); + + it('should be a singleton', () => { + const instance1 = Localization; + const instance2 = Localization; + expect(instance1).toBe(instance2); + }); + + it('should have English as default locale', () => { + const l10n = Localization; + expect(l10n.getCurrentLocale()).toBe('en'); + }); + + it('should register a new locale', () => { + const l10n = Localization; + const testLocale = { + test: { + key: { + compact: 'Test', + descriptive: 'Test Description' + } + } + }; + + l10n.registerLocale('test', testLocale); + expect(l10n.setLocale('test')).toBe(true); + expect(l10n.getCurrentLocale()).toBe('test'); + expect(l10n.getString('test.key').compact).toBe('Test'); + expect(l10n.getString('test.key').descriptive).toBe('Test Description'); + }); + + it('should fall back to English when key is missing', () => { + const l10n = Localization; + + // First get the English value for our test key + const testKey = 'publication.metadata.accessibility.hazards.none'; + const englishValue = l10n.getString(testKey); + + // Register a test locale that doesn't have our test key + const testLocale = { + test: { + someKey: { + compact: 'Test', + descriptive: 'Test Description' + } + } + }; + + // Register the test locale and switch to it + l10n.registerLocale('test', testLocale); + l10n.setLocale('test'); + + // Should return the test value for a key that exists in the test locale + const testResult = l10n.getString('test.someKey'); + expect(testResult).toEqual({ + compact: 'Test', + descriptive: 'Test Description' + }); + + // Should fall back to English for a key that doesn't exist in the test locale + const result = l10n.getString(testKey); + expect(result).toEqual(englishValue); + }); + + it('should handle nested keys', () => { + const l10n = Localization; + const key = 'publication.metadata.accessibility.hazards.none'; + const result = l10n.getString(key); + + expect(result).toHaveProperty('compact'); + expect(result).toHaveProperty('descriptive'); + expect(typeof result.compact).toBe('string'); + expect(typeof result.descriptive).toBe('string'); + }); + + it('should return empty strings for invalid keys', () => { + const l10n = Localization; + const result = l10n.getString('invalid.key.that.does.not.exist'); + + expect(result).toEqual({ + compact: '', + descriptive: '' + }); + }); + + it('should list available locales', () => { + const l10n = Localization; + const locales = l10n.getAvailableLocales(); + + expect(locales).toContain('en'); + expect(locales).toContain('fr'); + expect(locales.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 65f5c378..2a786dfd 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -25,10 +25,11 @@ "noUnusedParameters": true, // use Node's module resolution algorithm, instead of the legacy TS one "moduleResolution": "node", + // Enable JSON imports and ESM/CJS interop + "resolveJsonModule": true, + "esModuleInterop": true, // transpile JSX to React.createElement "jsx": "react", - // interop between ESM and CJS modules. Recommended by TS - "esModuleInterop": true, // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS "skipLibCheck": true, // error out if import and file system have a casing mismatch. Recommended by TS @@ -36,6 +37,6 @@ // emit only .d.ts "noEmit": false, "emitDeclarationOnly": true, - "types": ["vite/client"] + "types": ["vite/client", "jest"] }, } diff --git a/shared/update-accessibility-locales.mjs b/shared/update-accessibility-locales.mjs new file mode 100644 index 00000000..41812ba5 --- /dev/null +++ b/shared/update-accessibility-locales.mjs @@ -0,0 +1,108 @@ +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { promises as fs } from 'fs'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TARGET_DIR = join(__dirname, 'src', 'publication', 'accessibility', 'locales'); +const THORIUM_LOCALES_REPO = 'https://github.com/edrlab/thorium-locales.git'; +const THORIUM_LOCALES_DIR = join(__dirname, 'temp-thorium-locales'); + +function cloneOrUpdateRepo() { + return fs.access(THORIUM_LOCALES_DIR) + .then(() => { + console.log('Updating thorium-locales repository...'); + return execAsync('git pull', { cwd: THORIUM_LOCALES_DIR }); + }) + .catch(() => { + console.log('Cloning thorium-locales repository...'); + return execAsync(`git clone ${THORIUM_LOCALES_REPO} ${THORIUM_LOCALES_DIR}`); + }); +} + +function extractAccessibilityLocales() { + // Ensure target directory exists + return fs.mkdir(TARGET_DIR, { recursive: true }) + .then(() => { + // Read all JSON files in the publication-metadata directory + const sourceDir = join(THORIUM_LOCALES_DIR, 'publication-metadata'); + return fs.readdir(sourceDir) + .then(files => files.filter(file => file.endsWith('.json'))) + .then(files => { + const processFile = (index) => { + if (index >= files.length) { + console.log('Extraction completed successfully!'); + return Promise.resolve(); + } + + const file = files[index]; + const langCode = file.replace('.json', ''); + const filePath = join(sourceDir, file); + + // Read and parse the source file + return fs.readFile(filePath, 'utf-8') + .then(content => { + let data; + try { + data = JSON.parse(content); + } catch (e) { + console.error(`Error parsing ${file}:`, e); + return processFile(index + 1); + } + + // Extract the accessibility.display-guide part + const accessibilityData = data && + data.publication && + data.publication.metadata && + data.publication.metadata.accessibility && + data.publication.metadata.accessibility['display-guide']; + + if (accessibilityData) { + // Create the output file path + const outputFile = join(TARGET_DIR, `${langCode}.json`); + + // Write the extracted data to the new file + return fs.writeFile( + outputFile, + JSON.stringify(accessibilityData, null, 2) + '\n', + 'utf-8' + ) + .then(() => { + console.log(`Extracted ${langCode} accessibility strings to ${outputFile}`); + return processFile(index + 1); + }); + } else { + console.warn(`No accessibility strings found in ${file}`); + return processFile(index + 1); + } + }); + }; + + return processFile(0); + }); + }) + .catch(error => { + console.error('Error extracting accessibility locales:', error); + process.exit(1); + }); +} + +function cleanup() { + // Remove the temporary directory + return fs.rm(THORIUM_LOCALES_DIR, { recursive: true, force: true }) + .catch(error => { + console.warn('Error during cleanup:', error); + }); +} + +// Main execution +cloneOrUpdateRepo() + .then(extractAccessibilityLocales) + .then(cleanup) + .catch(error => { + console.error('An error occurred:', error); + process.exit(1); + });