diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fee815bcd0..6b0c2758d0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,14 +7,14 @@ on: env: platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone SE (3rd generation)' }} + device: ${{ 'iPhone 16 Pro' }} commit_sha: ${{ github.sha }} - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer jobs: build: name: Build - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} env: scheme: ${{ 'Readium-Package' }} @@ -26,11 +26,19 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Check Carthage project run: | # Check that the Carthage project is up to date. make carthage-project git diff --exit-code Support/Carthage/Readium.xcodeproj + - name: Check CocoaPods podspecs + run: | + # Check that the podspecs are up to date. + make podspecs + git diff --exit-code Support/CocoaPods/ + if git ls-files --others --exclude-standard Support/CocoaPods/ | grep -q .; then echo "Untracked podspec files found. Run 'make podspecs' and commit the result."; exit 1; fi - name: Build run: | set -eo pipefail @@ -39,10 +47,32 @@ jobs: run: | set -eo pipefail xcodebuild test-without-building -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + - name: Print Swift package versions + run: | + jq -r '.pins[] | "\(.identity): \(.state.version)"' Package.resolved + + # navigator-ui-tests: + # name: Navigator UI Tests + # runs-on: macos-15 + # if: ${{ !github.event.pull_request.draft }} + # steps: + # - name: Checkout + # uses: actions/checkout@v3 + # - name: Install dependencies + # run: | + # brew update + # brew install xcodegen + # # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + # xcrun simctl list + # - name: Test + # run: | + # set -eo pipefail + # make navigator-ui-tests-project + # xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi lint: name: Lint - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} env: scripts: ${{ 'Sources/Navigator/EPUB/Scripts' }} @@ -76,7 +106,7 @@ jobs: int-dev: name: Integration (Local) - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -89,6 +119,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Generate project run: make dev lcp=${{ secrets.LCP_URL_SPM }} - name: Build @@ -98,7 +130,7 @@ jobs: int-spm: name: Integration (Swift Package Manager) - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -117,6 +149,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Generate project run: make spm lcp=${{ secrets.LCP_URL_SPM }} commit=$commit_sha - name: Build @@ -126,7 +160,7 @@ jobs: int-carthage: name: Integration (Carthage) - runs-on: macos-14 + runs-on: macos-15 if: ${{ !github.event.pull_request.draft && github.ref == 'refs/heads/main' }} defaults: run: @@ -145,6 +179,8 @@ jobs: run: | brew update brew install xcodegen + # Preload the list of simulator for xcodebuild. The workflow is flaky without it. + xcrun simctl list - name: Generate project run: make carthage lcp=${{ secrets.LCP_URL_CARTHAGE }} commit=$commit_sha - name: Build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..7de4ca71b4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,78 @@ +name: Documentation + +on: + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: write + +# Allow only one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Determine Version + id: versioning + run: | + git fetch --tags --force + VERSION=$(git describe --tag --match "[0-9]*" --abbrev=0) + echo "READIUM_VERSION=$VERSION" >> $GITHUB_OUTPUT + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "folder=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "folder=latest" >> $GITHUB_OUTPUT + fi + + - name: Generate Documentation + run: | + chmod +x BuildTools/Scripts/generate-docs.sh + ./BuildTools/Scripts/generate-docs.sh ${{ steps.versioning.outputs.READIUM_VERSION }} + ./BuildTools/Scripts/generate-docs.sh latest + + - name: Setup Root Redirect + run: | + cat < docs-site/swift-toolkit/index.html + + + + + + + +

Redirecting to latest documentation...

+ + EOF + + - name: Deploy Versioned Folder πŸš€ + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs-site/swift-toolkit/${{ steps.versioning.outputs.READIUM_VERSION }} + target-folder: ${{ steps.versioning.outputs.READIUM_VERSION }} + clean: true + + - name: Deploy Latest Folder πŸš€ + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs-site/swift-toolkit/latest + target-folder: latest + clean: true + + - name: Deploy Root Redirect πŸš€ + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs-site/swift-toolkit + target-folder: . + clean: false diff --git a/.gitignore b/.gitignore index 0c5fcb4e6e..99e5a89a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,10 @@ playground.xcworkspace ## IntelliJ out/ +## Claude Code +.claude +CLAUDE.md + +# DocC generation +.build-docs +docs-site diff --git a/BuildTools/Empty.swift b/BuildTools/Empty.swift index e54e11f6a4..410a1329ce 100644 --- a/BuildTools/Empty.swift +++ b/BuildTools/Empty.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/BuildTools/Package.resolved b/BuildTools/Package.resolved index f85cfb22bb..79fa37f1a3 100644 --- a/BuildTools/Package.resolved +++ b/BuildTools/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", "state": { "branch": null, - "revision": "7ff506897aa5bdaf94f077087a2025b9505da112", - "version": "0.51.9" + "revision": "22a472ced4c621a0e41b982a6f32dec868d09392", + "version": "0.59.1" } } ] diff --git a/BuildTools/Package.swift b/BuildTools/Package.swift index 35ed5b2c1e..87b47e52de 100644 --- a/BuildTools/Package.swift +++ b/BuildTools/Package.swift @@ -11,7 +11,13 @@ let package = Package( name: "BuildTools", platforms: [.macOS(.v10_11)], dependencies: [ - .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.51.6"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.59.1"), ], - targets: [.target(name: "BuildTools", path: "")] + targets: [ + .target(name: "BuildTools", path: "", exclude: ["Sources"]), + .target( + name: "GeneratePodspecs", + path: "Sources/GeneratePodspecs" + ), + ] ) diff --git a/BuildTools/Scripts/convert-a11y-display-guide-localizations.js b/BuildTools/Scripts/convert-a11y-display-guide-localizations.js deleted file mode 100644 index 9628a342dc..0000000000 --- a/BuildTools/Scripts/convert-a11y-display-guide-localizations.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Copyright 2025 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - * - * This script can be used to convert the localized files from https://github.com/w3c/publ-a11y-display-guide-localizations - * into other output formats for various platforms. - */ - -const fs = require('fs'); -const path = require('path'); -const [inputFolder, outputFormat, outputFolder, keyPrefix = ''] = process.argv.slice(2); - -/** - * Ends the script with the given error message. - */ -function fail(message) { - console.error(`Error: ${message}`); - process.exit(1); -} - -/** - * Converter for Apple localized strings. - */ -function convertApple(lang, version, keys, keyPrefix, write) { - let disclaimer = `DO NOT EDIT. File generated automatically from v${version} of the ${lang} JSON strings.`; - - let stringsOutput = `// ${disclaimer}\n\n`; - for (const [key, value] of Object.entries(keys)) { - stringsOutput += `"${keyPrefix}${key}" = "${value}";\n`; - } - let stringsFile = path.join(`Resources/${lang}.lproj`, 'W3CAccessibilityMetadataDisplayGuide.strings'); - write(stringsFile, stringsOutput); - - // Using the "base" language, we will generate a static list of string keys to validate them at compile time. - if (lang == 'en-US') { - writeSwiftExtensions(disclaimer, keys, keyPrefix, write); - } -} - -/** - * Generates a static list of string keys to validate them at compile time. - */ -function writeSwiftExtensions(disclaimer, keys, keyPrefix, write) { - let keysOutput = `// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -// ${disclaimer}\n\npublic extension AccessibilityDisplayString {\n` - let keysList = Object.keys(keys) - .filter((k) => !k.endsWith("-descriptive")) - .map((k) => removeSuffix(k, "-compact")); - for (const key of keysList) { - keysOutput += ` static let ${convertKebabToCamelCase(key)}: Self = "${keyPrefix}${key}"\n`; - } - keysOutput += "}\n" - write("Publication/Accessibility/AccessibilityDisplayString+Generated.swift", keysOutput); -} - -const converters = { - apple: convertApple -}; - -if (!inputFolder || !outputFormat || !outputFolder) { - console.error('Usage: node convert.js [key-prefix]'); - process.exit(1); -} - -const langFolder = path.join(inputFolder, 'lang'); -if (!fs.existsSync(langFolder)) { - fail(`the specified input folder does not contain a 'lang' directory`); -} - -const convert = converters[outputFormat]; -if (!convert) { - fail(`unrecognized output format: ${outputFormat}, try: ${Object.keys(converters).join(', ')}.`); -} - -fs.readdir(langFolder, (err, langDirs) => { - if (err) { - fail(`reading directory: ${err.message}`); - } - - langDirs.forEach(langDir => { - const langDirPath = path.join(langFolder, langDir); - - fs.readdir(langDirPath, (err, files) => { - if (err) { - fail(`reading language directory ${langDir}: ${err.message}`); - } - - files.forEach(file => { - const filePath = path.join(langDirPath, file); - if (path.extname(file) === '.json') { - fs.readFile(filePath, 'utf8', (err, data) => { - if (err) { - console.error(`Error reading file ${file}: ${err.message}`); - return; - } - - try { - const jsonData = JSON.parse(data); - const version = jsonData["metadata"]["version"]; - convert(langDir, version, parseJsonKeys(jsonData), keyPrefix, write); - } catch (err) { - fail(`parsing JSON from file ${file}: ${err.message}`); - } - }); - } - }); - }); - }); -}); - -/** - * Writes the given content to the file path relative to the outputFolder provided in the CLI arguments. - */ -function write(relativePath, content) { - const outputPath = path.join(outputFolder, relativePath); - const outputDir = path.dirname(outputPath); - - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - fs.writeFile(outputPath, content, 'utf8', err => { - if (err) { - fail(`writing file ${outputPath}: ${err.message}`); - } else { - console.log(`Wrote ${outputPath}`); - } - }); -} - -/** - * Collects the JSON translation keys. - */ -function parseJsonKeys(obj) { - const keys = {}; - for (const key in obj) { - if (key === 'metadata') continue; // Ignore the metadata key - if (typeof obj[key] === 'object') { - for (const subKey in obj[key]) { - if (typeof obj[key][subKey] === 'object') { - for (const innerKey in obj[key][subKey]) { - const fullKey = `${subKey}-${innerKey}`; - keys[fullKey] = obj[key][subKey][innerKey]; - } - } else { - keys[subKey] = obj[key][subKey]; - } - } - } - } - return keys; -} - -function convertKebabToCamelCase(string) { - return string - .split('-') - .map((word, index) => { - if (index === 0) { - return word; - } - return word.charAt(0).toUpperCase() + word.slice(1); - }) - .join(''); -} - -function removeSuffix(str, suffix) { - if (str.endsWith(suffix)) { - return str.slice(0, -suffix.length); - } - return str; -} \ No newline at end of file diff --git a/BuildTools/Scripts/convert-thorium-localizations.js b/BuildTools/Scripts/convert-thorium-localizations.js new file mode 100644 index 0000000000..3e05ec6241 --- /dev/null +++ b/BuildTools/Scripts/convert-thorium-localizations.js @@ -0,0 +1,412 @@ +/** + * Copyright 2026 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + * + * This script converts the localized files from https://github.com/edrlab/thorium-locales/ + * into Apple .strings format. + */ + +const fs = require('fs'); +const fsPromises = fs.promises; +const path = require('path'); +const { generateAccessibilityDisplayStringExtensions } = require('./generate-a11y-extensions'); + +/** + * Plural suffixes used in thorium-locales JSON files (underscore format). + */ +const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other']; + +/** + * Languages to process from thorium-locales. + */ +const LANGUAGES = ['en', 'fr', 'it']; + +/** + * Represents a localization key with support for plural forms. + */ +class LocalizationKey { + constructor(base, pluralForm = null) { + this.base = base; + this.pluralForm = pluralForm; + } + + stripPrefix(prefix) { + if (!prefix || !this.base.startsWith(prefix)) { + return this; + } + return new LocalizationKey(this.base.slice(prefix.length), this.pluralForm); + } + + toCamelCase() { + const camel = this.base + .split('-') + .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(''); + return new LocalizationKey(camel, this.pluralForm); + } + + matchesAnyPrefix(prefixes) { + return prefixes.some(prefix => this.base.startsWith(prefix)); + } + + toString() { + return this.base; + } +} + +/** + * Represents a localization entry (key-value pair). + */ +class LocalizationEntry { + constructor(key, value, sourceKey = null) { + this.key = key; + this.value = value; + this.sourceKey = sourceKey; + this._placeholders = null; + } + + get placeholders() { + if (this._placeholders === null) { + const regex = /\{\{\s*(\w+)\s*\}\}/g; + const found = []; + let match; + while ((match = regex.exec(this.value)) !== null) { + if (!found.includes(match[1])) { + found.push(match[1]); + } + } + this._placeholders = found; + } + return this._placeholders; + } + + get hasPlaceholders() { + return this.placeholders.length > 0; + } +} + +/** + * Configuration for a thorium-locales project. + */ +class LocaleConfig { + constructor({ + folder, + stripPrefix = '', + outputPrefix = '', + outputFolder, + includePrefixes = null, + tableName = 'Localizable', + keyTransform = null, + postProcess = null, + convertKeysToCamelCase = true + }) { + if (!folder || !outputFolder) { + throw new Error('LocaleConfig requires folder and outputFolder'); + } + this.folder = folder; + this.stripPrefix = stripPrefix; + this.outputPrefix = outputPrefix; + this.outputFolder = outputFolder; + this.includePrefixes = includePrefixes; + this.tableName = tableName; + this.keyTransform = keyTransform; + this.postProcess = postProcess; + this.convertKeysToCamelCase = convertKeysToCamelCase; + } + + transformEntry(entry) { + const sourceKey = entry.key; + let base = sourceKey.base; + + if (this.stripPrefix && base.startsWith(this.stripPrefix)) { + base = base.slice(this.stripPrefix.length); + } + if (this.keyTransform) { + base = this.keyTransform(base); + } + if (this.outputPrefix) { + base = this.outputPrefix + base; + } + + const newKey = new LocalizationKey(base, sourceKey.pluralForm); + return new LocalizationEntry(newKey, entry.value, sourceKey); + } + + shouldInclude(entry) { + if (!this.includePrefixes) { + return true; + } + return entry.key.matchesAnyPrefix(this.includePrefixes); + } +} + +// ============================================================================ +// Apple Strings Converter +// ============================================================================ + +/** + * Converts localization entries to Apple .strings format. + */ +class AppleStringsConverter { + constructor(referenceEntries) { + this._placeholderMappings = this._buildPlaceholderMappings(referenceEntries); + } + + /** + * Generates .strings file content for the given entries. + */ + generate(lang, entries, config) { + const outputEntries = entries.map(entry => config.transformEntry(entry)); + + const disclaimer = `DO NOT EDIT. File generated automatically from the ${lang} JSON strings of https://github.com/edrlab/thorium-locales/.`; + let content = `// ${disclaimer}\n\n`; + + for (const entry of outputEntries) { + content += this._formatEntry(entry, config.convertKeysToCamelCase) + '\n'; + } + + // Extract output keys for postProcess + const outputKeys = outputEntries.map(entry => + this._formatKey(entry.key.stripPrefix(config.outputPrefix)) + ); + + return { content, outputKeys }; + } + + _buildPlaceholderMappings(entries) { + const mappings = new Map(); + for (const entry of entries) { + if (!entry.hasPlaceholders) continue; + const baseKey = entry.key.base; + if (mappings.has(baseKey)) continue; + + const mapping = {}; + entry.placeholders.forEach((name, index) => { + mapping[name] = index + 1; + }); + mappings.set(baseKey, mapping); + } + return mappings; + } + + _getPlaceholderMapping(key) { + return this._placeholderMappings.get(key.base) || {}; + } + + _formatEntry(entry, convertToCamelCase) { + const transformedKey = convertToCamelCase ? entry.key.toCamelCase() : entry.key; + const outputKey = this._formatKey(transformedKey); + const lookupKey = entry.sourceKey || entry.key; + const mapping = this._getPlaceholderMapping(lookupKey); + const escapedValue = this._escape(entry.value); + const convertedValue = this._convertPlaceholders(escapedValue, mapping); + return `"${outputKey}" = "${convertedValue}";`; + } + + _formatKey(key) { + if (key.pluralForm) { + return `${key.base}@${key.pluralForm}`; + } + return key.base; + } + + _escape(value) { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/%/g, '%%'); + } + + _convertPlaceholders(value, mapping) { + if (Object.keys(mapping).length === 0) { + return value; + } + return value.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name) => { + const position = mapping[name]; + if (position === undefined) { + return match; + } + const formatSpec = name === 'count' ? 'd' : '@'; + return `%${position}$${formatSpec}`; + }); + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} + +function writeFile(relativePath, content) { + const outputDir = path.dirname(relativePath); + + try { + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(relativePath, content, 'utf8'); + console.log(`Wrote ${relativePath}`); + } catch (err) { + fail(`Failed to write ${relativePath}: ${err.message}`); + } +} + +// ============================================================================ +// JSON Parsing +// ============================================================================ + +function parseJsonEntries(obj, prefix = '') { + const entries = []; + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'string') { + const pluralSuffix = PLURAL_SUFFIXES.find(suffix => fullKey.endsWith(suffix)); + if (pluralSuffix) { + const baseKey = fullKey.slice(0, -pluralSuffix.length); + const pluralForm = pluralSuffix.slice(1); + entries.push(new LocalizationEntry(new LocalizationKey(baseKey, pluralForm), value)); + } else { + entries.push(new LocalizationEntry(new LocalizationKey(fullKey), value)); + } + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + entries.push(...parseJsonEntries(value, fullKey)); + } else { + console.warn(`Warning: Skipping unexpected value type for key "${fullKey}": ${typeof value}`); + } + } + + return entries; +} + +async function loadLanguageEntries(inputFolder, localeFolder) { + const languageEntries = new Map(); + const folderPath = path.join(inputFolder, localeFolder); + + if (!fs.existsSync(folderPath)) { + fail(`the ${localeFolder} folder was not found at ${folderPath}`); + } + + console.log(`Processing folder: ${localeFolder}`); + + const files = await fsPromises.readdir(folderPath); + + for (const file of files) { + if (path.extname(file) !== '.json') continue; + + const lang = path.basename(file, '.json').replace(/_/g, '-'); + if (!LANGUAGES.includes(lang)) continue; + + const filePath = path.join(folderPath, file); + + try { + const data = await fsPromises.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(data); + const entries = parseJsonEntries(jsonData); + + if (!languageEntries.has(lang)) { + languageEntries.set(lang, []); + } + languageEntries.get(lang).push(...entries); + } catch (err) { + fail(`processing ${file}: ${err.message}`); + } + } + + return languageEntries; +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +const PROJECTS = { + lcp: new LocaleConfig({ + folder: 'lcp', + outputPrefix: 'readium.', + outputFolder: 'Sources/LCP/Resources', + includePrefixes: ['lcp.dialog'] + }), + + a11y: new LocaleConfig({ + folder: 'publication-metadata', + stripPrefix: 'publication.metadata.accessibility.display-guide.', + outputPrefix: 'readium.a11y.', + outputFolder: 'Sources/Shared/Resources', + includePrefixes: ['publication.metadata.accessibility.display-guide'], + tableName: 'W3CAccessibilityMetadataDisplayGuide', + keyTransform: key => key.replace(/\./g, '-'), + convertKeysToCamelCase: false, + postProcess: (lang, keys, config) => { + if (lang === 'en') { + generateAccessibilityDisplayStringExtensions( + keys, + 'Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift', + config.outputPrefix, + writeFile + ); + } + } + }) +}; + +const args = process.argv.slice(2); +const [inputFolder, ...projectNames] = args; + +if (!inputFolder) { + console.error('Usage: node convert-thorium-localizations.js [project...]'); + console.error(''); + console.error('Arguments:'); + console.error(' input-folder Path to the cloned thorium-locales repository'); + console.error(' project Optional project name(s) to process (default: all)'); + console.error(''); + console.error(`Available projects: ${Object.keys(PROJECTS).join(', ')}`); + process.exit(1); +} + +const projectsToProcess = projectNames.length > 0 + ? projectNames + : Object.keys(PROJECTS); + +async function processLocales(inputFolder, projectsToProcess) { + for (const projectName of projectsToProcess) { + const config = PROJECTS[projectName]; + if (!config) { + fail(`Unknown project: ${projectName}. Available: ${Object.keys(PROJECTS).join(', ')}`); + } + + console.log(`\nProcessing project: ${projectName}`); + + const languageEntries = await loadLanguageEntries(inputFolder, config.folder); + + // Filter entries + for (const [lang, entries] of languageEntries) { + const filtered = entries.filter(entry => config.shouldInclude(entry)); + languageEntries.set(lang, filtered); + } + + // Create converter with English entries as reference + const englishEntries = languageEntries.get('en') || []; + const converter = new AppleStringsConverter(englishEntries); + + for (const [lang, entries] of languageEntries) { + const { content, outputKeys } = converter.generate(lang, entries, config); + + // Write .strings file + const outputPath = path.join(config.outputFolder, `${lang}.lproj`, `${config.tableName}.strings`); + writeFile(outputPath, content); + + // Run postProcess hook + if (config.postProcess) { + config.postProcess(lang, outputKeys, config); + } + } + } +} + +processLocales(inputFolder, projectsToProcess).catch(err => fail(err.message)); diff --git a/BuildTools/Scripts/generate-a11y-extensions.js b/BuildTools/Scripts/generate-a11y-extensions.js new file mode 100644 index 0000000000..8392f5bcce --- /dev/null +++ b/BuildTools/Scripts/generate-a11y-extensions.js @@ -0,0 +1,68 @@ +/** + * Copyright 2026 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + * + * This module generates Swift extensions for AccessibilityDisplayString + * from localization keys. + */ + +/** + * Generates AccessibilityDisplayString Swift extension from localization keys. + * + * @param {string[]} keys - Array of localization keys (without prefix) + * @param {string} outputPath - Path to write the generated Swift file + * @param {string} keyPrefix - Prefix used in localization keys (e.g., "readium.a11y.") + * @param {function} write - Write function (relativePath, content) => void + */ +function generateAccessibilityDisplayStringExtensions(keys, outputPath, keyPrefix, write) { + const disclaimer = 'DO NOT EDIT. File generated automatically from https://github.com/edrlab/thorium-locales/.'; + + // Filter out -descriptive keys (keep base keys only) and remove -compact suffix + const filteredKeys = keys + .filter(k => !k.endsWith('-descriptive')) + .map(k => removeSuffix(k, '-compact')); + + // Remove duplicates (since we removed -compact suffix, some keys may now be the same) + const uniqueKeys = [...new Set(filteredKeys)]; + + let output = `// ${disclaimer} +public extension AccessibilityDisplayString { +`; + + for (const key of uniqueKeys) { + const swiftName = convertKebabToCamelCase(key); + output += ` static let ${swiftName}: Self = "${keyPrefix}${key}"\n`; + } + + output += '}\n'; + + write(outputPath, output); +} + +/** + * Converts a kebab-case string to camelCase. + */ +function convertKebabToCamelCase(string) { + return string + .split('-') + .map((word, index) => { + if (index === 0) { + return word; + } + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(''); +} + +/** + * Removes a suffix from a string if present. + */ +function removeSuffix(str, suffix) { + if (str.endsWith(suffix)) { + return str.slice(0, -suffix.length); + } + return str; +} + +module.exports = { generateAccessibilityDisplayStringExtensions }; diff --git a/BuildTools/Scripts/generate-docs.sh b/BuildTools/Scripts/generate-docs.sh new file mode 100755 index 0000000000..5fc3a65de9 --- /dev/null +++ b/BuildTools/Scripts/generate-docs.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# ============================================================================= +# Readium Swift Toolkit - Documentation Generator +# ============================================================================= +# This script automates the creation of a static DocC documentation site. +# It handles: +# 1. Cross-compiling the Swift package for the iOS Simulator. +# 2. Generating symbol graphs (API metadata) for all modules. +# 3. filtering out 3rd-party dependencies from the docs. +# 4. Assembling the DocC catalog from the 'docs/' folder. +# 5. Converting everything into a static HTML website. +# ============================================================================= + +set -e # Exit immediately if any command exits with a non-zero status. + +# ----------------------------------------------------------------------------- +# 1. Configuration +# ----------------------------------------------------------------------------- +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" +cd "$PROJECT_ROOT" + +echo "πŸ“‚ Working directory set to: $(pwd)" + +DOC_VERSION="${1:-latest}" +REPO_NAME="swift-toolkit" +# The final folder where the static HTML site will be generated. +OUTPUT_ROOT="docs-site" +# The site inside is nested in a folder matching the repo name. +# This emulates GitHub Pages URL structure (e.g., username.github.io/swift-toolkit/). +SITE_DIR="$OUTPUT_ROOT/$REPO_NAME/$DOC_VERSION" +# A temporary directory for intermediate build artifacts. +TEMP_DIR=".build-docs" +# The location of the "virtual" DocC catalog. +DOCC_CATALOG_DIR="$TEMP_DIR/Readium.docc" +# Where SwiftPM will dump the raw symbol graph JSON files. +SYMBOL_GRAPHS_DIR="$TEMP_DIR/symbol-graphs" + +# ----------------------------------------------------------------------------- +# 2. Argument Parsing +# ----------------------------------------------------------------------------- +SERVE_SITE=false +for arg in "$@"; do + if [ "$arg" == "--serve" ]; then + SERVE_SITE=true + fi +done + +# ----------------------------------------------------------------------------- +# 3. Cleanup +# ----------------------------------------------------------------------------- +# Remove previous outputs to ensure a clean build. +# This prevents stale files or old symbols from appearing in the new site. +rm -rf "$SITE_DIR" +mkdir -p "$DOCC_CATALOG_DIR" +mkdir -p "$SYMBOL_GRAPHS_DIR" +mkdir -p "$SITE_DIR" + +echo "βš™οΈ Configuring build environment..." + +# ----------------------------------------------------------------------------- +# 4. Environment Setup (Cross-Compilation) +# ----------------------------------------------------------------------------- +# DocC requires a build to generate symbol graphs. +# Because this project imports 'UIKit', it CANNOT be built with macOS. +# It must cross-compile with the iOS Simulator. + +# Find the path to the iOS Simulator SDK on the current machine. +SDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path) + +# Determine the host architecture (arm64 for Apple Silicon, x86_64 for Intel) +# and construct a target triple for the compiler (e.g., arm64-apple-ios15.0-simulator). +HOST_ARCH=$(uname -m) +TARGET_TRIPLE="${HOST_ARCH}-apple-ios15.0-simulator" + +echo " β€’ SDK: $SDK_PATH" +echo " β€’ Target: $TARGET_TRIPLE" + +# ----------------------------------------------------------------------------- +# 5. Build & Symbol Generation +# ----------------------------------------------------------------------------- +echo "πŸ”§ Patching Package.swift for macOS compatibility..." +# Define a cleanup function to restore the original file on exit/error +restore_package() { + if [ -f Package.swift.orig ]; then + mv Package.swift.orig Package.swift + fi +} +trap restore_package EXIT + +# Back up the original file +cp Package.swift Package.swift.orig + +# Inject .macOS(.v11) into the platforms array +# This satisfies the dependency graph validation for ReadiumZIPFoundation +sed -i '' 's/\(\.iOS("[^"]*")\)]/\1, .macOS(.v11)]/' Package.swift + +echo "🧹 Cleaning build artifacts..." +# Delete the .build folder to force SwiftPM to re-emit symbol graphs. +# If this isn't done, incremental builds might skip the documentation step. +rm -rf .build + +echo "βš™οΈ Building symbol graphs..." +# Run 'swift build' with specific flags: +# --sdk / --triple: Forces the build to target iOS Simulator (enabling UIKit). +# -Xswiftc -emit-symbol-graph: Tells the Swift compiler to generate documentation data. +# -Xswiftc -emit-symbol-graph-dir: Tells it where to save the .symbols.json files. +swift build \ + --sdk "$SDK_PATH" \ + --triple "$TARGET_TRIPLE" \ + -Xswiftc -emit-symbol-graph \ + -Xswiftc -emit-symbol-graph-dir -Xswiftc "$SYMBOL_GRAPHS_DIR" + +# ----------------------------------------------------------------------------- +# 6. Filter Dependencies +# ----------------------------------------------------------------------------- +echo "🧹 Filtering dependencies..." +# SwiftPM generates documentation for EVERYTHING in the dependency graph. +# Only Readium modules go in the sidebar. +# Find all .symbols.json files that do NOT start with "Readium" and delete them. +find "$SYMBOL_GRAPHS_DIR" -type f -name "*.symbols.json" ! -name "Readium*" -delete + +# ----------------------------------------------------------------------------- +# 7. Prepare Documentation Catalog +# ----------------------------------------------------------------------------- +echo "πŸ“„ Preparing documentation catalog..." + +# We create a temporary DocC bundle structure +# and copy the contents of the 'docs' folder into it. +if [ -d "docs" ]; then + cp -R docs/* "$DOCC_CATALOG_DIR/" +else + echo "⚠️ Warning: 'docs' folder not found. Site may be empty." +fi + +# Validation: Ensure the root landing page exists. +# Without this file, DocC will fail or produce an empty root. +if [ ! -f "$DOCC_CATALOG_DIR/Readium.md" ]; then + echo "❌ Error: docs/Readium.md is missing." + echo " Please create this file with @TechnologyRoot metadata." + exit 1 +fi + +echo "πŸš€ Generating site..." + +# ----------------------------------------------------------------------------- +# 8. DocC Conversion (Static Site Generation) +# ----------------------------------------------------------------------------- +# Find the 'docc' tool inside Xcode. +DOCC_EXEC=$(xcrun --find docc) + +# Run the conversion: +# --additional-symbol-graph-dir: Where the filtered symbols are stored. +# --transform-for-static-hosting: Generates a site compatible with GitHub Pages. +# --hosting-base-path: Critical for GitHub Pages. Sets the root URL path (e.g. /swift-toolkit/). +$DOCC_EXEC convert "$DOCC_CATALOG_DIR" \ + --additional-symbol-graph-dir "$SYMBOL_GRAPHS_DIR" \ + --output-dir "$SITE_DIR" \ + --fallback-display-name "Readium" \ + --transform-for-static-hosting \ + --hosting-base-path "$REPO_NAME/$DOC_VERSION" + +echo "βœ… Documentation generated at: $SITE_DIR" + +# ----------------------------------------------------------------------------- +# 9. Add SPA Routing (Fixes Root & Deep Links) +# ----------------------------------------------------------------------------- +echo "twisted_rightwards_arrows Adding 404 redirect for SPA routing..." + +# This script handles the redirect for both the root path AND deep links. +cat < "$SITE_DIR/404.html" + + + + + Redirecting... + + + + +

Redirecting to documentation...

+ + +EOF + +cp "$SITE_DIR/index.html" "$SITE_DIR/404.html" + +# ----------------------------------------------------------------------------- +# 10. Local Preview +# ----------------------------------------------------------------------------- +if [ "$SERVE_SITE" = true ]; then + URL="http://localhost:8080/$REPO_NAME/$DOC_VERSION/documentation/readium" + echo "🌍 Serving at $URL" + + # Open the browser + open "$URL" 2>/dev/null || true + + # Run a simple Python HTTP server to serve the static files. + # Serve from OUTPUT_ROOT so the subdirectory /swift-toolkit/ exists. + python3 -m http.server -d "$OUTPUT_ROOT" 8080 +else + echo " Run 'BuildTools/Scripts/generate-docs.sh --serve' to preview." +fi diff --git a/BuildTools/Sources/GeneratePodspecs/Specs.swift b/BuildTools/Sources/GeneratePodspecs/Specs.swift new file mode 120000 index 0000000000..ebd3c6f83a --- /dev/null +++ b/BuildTools/Sources/GeneratePodspecs/Specs.swift @@ -0,0 +1 @@ +../../../Support/CocoaPods/Specs.swift \ No newline at end of file diff --git a/BuildTools/Sources/GeneratePodspecs/main.swift b/BuildTools/Sources/GeneratePodspecs/main.swift new file mode 100644 index 0000000000..313a95e206 --- /dev/null +++ b/BuildTools/Sources/GeneratePodspecs/main.swift @@ -0,0 +1,106 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +// Generates Support/CocoaPods/*.podspec from the module definitions in Specs.swift. +// Run from the repo root via: swift run --package-path BuildTools GeneratePodspecs + +import Foundation + +let outputDir = URL(fileURLWithPath: "Support/CocoaPods") + +guard FileManager.default.fileExists(atPath: outputDir.path) else { + fputs("Error: '\(outputDir.path)' not found. Run this tool from the repository root.\n", stderr) + exit(1) +} + +for module in modules { + let content = render(module) + let dest = outputDir.appendingPathComponent("\(module.name).podspec") + do { + try content.write(to: dest, atomically: true, encoding: .utf8) + print("Wrote \(module.name).podspec") + } catch { + fputs("Error: failed to write \(dest.path): \(error)\n", stderr) + exit(1) + } +} + +func render(_ m: ModuleSpec) -> String { + var lines: [String] = [] + + lines.append("# This file is generated by `make podspecs`. Do not edit manually.") + lines.append("# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate.") + lines.append("") + lines.append("Pod::Spec.new do |s|") + lines.append("") + lines.append(" s.name = \"\(m.name)\"") + lines.append(" s.version = \"\(version)\"") + lines.append(" s.license = \"BSD 3-Clause License\"") + lines.append(" s.summary = \"\(m.summary)\"") + lines.append(" s.homepage = \"http://readium.github.io\"") + lines.append(" s.author = { \"Readium\" => \"contact@readium.org\" }") + lines.append(" s.source = { :git => \"https://github.com/readium/swift-toolkit.git\", :tag => s.version }") + lines.append(" s.requires_arc = true") + + if !m.resourceBundles.isEmpty { + // Sort by key for deterministic output. + let sorted = m.resourceBundles.sorted { $0.key < $1.key } + lines.append(" s.resource_bundles = {") + for (bundleName, patterns) in sorted { + if patterns.count == 1 { + lines.append(" '\(bundleName)' => ['\(patterns[0])'],") + } else { + lines.append(" '\(bundleName)' => [") + for pattern in patterns { + lines.append(" '\(pattern)',") + } + lines.append(" ],") + } + } + lines.append(" }") + } + + lines.append(" s.source_files = \"\(m.sourcePath)/**/*.{m,h,swift}\"") + lines.append(" s.swift_version = '\(swiftVersion)'") + lines.append(" s.platform = :ios") + lines.append(" s.ios.deployment_target = \"\(iosTarget)\"") + + if !m.frameworks.isEmpty { + lines.append(" s.frameworks = \"\(m.frameworks.joined(separator: "\", \""))\"") + } + + if !m.libraries.isEmpty { + let quoted = m.libraries.map { "'\($0)'" }.joined(separator: ", ") + lines.append(" s.libraries = \(quoted)") + } + + if !m.xcconfig.isEmpty { + let sorted = m.xcconfig.sorted { $0.key < $1.key } + let pairs = sorted.map { "'\($0.key)' => '\($0.value)'" }.joined(separator: ", ") + lines.append(" s.xcconfig = { \(pairs) }") + } + + // Required for Swift `package` access level, which needs a -package-name compiler flag. + // SPM sets this automatically from Package.swift `name`; CocoaPods does not. + // All Readium modules share the same package name so that `package` access works across them. + lines.append(" s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name \(packageName)' }") + + if !m.dependencies.isEmpty { + lines.append("") + for dep in m.dependencies { + switch dep { + case let .readium(name): + lines.append(" s.dependency '\(name)', '~> \(version)'") + case let .pod(name, constraint): + lines.append(" s.dependency '\(name)', '\(constraint)'") + } + } + } + + lines.append("") + lines.append("end") + return lines.joined(separator: "\n") + "\n" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2225c6ad2f..78d78c1ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,151 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. -## [Unreleased] + + +## [3.8.0] + +### Added + +#### LCP + +* New Keychain-based implementations of the LCP license and passphrase repositories: `LCPKeychainLicenseRepository` and `LCPKeychainPassphraseRepository`. + * Stored securely in the iOS/macOS Keychain. + * Persist across app reinstalls. + * Optionally synchronized across devices via iCloud Keychain. + +### Changed + +#### Navigator + +* The EPUB navigator no longer requires an HTTP server. Publication resources are now served directly to the web views using a custom URL scheme handler. + * The `httpServer` parameter of `EPUBNavigatorViewController` is deprecated and ignored. + +### Deprecated + +#### Navigator + +* `CBZNavigatorViewController` is now deprecated. + * Open CBZ publications with `EPUBNavigatorViewController` instead, which has more configuration options and preferences. + +#### LCP + +* `ReadiumAdapterLCPSQLite` is now deprecated in favor of the built-in Keychain repositories. See [the migration guide](docs/Migration%20Guide.md) for instructions. + +### Fixed + +* Fixed casting of `ResourceProperties`'s `mediaType` (contributed by [@lbeus](https://github.com/readium/swift-toolkit/pull/719)). + +#### Navigator + +* The first resource of a fixed-layout EPUB is now displayed on its own by default, matching Apple Books behavior. +* Fixed the default spread position for single fixed-layout EPUB spreads that are not the first page. + +#### LCP + +* Fixed the `print` method consuming copy rights instead of print rights. + + +## [3.7.0] + +### Added + +#### Shared + +* Added support for JXL (JPEG XL) bitmap images. JXL is decoded natively on iOS 17+. +* `Publication.cover()` now falls back on the first reading order resource if it's a bitmap image and no cover is declared. + +#### Navigator + +* Support for displaying Divina (image-based publications like CBZ) in the fixed-layout EPUB navigator. +* Bitmap images in the EPUB reading order are now supported as a fixed layout resource. +* Added `offsetFirstPage` preference for fixed-layout EPUBs to control whether the first page is displayed alone or alongside the second page when spreads are enabled. + +#### Streamer + +* The `ImageParser` now extracts metadata from `ComicInfo.xml` files in CBZ archives. +* EPUB manifest item fallbacks are now exposed as `alternates` in the corresponding `Link`. +* EPUBs with only bitmap images in the spine are now treated as Divina publications with fixed layout. + * When an EPUB spine item is HTML with a bitmap image fallback (or vice versa), the image is preferred as the primary link. +* Standalone audio files (e.g. MP3) metadata extraction now includes `narrators` (from the composer metadata fields) and merges artist metadata into `authors`, following conventions used by common audiobook tools. + +### Changed + +* The iOS minimum deployment target is now iOS 15.0. + +#### Shared + +* Accessibility display strings are now sourced from the [thorium-locales](https://github.com/edrlab/thorium-locales/) repository (instead of W3C's repository). Contributions are welcome on [Weblate](https://hosted.weblate.org/projects/thorium-reader/publication-metadata/). + +#### LCP + +* The LCP dialog used by `LCPDialogAuthentication` has been redesigned. + * **Breaking:** The LCP dialog localization string keys have been renamed. If you overrode these strings in your app, you must update them. [See the migration guide](docs/Migration%20Guide.md) for the key mapping. +* LCP localized strings are now sourced from the [thorium-locales](https://github.com/edrlab/thorium-locales/) repository. Contributions are welcome on [Weblate](https://hosted.weblate.org/projects/thorium-reader/readium-lcp/). + +### Deprecated + +#### Streamer + +* The EPUB manifest item `id` attribute is no longer exposed in `Link.properties`. +* Removed title inference based on folder names within image and audio archives. Use the archive's filename instead. + +### Fixed + +#### Navigator + +* PDF documents are now opened off the main thread, preventing UI freezes with large files. +* Fixed providing a custom reading order to the `EPUBNavigatorViewController` (contributed by [@lbeus](https://github.com/readium/swift-toolkit/pull/694)). + + +## [3.6.0] + +### Added + +#### Navigator + +* Added `DragPointerObserver` to recognize drag gestures with pointer events. +* Added `DirectionalNavigationAdapter.onNavigation` callback to be notified when a navigation action is triggered. + * This callback is called before executing any navigation action. + * Useful for hiding UI elements when the user navigates, or implementing analytics. +* Added swipe gesture support for navigating in PDF paginated spread mode. +* Added `fit` preference for fixed-layout publications (PDF and FXL EPUB) to control how pages are scaled within the viewport. + * In the PDF navigator, it is only effective in scroll mode. Paginated mode always uses `page` fit due to PDFKit limitations. + +### Deprecated + +#### Navigator + +* `PDFNavigatorViewController.scalesDocumentToFit` is now deprecated and non-functional. The navigator always scales the document to fit the viewport. + +### Changed + +#### Streamer + +* Support for asynchronous callbacks with `onCreatePublication` (contributed by [@smoores-dev](https://github.com/readium/swift-toolkit/pull/673)). + +#### Navigator + +* The `Fit` enum has been redesigned to fit the PDF implementation. + * **Breaking change:** Update any code using the old `Fit` enum values. +* The fixed-layout navigators (PDF and FXL EPUB)'s content inset behavior has changed: + * iPhone: Continues to apply window safe area insets (to account for notch/Dynamic Island). + * iPad/macOS: Now displays edge-to-edge with no automatic safe area insets. + * You can customize this behavior with `VisualNavigatorDelegate.navigatorContentInset(_:)`. + +### Fixed + +#### Navigator + +* Fixed EPUB fixed-layout spread settings not updating after device rotation when the app was in the background. +* Fixed zoom-to-fit scaling in PDF paginated spread mode when `offsetFirstPage` is enabled. + +#### LCP + +* Fixed crash when an EPUB resource is declared as LCP-encrypted in the manifest but contains unencrypted data. + + +## [3.5.0] ### Added @@ -12,6 +156,8 @@ All notable changes to this project will be documented in this file. Take a look * By default, the navigator uses the window's `safeAreaInsets`, which can cause content to shift when the status bar is shown or hidden (since those insets change). To avoid this, implement `navigatorContentInset(_:)` and return insets that remain stable across status bar visibility changes β€” for example, a top inset large enough to accommodate the maximum expected status bar height. * Added `[TTSVoice].filterByLanguage(_:)` to filter TTS voices by language and region. * Added `[TTSVoice].sorted()` to sort TTS voices by region, quality, and gender. +* New experimental positioning of EPUB decorations that places highlights behind text to improve legibility with opaque decorations (contributed by [@ddfreiling](https://github.com/readium/swift-toolkit/pull/665)). + * To opt-in, initialize the `EPUBNavigatorViewController.Configuration` object with `decorationTemplates: HTMLDecorationTemplate.defaultTemplates(alpha: 1.0, experimentalPositioning: true)`. #### LCP @@ -472,8 +618,8 @@ All notable changes to this project will be documented in this file. Take a look * New `VisualNavigatorDelegate` APIs to handle keyboard events (contributed by [@lukeslu](https://github.com/readium/swift-toolkit/pull/267)). * This can be used to turn pages with the arrow keys, for example. -* [Support for custom fonts with the EPUB navigator](docs/Guides/EPUB%20Fonts.md). -* A brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](docs/Guides/Navigator%20Preferences.md) and [migration guide](docs/Migration%20Guide.md). +* [Support for custom fonts with the EPUB navigator](docs/Guides/Navigator/EPUB%20Fonts.md). +* A brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](docs/Guides/Navigator/Preferences.md) and [migration guide](docs/Migration%20Guide.md). * New EPUB user preferences: * `fontWeight` - Base text font weight. * `textNormalization` - Normalize font style, weight and variants, which improves accessibility. @@ -1004,3 +1150,7 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [3.2.0]: https://github.com/readium/swift-toolkit/compare/3.1.0...3.2.0 [3.3.0]: https://github.com/readium/swift-toolkit/compare/3.2.0...3.3.0 [3.4.0]: https://github.com/readium/swift-toolkit/compare/3.3.0...3.4.0 +[3.5.0]: https://github.com/readium/swift-toolkit/compare/3.4.0...3.5.0 +[3.6.0]: https://github.com/readium/swift-toolkit/compare/3.5.0...3.6.0 +[3.7.0]: https://github.com/readium/swift-toolkit/compare/3.6.0...3.7.0 +[3.8.0]: https://github.com/readium/swift-toolkit/compare/3.7.0...3.8.0 diff --git a/Cartfile b/Cartfile index 6e94b1cff9..59de1200ad 100644 --- a/Cartfile +++ b/Cartfile @@ -3,7 +3,6 @@ github "krzyzanowskim/CryptoSwift" ~> 1.8.0 github "ra1028/DifferenceKit" ~> 1.3.0 github "readium/Fuzi" ~> 4.0.0 github "readium/GCDWebServer" ~> 4.0.0 -github "readium/ZIPFoundation" ~> 3.0.0 -# There's a regression with 2.7.4 in SwiftSoup, because they used iOS 13 APIs without bumping the deployment target. -github "scinfu/SwiftSoup" == 2.7.1 +github "readium/ZIPFoundation" ~> 3.0.1 +github "scinfu/SwiftSoup" ~> 2.13.0 github "stephencelis/SQLite.swift" ~> 0.15.0 diff --git a/MAINTAINING.md b/MAINTAINING.md index f51cb4d72c..9525662e5c 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -1,6 +1,73 @@ # Maintaining the Readium Swift toolkit -## Releasing a new version +## Bumping the Minimum iOS Deployment Target + +To bump the minimum required iOS version, update these files: + +- `README.md`, section "Minimum Requirements" +- `Package.swift` +- `Support/Carthage/project.yml` +- `Support/CocoaPods/*.podspec` β€” edit `iosTarget` in `Support/CocoaPods/Specs.swift`, then run `make podspecs` and commit the generated files + +## Creating a New Package + +A new package is a separately distributable SPM library product. It requires updates to four places. + +### 1. `Package.swift` + +Add a new product and its source/test targets: + +```swift +// products: +.library(name: "Readium", targets: ["Readium"]), + +// targets: +.target( + name: "Readium", + dependencies: ["ReadiumShared", "ReadiumNavigator"], + path: "Sources/" +), +.testTarget( + name: "ReadiumTests", + dependencies: ["Readium"], + path: "Tests/Tests" +), +``` + +### 2. `Support/CocoaPods/Readium.podspec` + +Add an entry to `Support/CocoaPods/Specs.swift` and run `make podspecs` to generate the podspec file. + +### 3. `Support/Carthage/project.yml` + +Add a new target and scheme: + +```yaml +targets: + Readium: + type: framework + platform: iOS + deploymentTarget: "15.0" + sources: + - path: ../../Sources/ + dependencies: + - target: ReadiumShared + - target: ReadiumNavigator + settings: + PRODUCT_BUNDLE_IDENTIFIER: org.readium.swift-toolkit.audio-navigator + INFOPLIST_FILE: Info.plist + +schemes: + Readium: + build: + targets: + Readium: all +``` + +> [!WARNING] +> The module name must follow the `Readium` convention, and the iOS deployment target / Swift version must match the values in all other packages. + +## Releasing a New Version You are ready to release a new version of the Swift toolkit? Great, follow these steps: @@ -14,31 +81,26 @@ You are ready to release a new version of the Swift toolkit? Great, follow these ``` 4. Try to run the Test App, adjusting the integration if needed. 5. Delete the Git tag created previously. -3. Update the [migration guide](Documentation/Migration%20Guide.md) in case of breaking changes. -4. Issue the new release. +3. Update the localized strings (`make update-locales`). +4. Review the list of supported features in `README.md`. +5. Update the [migration guide](Documentation/Migration%20Guide.md) in case of breaking changes. +6. Issue the new release. 1. Create a branch with the same name as the future tag, from `develop`. - 2. Bump the version numbers in the `Support/CocoaPods/*.podspec` files. - * :warning: Don't forget to bump the version numbers of the Readium dependencies as well. - 3. Bump the version numbers in `README.md`. + 2. Bump `version` in `Support/CocoaPods/Specs.swift`, run `make podspecs`, and commit the generated files. + 3. Bump the version numbers in `README.md`, and check the "Minimum Requirements" section. 4. Bump the version numbers in `TestApp/Sources/Info.plist`. 5. Close the version in the `CHANGELOG.md`, [for example](https://github.com/readium/swift-toolkit/pull/353/commits/a0714589b3da928dd923ba78f379116715797333#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4ed). 6. Create a PR to merge in `develop` and verify the CI workflows. - 7. Squash and merge the PR. - 8. Tag the new version from `develop`. - ```shell - git checkout develop - git pull - git tag -a 3.0.1 -m 3.0.1 - git push --tags - ``` - 9. Release the updated Podspecs: + 7. Release the updated Podspecs: ```shell cd Support/CocoaPods pod repo add readium git@github.com:readium/podspecs.git pod repo push readium ReadiumInternal.podspec + pod repo push readium ReadiumShared.podspec + pod repo push readium ReadiumStreamer.podspec pod repo push readium ReadiumNavigator.podspec pod repo push readium ReadiumOPDS.podspec @@ -46,10 +108,17 @@ You are ready to release a new version of the Swift toolkit? Great, follow these pod repo push readium ReadiumAdapterGCDWebServer.podspec pod repo push readium ReadiumAdapterLCPSQLite.podspec ``` -5. Verify you can fetch the new version from the latest Test App with `make spm|carthage|cocoapods version=3.0.1` -7. Announce the release. + 8. Squash and merge the PR. + 9. Tag the new version from `develop`. + ```shell + git checkout develop + git pull + git tag -a 3.0.1 -m 3.0.1 + git push --tags + ``` +7. Verify you can fetch the new version from the latest Test App with `make spm|carthage|cocoapods version=3.0.1` +8. Announce the release. 1. Create a new release on GitHub. - 2. Publish a new TestFlight beta with LCP enabled. - * Click on "External Groups" > "Public Beta", then add the new build so that it's available to everyone. -8. Merge `develop` into `main`. - + 2. Write a high-level summary of the changelog for the blog. + 3. Post the blog summary on Discord's `#announcement`, with a link to the GitHub release. +9. Merge `develop` into `main`. diff --git a/Makefile b/Makefile index c068e75bc3..2fce9b1391 100644 --- a/Makefile +++ b/Makefile @@ -3,19 +3,29 @@ CSS_PATH := Sources/Navigator/EPUB/Assets/Static/readium-css help: @echo "Usage: make \n\n\ - carthage-proj\t\tGenerate the Carthage Xcode project\n\ + carthage-project\tGenerate the Carthage Xcode project\n\ + podspecs\t\tGenerate the CocoaPods podspecs\n\ scripts\t\tBundle the Navigator EPUB scripts\n\ test\t\t\tRun unit tests\n\ lint-format\t\tVerify formatting\n\ format\t\tFormat sources\n\ - update-a11y-l10n\tUpdate the Accessibility Metadata Display Guide localization files\n\ + update-locales\tUpdate the localization files\n\ " +.PHONY: podspecs +podspecs: + swift run --package-path BuildTools GeneratePodspecs + .PHONY: carthage-project carthage-project: + rm -rf **/.DS_Store rm -rf $(SCRIPTS_PATH)/node_modules/ xcodegen -s Support/Carthage/project.yml --use-cache --cache-path Support/Carthage/.xcodegen +.PHONY: navigator-ui-tests-project +navigator-ui-tests-project: + xcodegen -s Tests/NavigatorTests/UITests/project.yml + .PHONY: scripts scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) @@ -33,15 +43,6 @@ update-scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) pnpm install --dir "$(SCRIPTS_PATH)" -.PHONY: update-css -update-css: - git clone https://github.com/readium/css.git readium-css - rm -rf "$(CSS_PATH)" - cp -r readium-css/css/dist "$(CSS_PATH)" - git -C readium-css rev-parse HEAD > "$(CSS_PATH)/HEAD" - rm -rf readium-css - rm -rf "$(CSS_PATH)/android-fonts-patch" - .PHONY: test test: # To limit to a particular test suite: -only-testing:ReadiumSharedTests @@ -56,11 +57,17 @@ f: format format: swift run --package-path BuildTools swiftformat . -.PHONY: update-a11y-l10n -update-a11y-l10n: - @which node >/dev/null 2>&1 || (echo "ERROR: node is required, please install it first"; exit 1) - rm -rf publ-a11y-display-guide-localizations - git clone https://github.com/w3c/publ-a11y-display-guide-localizations.git - node BuildTools/Scripts/convert-a11y-display-guide-localizations.js publ-a11y-display-guide-localizations apple Sources/Shared readium.a11y. - rm -rf publ-a11y-display-guide-localizations +BRANCH ?= main +.PHONY: update-locales +update-locales: + @which node >/dev/null 2>&1 || (echo "ERROR: node is required, please install it first"; exit 1) +ifndef DIR + rm -rf thorium-locales + git clone -b $(BRANCH) --single-branch --depth 1 https://github.com/edrlab/thorium-locales.git +endif + node BuildTools/Scripts/convert-thorium-localizations.js thorium-locales +ifndef DIR + rm -rf thorium-locales +endif + make format diff --git a/Package.swift b/Package.swift index c281d2038e..278bde6ab4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,6 @@ // swift-tools-version:5.10 // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,7 +10,7 @@ import PackageDescription let package = Package( name: "Readium", defaultLocalization: "en", - platforms: [.iOS("13.4")], + platforms: [.iOS("15.0")], products: [ .library(name: "ReadiumShared", targets: ["ReadiumShared"]), .library(name: "ReadiumStreamer", targets: ["ReadiumStreamer"]), @@ -28,9 +28,10 @@ let package = Package( .package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"), .package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"), .package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"), - .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.0"), - .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"), + .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.1"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.0"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), ], targets: [ .target( @@ -53,7 +54,10 @@ let package = Package( ), .testTarget( name: "ReadiumSharedTests", - dependencies: ["ReadiumShared"], + dependencies: [ + "ReadiumShared", + "TestPublications", + ], path: "Tests/SharedTests", resources: [ .copy("Fixtures"), @@ -101,7 +105,10 @@ let package = Package( .testTarget( name: "ReadiumNavigatorTests", dependencies: ["ReadiumNavigator"], - path: "Tests/NavigatorTests" + path: "Tests/NavigatorTests", + exclude: [ + "UITests", + ] ), .target( @@ -125,6 +132,7 @@ let package = Package( name: "ReadiumLCP", dependencies: [ "CryptoSwift", + "ReadiumInternal", "ReadiumShared", .product(name: "ReadiumZIPFoundation", package: "ZIPFoundation"), ], @@ -140,7 +148,7 @@ let package = Package( // dependencies: ["ReadiumLCP"], // path: "Tests/LCPTests", // resources: [ - // .copy("Fixtures"), + // .copy("../Fixtures"), // ] // ), @@ -171,5 +179,14 @@ let package = Package( dependencies: ["ReadiumInternal"], path: "Tests/InternalTests" ), + + // Shared test publications used across multiple test targets. + .target( + name: "TestPublications", + path: "Tests/Publications", + resources: [ + .copy("Publications"), + ] + ), ] ) diff --git a/README.md b/README.md index e398b4975b..7698779aff 100644 --- a/README.md +++ b/README.md @@ -7,53 +7,67 @@ ## Features -βœ… Implemented      🚧 Partially implemented      πŸ“† Planned      πŸ‘€ Want to do      ❓ Not planned +βœ… Implemented      🚧 Partially implemented      πŸ“† Planned      πŸ‘€ Want to do      ❌ Not planned ### Formats -| Format | Status | -|---|:---:| -| EPUB 2 | βœ… | -| EPUB 3 | βœ… | -| Readium Web Publication | 🚧 | -| PDF | βœ… | -| Readium Audiobook | βœ… | -| Zipped Audiobook | βœ… | -| Standalone audio files (MP3, AAC, etc.) | βœ… | -| Readium Divina | 🚧 | -| CBZ (Comic Book ZIP) | 🚧 | -| CBR (Comic Book RAR) | ❓ | -| [DAISY](https://daisy.org/activities/standards/daisy/) | πŸ‘€ | +#### Ebook and Document Formats + +| Format | Status | +|--------------------------------------------------------|:------:| +| EPUB (reflowable) | βœ… | +| EPUB (fixed-layout) | βœ… | +| PDF | βœ… | +| Readium Web Publication | 🚧 | +| [DAISY](https://daisy.org/activities/standards/daisy/) | πŸ‘€ | + +#### Audiobook Formats + +| Format | Status | +|--------------------------------------------------------|:------:| +| Readium Audiobook | βœ… | +| Zipped Audiobook | βœ… | +| Standalone audio files (MP3, AAC, etc.) | βœ… | +| [DAISY](https://daisy.org/activities/standards/daisy/) | πŸ‘€ | + +#### Comic Formats + +| Format | Status | +|----------------------|:------:| +| Readium Divina | βœ… | +| CBZ (Comic Book ZIP) | βœ… | +| CBR (Comic Book RAR) | ❌ | ### Features A number of features are implemented only for some publication formats. -| Feature | EPUB (reflow) | EPUB (FXL) | PDF | -|---|:---:|:---:|:---:| -| Pagination | βœ… | βœ… | βœ… | -| Scrolling | βœ… | πŸ‘€ | βœ… | -| Right-to-left (RTL) | βœ… | βœ… | βœ… | -| Search in textual content | βœ… | βœ… | πŸ‘€ | -| Highlighting (Decoration API) | βœ… | βœ… | πŸ‘€ | -| Text-to-speech (TTS) | βœ… | βœ… | πŸ‘€ | -| Media overlays | πŸ“† | πŸ“† | | +| Feature | EPUB (reflow) | EPUB (FXL) | PDF | +|-------------------------------|:-------------:|:----------:|:---:| +| Pagination | βœ… | βœ… | βœ… | +| Scrolling | βœ… | πŸ‘€ | βœ… | +| Right-to-left (RTL) | βœ… | βœ… | βœ… | +| Search in textual content | βœ… | βœ… | πŸ‘€ | +| Highlighting (Decoration API) | βœ… | βœ… | πŸ‘€ | +| Text-to-speech (TTS) | βœ… | βœ… | πŸ‘€ | +| Media overlays | πŸ“† | πŸ“† | | ### OPDS Support -| Feature | Status | -|---|:---:| -| [OPDS Catalog 1.2](https://specs.opds.io/opds-1.2) | βœ… | -| [OPDS Catalog 2.0](https://drafts.opds.io/opds-2.0) | βœ… | -| [Authentication for OPDS](https://drafts.opds.io/authentication-for-opds-1.0.html) | πŸ“† | -| [Readium LCP Automatic Key Retrieval](https://readium.org/lcp-specs/notes/lcp-key-retrieval.html) | πŸ“† | +| Feature | Status | +|---------------------------------------------------------------------------------------------------|:------:| +| [OPDS Catalog 1.2](https://specs.opds.io/opds-1.2) | βœ… | +| [OPDS Catalog 2.0](https://drafts.opds.io/opds-2.0) | βœ… | +| [Authentication for OPDS](https://drafts.opds.io/authentication-for-opds-1.0.html) | πŸ“† | +| [OPDS Progression](https://github.com/opds-community/drafts/pull/91) | πŸ“† | +| [Readium LCP Automatic Key Retrieval](https://readium.org/lcp-specs/notes/lcp-key-retrieval.html) | πŸ“† | ### DRM Support -| Feature | Status | -|---|:---:| -| [Readium LCP](https://www.edrlab.org/projects/readium-lcp/) | βœ… | -| [Adobe ACS](https://www.adobe.com/fr/solutions/ebook/content-server.html) | ❓ | +| Feature | Status | +|---------------------------------------------------------------------------|:------:| +| [Readium LCP](https://www.edrlab.org/projects/readium-lcp/) | βœ… | +| [Adobe ACS](https://www.adobe.com/fr/solutions/ebook/content-server.html) | ❌ | ## User Guides @@ -72,8 +86,9 @@ Guides are available to help you make the most of the toolkit. * [Navigator](docs/Guides/Navigator/Navigator.md) - an overview of the Navigator to render a `Publication`'s content to the user * [Configuring the Navigator](docs/Guides/Navigator/Preferences.md) – setup and render Navigator user preferences (font size, colors, etc.) -* [Font families in the EPUB navigator](docs/Guides/Navigator/EPUB%20Fonts.md) – support custom font families with reflowable EPUB publications * [Integrating the Navigator with SwiftUI](docs/Guides/Navigator/SwiftUI.md) – glue to setup the Navigator in a SwiftUI application +* [Implementing Highlights](docs/Guides/Navigator/Highlights.md) – add and manage highlights in a publication +* [Font families in the EPUB navigator](docs/Guides/Navigator/EPUB%20Fonts.md) – support custom font families with reflowable EPUB publications ### DRM @@ -87,7 +102,8 @@ Guides are available to help you make the most of the toolkit. | Readium | iOS | Swift compiler | Xcode | |-----------|------|----------------|-------| -| `develop` | 13.4 | 6.0 | 16.2 | +| `develop` | 15.0 | 6.0 | 16.4 | +| 3.8.0 | 15.0 | 6.0 | 16.4 | | 3.0.0 | 13.4 | 5.10 | 15.4 | | 2.5.1 | 11.0 | 5.6.1 | 13.4 | | 2.5.0 | 10.0 | 5.6.1 | 13.4 | @@ -113,7 +129,7 @@ If you're stuck, find more information at [developer.apple.com](https://develope Add the following to your `Cartfile`: ``` -github "readium/swift-toolkit" ~> 3.4.0 +github "readium/swift-toolkit" ~> 3.8.0 ``` Then, [follow the usual Carthage steps](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) to add the Readium libraries to your project. @@ -143,11 +159,11 @@ Add the following `pod` statements to your `Podfile` for the Readium libraries y source 'https://github.com/readium/podspecs' source 'https://cdn.cocoapods.org/' -pod 'ReadiumShared', '~> 3.4.0' -pod 'ReadiumStreamer', '~> 3.4.0' -pod 'ReadiumNavigator', '~> 3.4.0' -pod 'ReadiumOPDS', '~> 3.4.0' -pod 'ReadiumLCP', '~> 3.4.0' +pod 'ReadiumShared', '~> 3.8.0' +pod 'ReadiumStreamer', '~> 3.8.0' +pod 'ReadiumNavigator', '~> 3.8.0' +pod 'ReadiumOPDS', '~> 3.8.0' +pod 'ReadiumLCP', '~> 3.8.0' ``` Take a look at [CocoaPods's documentation](https://guides.cocoapods.org/using/using-cocoapods.html) for more information. diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index 3df551fd16..6624771b58 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -46,7 +46,9 @@ public class GCDHTTPServer: HTTPServer, Loggable { /// Creates a new instance of the HTTP server. /// - /// - Parameter logLevel: See `ReadiumGCDWebServer.setLogLevel`. + /// - Parameters: + /// - assetRetriever: The retriever used to fetch assets for the server. + /// - logLevel: See `ReadiumGCDWebServer.setLogLevel`. public init( assetRetriever: AssetRetriever, logLevel: Int = 3 diff --git a/Sources/Adapters/GCDWebServer/ResourceResponse.swift b/Sources/Adapters/GCDWebServer/ResourceResponse.swift index e5a939291e..1624c38efd 100644 --- a/Sources/Adapters/GCDWebServer/ResourceResponse.swift +++ b/Sources/Adapters/GCDWebServer/ResourceResponse.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Adapters/LCPSQLite/Database.swift b/Sources/Adapters/LCPSQLite/Database.swift index 7d33f1417b..4afd1f3406 100644 --- a/Sources/Adapters/LCPSQLite/Database.swift +++ b/Sources/Adapters/LCPSQLite/Database.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift index a085367688..9dba0b82cf 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift @@ -1,14 +1,16 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // import Foundation import ReadiumLCP +import ReadiumShared import SQLite -public class LCPSQLiteLicenseRepository: LCPLicenseRepository { +@available(*, deprecated, message: "Use LCPKeychainLicenseRepository from ReadiumLCP instead") +public class LCPSQLiteLicenseRepository: LCPLicenseRepository, Loggable { let licenses = Table("Licenses") let id = SQLite.Expression("id") let printsLeft = SQLite.Expression("printsLeft") @@ -117,4 +119,57 @@ public class LCPSQLiteLicenseRepository: LCPLicenseRepository { copy: get(copiesLeft, for: id) ) } + + /// Migrates all licenses from this SQLite repository to the target + /// keychain repository. + /// + /// This migration transfers consumable rights (print/copy counts) and + /// device registration status to the target repository. The full + /// `LicenseDocument` is not stored in SQLite and will be automatically + /// added to the target repository when each publication is opened + /// for the first time after migration. + /// + /// - Returns: `true` if all the licenses were migrated successfully. + @discardableResult + public func migrate(to target: LCPKeychainLicenseRepository) async throws -> Bool { + let allLicenseData = try db.prepare(licenses).map { row in + try ( + id: row.get(id), + printsLeft: row.get(printsLeft), + copiesLeft: row.get(copiesLeft), + registered: row.get(registered) + ) + } + + var successCount = 0 + var failureCount = 0 + + for licenseData in allLicenseData { + do { + let rights = LCPConsumableUserRights( + print: licenseData.printsLeft, + copy: licenseData.copiesLeft + ) + + try await target.importLicenseRights( + for: licenseData.id, + rights: rights, + registered: licenseData.registered + ) + + successCount += 1 + } catch { + failureCount += 1 + log(.error, "Failed to migrate license \(licenseData.id): \(error)") + } + } + + if failureCount > 0 { + log(.info, "License migration completed with \(successCount) succeeded, \(failureCount) failed") + } else { + log(.info, "License migration completed successfully: \(successCount) licenses migrated") + } + + return failureCount == 0 + } } diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift index 43292afe0a..7255273d4f 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,6 +9,7 @@ import ReadiumLCP import ReadiumShared import SQLite +@available(*, deprecated, message: "Use LCPKeychainPassphraseRepository from ReadiumLCP instead") public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { let transactions = Table("Transactions") let licenseId = SQLite.Expression("licenseId") @@ -32,28 +33,24 @@ public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { public func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? { try logAndRethrow { try db.prepare(transactions.select(passphrase) - .filter(self.licenseId == licenseID) - ) - .compactMap { try $0.get(passphrase) } - .first + .filter(self.licenseId == licenseID)) + .compactMap { try $0.get(passphrase) } + .first } } public func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] { try logAndRethrow { - var passphrases = - try db.prepare(transactions.select(passphrase) - .filter(self.userId == userID && self.provider == provider) - ) - .compactMap { try $0.get(passphrase) } - - // The legacy SQLite database did not save all the new - // (passphrase, userID, provider) tuples. So we need to fall back - // on checking all the saved passphrases for a match. - passphrases += try db.prepare(transactions.select(passphrase)) + try db.prepare(transactions.select(passphrase) + .filter(self.userId == userID && self.provider == provider)) .compactMap { try $0.get(passphrase) } + } + } - return passphrases + public func passphrases() async throws -> [LCPPassphraseHash] { + try logAndRethrow { + try db.prepare(transactions.select(passphrase)) + .compactMap { try $0.get(passphrase) } } } @@ -71,13 +68,46 @@ public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { } } - private func all() -> [String] { - let query = transactions.select(passphrase) - do { - return try db.prepare(query).compactMap { try $0.get(passphrase) } - } catch { - log(.error, error) - return [] + /// Migrates all passphrases from this SQLite repository to the target + /// repository. + /// + /// - Returns: `true` if all the passphrases were migrated successfully. + @discardableResult + public func migrate(to target: LCPPassphraseRepository) async throws -> Bool { + let allPassphraseData = try db.prepare(transactions).map { row in + try ( + licenseId: row.get(licenseId), + passphrase: row.get(passphrase), + provider: row.get(provider), + userId: row.get(userId) + ) + } + + var successCount = 0 + var failureCount = 0 + + for passphraseData in allPassphraseData { + do { + try await target.addPassphrase( + passphraseData.passphrase, + for: passphraseData.licenseId, + userID: passphraseData.userId, + provider: passphraseData.provider + ) + successCount += 1 + } catch { + failureCount += 1 + // Log error but continue with other passphrases + log(.error, "Failed to migrate passphrase for license \(passphraseData.licenseId): \(error)") + } + } + + if failureCount > 0 { + log(.info, "Passphrase migration completed with \(successCount) succeeded, \(failureCount) failed") + } else { + log(.info, "Passphrase migration completed successfully: \(successCount) passphrases migrated") } + + return failureCount == 0 } } diff --git a/Sources/Internal/Extensions/Array.swift b/Sources/Internal/Extensions/Array.swift index 9dad9b4ac8..6c1bf08da8 100644 --- a/Sources/Internal/Extensions/Array.swift +++ b/Sources/Internal/Extensions/Array.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Collection.swift b/Sources/Internal/Extensions/Collection.swift index 224ca63c7d..f8419f53d7 100644 --- a/Sources/Internal/Extensions/Collection.swift +++ b/Sources/Internal/Extensions/Collection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Comparable.swift b/Sources/Internal/Extensions/Comparable.swift index 39bb206a17..3b7480cac6 100644 --- a/Sources/Internal/Extensions/Comparable.swift +++ b/Sources/Internal/Extensions/Comparable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Data.swift b/Sources/Internal/Extensions/Data.swift index 45a5d16963..257b17a8fa 100644 --- a/Sources/Internal/Extensions/Data.swift +++ b/Sources/Internal/Extensions/Data.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Date+ISO8601.swift b/Sources/Internal/Extensions/Date+ISO8601.swift index c5f6a0923c..6f3cc79ec9 100644 --- a/Sources/Internal/Extensions/Date+ISO8601.swift +++ b/Sources/Internal/Extensions/Date+ISO8601.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Double.swift b/Sources/Internal/Extensions/Double.swift index f707486e72..efe9dde61e 100644 --- a/Sources/Internal/Extensions/Double.swift +++ b/Sources/Internal/Extensions/Double.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/NSRegularExpression.swift b/Sources/Internal/Extensions/NSRegularExpression.swift index 4ca10654ef..614d135e6c 100644 --- a/Sources/Internal/Extensions/NSRegularExpression.swift +++ b/Sources/Internal/Extensions/NSRegularExpression.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Number.swift b/Sources/Internal/Extensions/Number.swift index 299774baf1..bd6a9da34b 100644 --- a/Sources/Internal/Extensions/Number.swift +++ b/Sources/Internal/Extensions/Number.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Optional.swift b/Sources/Internal/Extensions/Optional.swift index a73e1ac200..e9fe54171f 100644 --- a/Sources/Internal/Extensions/Optional.swift +++ b/Sources/Internal/Extensions/Optional.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Range.swift b/Sources/Internal/Extensions/Range.swift index a5ae36ee13..546ce91bc6 100644 --- a/Sources/Internal/Extensions/Range.swift +++ b/Sources/Internal/Extensions/Range.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,4 +10,44 @@ public extension Range where Bound == UInt64 { func clampedToInt() -> Range { clamped(to: 0 ..< UInt64(Int.max)) } + + /// Parses an HTTP `Range` header value (RFC 7233) into a byte range. + /// + /// Supports: + /// - `bytes=0-1023` β†’ `0..<1024` + /// - `bytes=1024-` β†’ `1024.. 0 else { return nil } + let start = totalLength > suffix ? totalLength - suffix : 0 + self = start ..< totalLength + return + } + + let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, let start = UInt64(parts[0]) else { return nil } + + if parts[1].isEmpty { + // Open-ended range: bytes=N- + guard start < totalLength else { return nil } + self = start ..< totalLength + return + } + + // Closed range: bytes=N-M + guard let end = UInt64(parts[1]), end >= start else { return nil } + let clampedEnd = Swift.min(end + 1, totalLength) + guard start < clampedEnd else { return nil } + self = start ..< clampedEnd + } } diff --git a/Sources/Internal/Extensions/Result.swift b/Sources/Internal/Extensions/Result.swift index 669f352d0b..9177e23dac 100644 --- a/Sources/Internal/Extensions/Result.swift +++ b/Sources/Internal/Extensions/Result.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Sequence.swift b/Sources/Internal/Extensions/Sequence.swift index 10912954d5..cc9ed5727f 100644 --- a/Sources/Internal/Extensions/Sequence.swift +++ b/Sources/Internal/Extensions/Sequence.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/String.swift b/Sources/Internal/Extensions/String.swift index 900c1f0567..0d74535309 100644 --- a/Sources/Internal/Extensions/String.swift +++ b/Sources/Internal/Extensions/String.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/Task.swift b/Sources/Internal/Extensions/Task.swift index aba07ff6b0..f6358ec44a 100644 --- a/Sources/Internal/Extensions/Task.swift +++ b/Sources/Internal/Extensions/Task.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/UInt64.swift b/Sources/Internal/Extensions/UInt64.swift index aa8b17082a..cc8704c9d6 100644 --- a/Sources/Internal/Extensions/UInt64.swift +++ b/Sources/Internal/Extensions/UInt64.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/Extensions/URL.swift b/Sources/Internal/Extensions/URL.swift index cf6f3f298f..a09e089b98 100644 --- a/Sources/Internal/Extensions/URL.swift +++ b/Sources/Internal/Extensions/URL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/JSON.swift b/Sources/Internal/JSON.swift index 4189979709..95063a091f 100644 --- a/Sources/Internal/JSON.swift +++ b/Sources/Internal/JSON.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -89,7 +89,9 @@ public func parseRaw(_ json: Any?) -> T? { /// let values1: [String] = parseArray(json["multiple"]) /// let values2: [String] = parseArray(json["single"], allowingSingle: true) /// -/// - Parameter allowingSingle: If true, then allows the parsing of both a single value and an array. +/// - Parameters: +/// - json: The JSON object to parse, typically an `Array` or a single value. +/// - allowingSingle: If true, then allows the parsing of both a single value and an array. public func parseArray(_ json: Any?, allowingSingle: Bool = false) -> [T] { if let values = json as? [T] { return values diff --git a/Sources/Internal/Keychain.swift b/Sources/Internal/Keychain.swift new file mode 100644 index 0000000000..cd6e33b34c --- /dev/null +++ b/Sources/Internal/Keychain.swift @@ -0,0 +1,241 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import Security + +/// Errors occurring in ``Keychain``. +public enum KeychainError: Error { + /// The item was not found in the Keychain. + case itemNotFound + + /// An item with this key already exists. + case duplicateItem + + /// The data retrieved from the Keychain is invalid. + case invalidData + + /// An unhandled Keychain error occurred. + case unhandledError(OSStatus) +} + +/// Utility for managing Keychain operations. +/// +/// This class handles low-level Security framework calls for storing, retrieving, +/// updating, and deleting data from the iOS/macOS Keychain. +public final class Keychain: Sendable { + private let serviceName: String + private let synchronizable: Bool + + /// Initializes a ``Keychain`` with the specified configuration. + /// + /// - Parameters: + /// - serviceName: The service identifier for Keychain items. + /// - synchronizable: Whether items should sync via iCloud Keychain. + public init( + serviceName: String, + synchronizable: Bool = true + ) { + self.serviceName = serviceName + self.synchronizable = synchronizable + } + + /// Saves data to the Keychain with the specified key. + /// + /// - Parameters: + /// - data: The data to save. + /// - key: The account identifier. + public func save(data: Data, forKey key: String) throws(KeychainError) { + var query = baseQuery(forKey: key, forAdding: true) + query[kSecValueData as String] = data + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw mapError(status) + } + } + + /// Loads data from the Keychain for the specified key. + /// + /// - Parameter key: The account identifier. + /// - Returns: The data if found, or `nil` if no item exists with this key. + public func load(forKey key: String) throws(KeychainError) -> Data? { + var query = baseQuery(forKey: key, forAdding: false) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess else { + throw mapError(status) + } + + guard let data = result as? Data else { + throw KeychainError.invalidData + } + + return data + } + + /// Updates existing data in the Keychain for the specified key. + /// + /// - Parameters: + /// - data: The new data to save. + /// - key: The account identifier. + public func update(data: Data, forKey key: String) throws(KeychainError) { + let query = baseQuery(forKey: key, forAdding: false) + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: data, + ] + + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + guard status == errSecSuccess else { + throw mapError(status) + } + } + + /// Deletes an item from the Keychain for the specified key. + /// + /// - Parameter key: The account identifier. + public func delete(forKey key: String) throws(KeychainError) { + let query = baseQuery(forKey: key, forAdding: false) + let status = SecItemDelete(query as CFDictionary) + + // Success or item not found are both acceptable + guard status == errSecSuccess || status == errSecItemNotFound else { + throw mapError(status) + } + } + + /// Deletes all items for this service from the Keychain. + public func deleteAll() throws(KeychainError) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + ] + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw mapError(status) + } + } + + /// Returns all account identifiers (keys) stored for this service. + /// + /// - Returns: An array of account identifiers. + public func allKeys() throws(KeychainError) -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return [] + } + + guard status == errSecSuccess else { + throw mapError(status) + } + + guard let items = result as? [[String: Any]] else { + return [] + } + + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + /// Returns all items stored for this service. + /// + /// - Returns: A dictionary where keys are account identifiers and values are + /// the stored data. + public func allItems() throws(KeychainError) -> [String: Data] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return [:] + } + + guard status == errSecSuccess else { + throw mapError(status) + } + + guard let items = result as? [[String: Any]] else { + return [:] + } + + var itemsDictionary: [String: Data] = [:] + for item in items { + if let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data + { + itemsDictionary[account] = data + } + } + + return itemsDictionary + } + + // MARK: - Private Helpers + + /// Creates the base query dictionary for Keychain operations. + /// + /// - Parameters: + /// - key: The account identifier. + /// - forAdding: If `true`, uses the boolean `synchronizable` value for adding items. + /// If `false`, uses `kSecAttrSynchronizableAny` for queries/updates/deletes. + private func baseQuery(forKey key: String, forAdding: Bool) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + if forAdding { + query[kSecAttrSynchronizable as String] = synchronizable + } else { + query[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny + } + + return query + } + + /// Maps OSStatus error codes to KeychainError cases. + private func mapError(_ status: OSStatus) -> KeychainError { + switch status { + case errSecItemNotFound: + return .itemNotFound + case errSecDuplicateItem: + return .duplicateItem + default: + return .unhandledError(status) + } + } +} diff --git a/Sources/Internal/Measure.swift b/Sources/Internal/Measure.swift index 04fdbf789c..686df29bd0 100644 --- a/Sources/Internal/Measure.swift +++ b/Sources/Internal/Measure.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Internal/UTI.swift b/Sources/Internal/UTI.swift index 27ea7b73fd..63d1e89262 100644 --- a/Sources/Internal/UTI.swift +++ b/Sources/Internal/UTI.swift @@ -1,59 +1,84 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // -import CoreServices import Foundation +import UniformTypeIdentifiers /// Uniform Type Identifier. -public struct UTI: ExpressibleByStringLiteral { - /// Type tag class, eg. kUTTagClassMIMEType. +public struct UTI { + /// Type tag class, eg. UTTagClass.mimeType. public enum TagClass { case mediaType, fileExtension + } - var rawString: CFString { - switch self { - case .mediaType: - return kUTTagClassMIMEType - case .fileExtension: - return kUTTagClassFilenameExtension - } + public let type: UTType + + public init(type: UTType) { + self.type = type + } + + public init?(_ identifier: String) { + guard let type = UTType(identifier) else { + return nil } + self.init(type: type) } - public let string: String + public init?(mediaType: String) { + guard let type = UTType(mimeType: mediaType) else { + return nil + } + self.init(type: type) + } - public init(stringLiteral value: StringLiteralType) { - string = value + public init?(fileExtension: String) { + guard let type = UTType(filenameExtension: fileExtension) else { + return nil + } + self.init(type: type) } public var name: String? { - UTTypeCopyDescription(string as CFString)?.takeRetainedValue() as String? + type.localizedDescription + } + + public var string: String { + type.identifier } /// Returns the preferred tag for this `UTI`, with the given type `tagClass`. public func preferredTag(withClass tagClass: TagClass) -> String? { - UTTypeCopyPreferredTagWithClass(string as CFString, tagClass.rawString)?.takeRetainedValue() as String? + switch tagClass { + case .mediaType: + return type.preferredMIMEType + case .fileExtension: + return type.preferredFilenameExtension + } } /// Returns all tags for this `UTI`, with the given type `tagClass`. public func tags(withClass tagClass: TagClass) -> [String] { - UTTypeCopyAllTagsWithClass(string as CFString, tagClass.rawString)?.takeRetainedValue() as? [String] - ?? [] + switch tagClass { + case .mediaType: + return type.tags[.mimeType] ?? [] + case .fileExtension: + return type.tags[.filenameExtension] ?? [] + } } /// Finds the first `UTI` recognizing any of the given `mediaTypes` or `fileExtensions`. public static func findFrom(mediaTypes: [String], fileExtensions: [String]) -> UTI? { for mediaType in mediaTypes { - if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mediaType as CFString, nil)?.takeUnretainedValue() { - return UTI(stringLiteral: uti as String) + if let uti = UTI(mediaType: mediaType) { + return uti } } for fileExtension in fileExtensions { - if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)?.takeUnretainedValue() { - return UTI(stringLiteral: uti as String) + if let uti = UTI(fileExtension: fileExtension) { + return uti } } return nil diff --git a/Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib b/Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib deleted file mode 100644 index 886be11452..0000000000 --- a/Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index da0295db0b..6fdd1c8da6 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -19,11 +19,9 @@ public protocol LCPAuthenticating { /// - reason: Reason why the passphrase is requested. It should be used to prompt the user. /// - allowUserInteraction: Indicates whether the user can be prompted for their passphrase. /// If your implementation requires it and `allowUserInteraction` is false, terminate - /// quickly by sending `nil` to the completion block. + /// quickly by returning `nil`. /// - sender: Free object that can be used by reading apps to give some UX context when /// presenting dialogs. For example, the host `UIViewController`. - /// - completion: Used to return the retrieved passphrase. If the user cancelled, send nil. - /// The passphrase may be already hashed. @MainActor func retrievePassphrase( for license: LCPAuthenticatedLicense, @@ -68,8 +66,4 @@ public struct LCPAuthenticatedLicense { /// License Document being opened. public let document: LicenseDocument - - init(document: LicenseDocument) { - self.document = document - } } diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index 5f87c04e14..8cf54c9755 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -46,7 +46,6 @@ import SwiftUI /// } /// } /// ``` -@available(iOS 16.0, *) public struct LCPDialog: View { public enum ErrorMessage { case incorrectPassphrase @@ -54,17 +53,20 @@ public struct LCPDialog: View { var string: String { switch self { case .incorrectPassphrase: - ReadiumLCPLocalizedString("dialog.error.incorrectPassphrase") + ReadiumLCPLocalizedString("dialog.errors.incorrectPassphrase") } } } - public var id: LCPDialog { self } + public var id: LCPDialog { + self + } private let hint: String? private let errorMessage: ErrorMessage? private let onSubmit: (String) -> Void private let onForgotPassphrase: (() -> Void)? + private let onCancel: (() -> Void)? private let openButtonId = "open" @@ -72,12 +74,14 @@ public struct LCPDialog: View { hint: String?, errorMessage: ErrorMessage?, onSubmit: @escaping (String) -> Void, - onForgotPassphrase: (() -> Void)? + onForgotPassphrase: (() -> Void)?, + onCancel: (() -> Void)? = nil ) { self.hint = hint self.errorMessage = errorMessage self.onSubmit = onSubmit self.onForgotPassphrase = onForgotPassphrase + self.onCancel = onCancel } public init( @@ -100,7 +104,7 @@ public struct LCPDialog: View { @State private var passphrase: String = "" public var body: some View { - NavigationStack { + NavigationView { ScrollViewReader { scrollProxy in Form { header @@ -120,20 +124,22 @@ public struct LCPDialog: View { } } } - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboardIfAvailable() .navigationTitle(ReadiumLCPLocalizedStringKey("dialog.title")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(ReadiumLCPLocalizedStringKey("dialog.cancel"), role: .cancel) { + Button(ReadiumLCPLocalizedStringKey("dialog.actions.cancel"), role: .cancel) { + onCancel?() dismiss() } } } } + .navigationViewStyle(.stack) } - @ViewBuilder private var header: some View { + private var header: some View { Section { HStack { Spacer() @@ -142,31 +148,29 @@ public struct LCPDialog: View { .foregroundStyle(.blue) .font(.system(size: 70)) - Text(ReadiumLCPLocalizedStringKey("dialog.header")) + Text(ReadiumLCPLocalizedStringKey("dialog.message")) .multilineTextAlignment(.center) .padding(.bottom, 16) } Spacer() } - DisclosureGroup(ReadiumLCPLocalizedStringKey("dialog.details.title")) { + DisclosureGroup(ReadiumLCPLocalizedStringKey("dialog.info.title")) { VStack { - Text(ReadiumLCPLocalizedStringKey("dialog.details.body")) + Text(ReadiumLCPLocalizedStringKey("dialog.info.body")) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) - Text("[\(ReadiumLCPLocalizedString("dialog.details.more"))](https://www.edrlab.org/readium-lcp/)") + Text("[\(ReadiumLCPLocalizedString("dialog.info.more"))](https://www.edrlab.org/readium-lcp/)") .frame(maxWidth: .infinity, alignment: .leading) } } } - .alignmentGuide(.listRowSeparatorLeading) { _ in - 0 - } + .alignListRowSeparatorLeading() .font(.callout) } - @ViewBuilder private var input: some View { + private var input: some View { Section { VStack(alignment: .leading, spacing: 8) { TextField(text: $passphrase) { @@ -188,16 +192,20 @@ public struct LCPDialog: View { .font(.callout) } } + } footer: { + if let hint = hint { + Text(ReadiumLCPLocalizedStringKey("dialog.passphrase.hint", hint)) + } } .listRowSeparator(.hidden) } @ViewBuilder private var buttons: some View { Section { - Button(ReadiumLCPLocalizedStringKey("dialog.continue")) { + Button(ReadiumLCPLocalizedStringKey("dialog.actions.continue")) { submit() } - .bold() + .boldIfAvailable() .id(openButtonId) .disabled(passphrase.isEmpty) .frame(maxWidth: .infinity, alignment: .center) @@ -205,14 +213,10 @@ public struct LCPDialog: View { if let onForgotPassphrase = onForgotPassphrase { Section { - Button(ReadiumLCPLocalizedStringKey("dialog.forgotYourPassphrase"), role: .destructive) { + Button(ReadiumLCPLocalizedStringKey("dialog.actions.recoverPassphrase"), role: .destructive) { onForgotPassphrase() } .frame(maxWidth: .infinity, alignment: .center) - } footer: { - if let hint = hint { - Text(ReadiumLCPLocalizedStringKey("dialog.hint", hint)) - } } } } @@ -227,6 +231,35 @@ public struct LCPDialog: View { } } +private extension View { + @ViewBuilder + func scrollDismissesKeyboardIfAvailable() -> some View { + if #available(iOS 16.0, *) { + scrollDismissesKeyboard(.interactively) + } else { + self + } + } + + @ViewBuilder + func alignListRowSeparatorLeading() -> some View { + if #available(iOS 16.0, *) { + alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } else { + self + } + } + + @ViewBuilder + func boldIfAvailable() -> some View { + if #available(iOS 16.0, *) { + bold() + } else { + self + } + } +} + #Preview { if #available(iOS 18.0, *) { Spacer().sheet(isPresented: .constant(true)) { diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index 748ff4fe04..43a1c2e7ac 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -42,11 +42,10 @@ public class LCPDialogAuthentication: LCPAuthenticating, Loggable { continuation.resume(returning: passphrase) } - let navController = UINavigationController(rootViewController: dialogViewController) - navController.modalPresentationStyle = modalPresentationStyle - navController.modalTransitionStyle = modalTransitionStyle + dialogViewController.modalPresentationStyle = modalPresentationStyle + dialogViewController.modalTransitionStyle = modalTransitionStyle - viewController.present(navController, animated: animated) + viewController.present(dialogViewController, animated: animated) } } } diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index f439b9874f..ee32b59ef3 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -1,111 +1,59 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // -import SafariServices +import SwiftUI import UIKit final class LCPDialogViewController: UIViewController { - @IBOutlet var scrollView: UIScrollView! - @IBOutlet var hintLabel: UILabel! - @IBOutlet var promptLabel: UILabel! - @IBOutlet var messageLabel: UILabel! - @IBOutlet var passphraseField: UITextField! - @IBOutlet var supportButton: UIButton! - @IBOutlet var forgotPassphraseButton: UIButton! - @IBOutlet var continueButton: UIButton! - private let license: LCPAuthenticatedLicense private let reason: LCPAuthenticationReason private let completion: (String?) -> Void - private let supportLinks: [(Link, URL)] init(license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, completion: @escaping (String?) -> Void) { self.license = license self.reason = reason self.completion = completion - supportLinks = license.supportLinks - .compactMap { link -> (Link, URL)? in - guard let url = URL(string: link.href), UIApplication.shared.canOpenURL(url) else { - return nil - } - return (link, url) - } + super.init(nibName: nil, bundle: nil) - super.init(nibName: nil, bundle: Bundle.module) - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + isModalInPresentation = true } @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - NotificationCenter.default.removeObserver(self) - } - override func viewDidLoad() { super.viewDidLoad() - if #available(iOS 13.0, *) { - // Prevents swipe down to dismiss the dialog on iOS 13+ - isModalInPresentation = true - } - - var provider = license.document.provider - if let providerHost = URL(string: provider)?.host { - provider = providerHost - } - - supportButton.isHidden = supportLinks.isEmpty - - let label = UILabel() - - switch reason { - case .passphraseNotFound: - label.text = ReadiumLCPLocalizedString("dialog.reason.passphraseNotFound") - case .invalidPassphrase: - label.text = ReadiumLCPLocalizedString("dialog.reason.invalidPassphrase") - passphraseField.layer.borderWidth = 1 - passphraseField.layer.borderColor = UIColor.red.cgColor - } - - label.sizeToFit() - if #available(iOS 13.0, *) { - label.textColor = .label - navigationController?.navigationBar.backgroundColor = .systemBackground - } - - let leftItem = UIBarButtonItem(customView: label) - navigationItem.leftBarButtonItem = leftItem - - promptLabel.text = ReadiumLCPLocalizedString("dialog.prompt.message1") - messageLabel.text = String(format: ReadiumLCPLocalizedString("dialog.prompt.message2"), provider) - forgotPassphraseButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.forgotPassphrase"), for: .normal) - supportButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.support"), for: .normal) - continueButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.continue"), for: .normal) - passphraseField.placeholder = ReadiumLCPLocalizedString("dialog.prompt.passphrase") - hintLabel.text = license.hint - - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .cancel, - target: self, - action: #selector(LCPDialogViewController.cancel(_:)) + let dialog = LCPDialog( + hint: license.hint.orNilIfBlank(), + errorMessage: reason == .invalidPassphrase ? .incorrectPassphrase : nil, + onSubmit: { [weak self] passphrase in + self?.complete(with: passphrase) + }, + onForgotPassphrase: license.hintLink?.url().map { url in + { UIApplication.shared.open(url.url) } + }, + onCancel: { [weak self] in + self?.complete(with: nil) + } ) - } - - @IBAction func authenticate(_ sender: Any) { - let passphrase = passphraseField.text ?? "" - complete(with: passphrase) - } - @IBAction func cancel(_ sender: Any) { - complete(with: nil) + let hostingController = UIHostingController(rootView: dialog) + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hostingController.didMove(toParent: self) } private var isCompleted = false @@ -118,103 +66,4 @@ final class LCPDialogViewController: UIViewController { completion(passphrase) dismiss(animated: true) } - - @IBAction func showSupportLink(_ sender: Any) { - guard !supportLinks.isEmpty else { - return - } - - func open(_ url: URL) { - UIApplication.shared.open(url) - } - - if let (_, url) = supportLinks.first, supportLinks.count == 1 { - open(url) - return - } - - let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - for (link, url) in supportLinks { - let title: String = { - if let title = link.title { - return title - } - if let scheme = url.scheme { - switch scheme { - case "http", "https": - return ReadiumLCPLocalizedString("dialog.support.website") - case "tel": - return ReadiumLCPLocalizedString("dialog.support.phone") - case "mailto": - return ReadiumLCPLocalizedString("dialog.support.mail") - default: - break - } - } - return ReadiumLCPLocalizedString("dialog.support") - }() - - let action = UIAlertAction(title: title, style: .default) { _ in - open(url) - } - alert.addAction(action) - } - alert.addAction(UIAlertAction(title: ReadiumLCPLocalizedString("dialog.cancel"), style: .cancel)) - - if let popover = alert.popoverPresentationController, let sender = sender as? UIView { - popover.sourceView = sender - var rect = sender.bounds - rect.origin.x = sender.center.x - 1 - rect.size.width = 2 - popover.sourceRect = rect - } - present(alert, animated: true) - } - - @IBAction func showHintLink(_ sender: Any) { - guard let href = license.hintLink?.href, let url = URL(string: href) else { - return - } - - let browser = SFSafariViewController(url: url) - browser.modalPresentationStyle = .currentContext - present(browser, animated: true) - } - - /// Makes sure the form contents is scrollable when the keyboard is visible. - @objc func keyboardWillChangeFrame(_ note: Notification) { - guard - let window = view.window, - let scrollView = scrollView, - let scrollViewSuperview = scrollView.superview, - let info = note.userInfo - else { - return - } - - var keyboardHeight: CGFloat = 0 - if note.name == UIResponder.keyboardWillChangeFrameNotification { - guard let keyboardFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { - return - } - keyboardHeight = keyboardFrame.height - } - - // Calculates the scroll view offsets in the coordinate space of of our window - let scrollViewFrame = scrollViewSuperview.convert(scrollView.frame, to: window) - - var contentInset = scrollView.contentInset - // Bottom inset is the part of keyboard that is covering the tableView - contentInset.bottom = keyboardHeight - (window.frame.height - scrollViewFrame.height - scrollViewFrame.origin.y) + 16 - - self.scrollView.contentInset = contentInset - self.scrollView.scrollIndicatorInsets = contentInset - } -} - -extension LCPDialogViewController: UITextFieldDelegate { - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - authenticate(textField) - return false - } } diff --git a/Sources/LCP/Authentications/LCPObservableAuthentication.swift b/Sources/LCP/Authentications/LCPObservableAuthentication.swift index 354d33c61b..e088eccf98 100644 --- a/Sources/LCP/Authentications/LCPObservableAuthentication.swift +++ b/Sources/LCP/Authentications/LCPObservableAuthentication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift index e80db33013..4940739f34 100644 --- a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift +++ b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/Content Protection/EncryptionParser.swift b/Sources/LCP/Content Protection/EncryptionParser.swift index f7140803d0..5b0c81b110 100644 --- a/Sources/LCP/Content Protection/EncryptionParser.swift +++ b/Sources/LCP/Content Protection/EncryptionParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,7 +21,8 @@ private func parseRPFEncryptionData(in container: Container) async -> ReadResult } return await manifestResource - .readAsJSONObject() + .read() + .asJSONObject() .flatMap { json in do { return try .success(Manifest(json: json)) @@ -49,7 +50,7 @@ private func parseEPUBEncryptionData(in container: Container) async -> ReadResul return await encryptionResource.read() .asyncFlatMap { data -> ReadResult in do { - let doc = try await DefaultXMLDocumentFactory().open( + let doc = try DefaultXMLDocumentFactory().open( data: data, namespaces: [.enc, .ds, .comp] ) diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 2254000c76..1f57f4a4f3 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -53,10 +53,10 @@ final class LCPContentProtection: ContentProtection, Loggable { return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License"))) } - return await asset.resource.readAsLCPL() + return await asset.resource.read() + .asLCPL() .mapError { .reading($0) } .asyncFlatMap { licenseDocument in - await assetRetriever.retrieve(link: licenseDocument.publicationLink) .flatMap { publicationAsset in switch publicationAsset { diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 7025ba84a6..04c2823c3e 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,7 +9,6 @@ import ReadiumInternal import ReadiumShared private let lcpScheme = "http://readium.org/2014/01/lcp" -private let AESBlockSize: UInt64 = 16 // bytes /// Decrypts a resource protected with LCP. final class LCPDecryptor { @@ -117,7 +116,7 @@ final class LCPDecryptor { guard let length = length else { return failure(.requiredEstimatedLength) } - guard length >= 2 * AESBlockSize else { + guard length.isValidAESChunk else { return failure(.invalidCBCData) } @@ -207,6 +206,10 @@ final class LCPDecryptor { private extension LCPLicense { func decryptFully(data: ReadResult, isDeflated: Bool) async -> ReadResult { data.flatMap { + guard UInt64($0.count).isValidAESChunk else { + return .failure(.decoding(LCPDecryptor.Error.invalidCBCData)) + } + do { // Decrypts the resource. guard var data = try self.decipher($0) else { @@ -242,3 +245,15 @@ private extension ReadiumShared.Encryption { algorithm == "http://www.w3.org/2001/04/xmlenc#aes256-cbc" } } + +private let AESBlockSize: UInt64 = 16 // bytes + +private extension UInt64 { + /// Checks if this number is a valid CBC length - i.e. a multiple of AES + /// block size and at least 2 blocks (IV + data). + /// If not, the file is likely not actually encrypted despite being declared + /// as such. + var isValidAESChunk: Bool { + self >= 2 * AESBlockSize && self % AESBlockSize == 0 + } +} diff --git a/Sources/LCP/LCPAcquiredPublication.swift b/Sources/LCP/LCPAcquiredPublication.swift index efad0c9a17..aa1f9fe249 100644 --- a/Sources/LCP/LCPAcquiredPublication.swift +++ b/Sources/LCP/LCPAcquiredPublication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/LCPClient.swift b/Sources/LCP/LCPClient.swift index c64c07395d..de7330fa5d 100644 --- a/Sources/LCP/LCPClient.swift +++ b/Sources/LCP/LCPClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 2d8e464aec..9e156f3ad8 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -45,7 +45,7 @@ public enum LCPError: Error { case parsing(ParsingError) /// A network request failed with the given error. - case network(Error?) + case network(HTTPError?) /// An unexpected LCP error occured. Please post an issue on r2-lcp-swift with the error message and how to reproduce it. case runtime(String) @@ -81,50 +81,50 @@ public enum StatusError: Error { /// Errors while renewing a loan. public enum RenewError: Error { - // Your publication could not be renewed properly. + /// Your publication could not be renewed properly. case renewFailed - // Incorrect renewal period, your publication could not be renewed. + /// Incorrect renewal period, your publication could not be renewed. case invalidRenewalPeriod(maxRenewDate: Date?) - // An unexpected error has occurred on the licensing server. + /// An unexpected error has occurred on the licensing server. case unexpectedServerError(HTTPError) } /// Errors while returning a loan. public enum ReturnError: Error { - // Your publication could not be returned properly. + /// Your publication could not be returned properly. case returnFailed - // Your publication has already been returned before or is expired. + /// Your publication has already been returned before or is expired. case alreadyReturnedOrExpired - // An unexpected error has occurred on the licensing server. + /// An unexpected error has occurred on the licensing server. case unexpectedServerError(HTTPError) } /// Errors while parsing the License or Status JSON Documents. public enum ParsingError: Error { - // The JSON is malformed and can't be parsed. + /// The JSON is malformed and can't be parsed. case malformedJSON - // The JSON is not representing a valid License Document. + /// The JSON is not representing a valid License Document. case licenseDocument - // The JSON is not representing a valid Status Document. + /// The JSON is not representing a valid Status Document. case statusDocument - // Invalid Link. + /// Invalid Link. case link - // Invalid Encryption. + /// Invalid Encryption. case encryption - // Invalid License Document Signature. + /// Invalid License Document Signature. case signature - // Invalid URL for link with rel %@. + /// Invalid URL for link with rel %@. case url(rel: String) } /// Errors while reading or writing a LCP container (LCPL, EPUB, LCPDF, etc.) public enum ContainerError: Error { - // Can't access the container, it's format is wrong. + /// Can't access the container, it's format is wrong. case openFailed(Error?) - // The file at given relative path is not found in the Container. + /// The file at given relative path is not found in the Container. case fileNotFound(String) - // Can't read the file at given relative path in the Container. + /// Can't read the file at given relative path in the Container. case readFailed(path: String) - // Can't write the file at given relative path in the Container. + /// Can't write the file at given relative path in the Container. case writeFailed(path: String) } diff --git a/Sources/LCP/LCPLicense.swift b/Sources/LCP/LCPLicense.swift index a05434fd5c..5edfe2be16 100644 --- a/Sources/LCP/LCPLicense.swift +++ b/Sources/LCP/LCPLicense.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -42,8 +42,10 @@ public protocol LCPLicense: UserRights { /// Renews the loan by starting a renew LSD interaction. /// - /// - Parameter prefersWebPage: Indicates whether the loan should be renewed through a web page if available, - /// instead of programmatically. + /// - Parameters: + /// - delegate: The delegate used to handle the user interactions required during the renewal process. + /// - prefersWebPage: Indicates whether the loan should be renewed through a web page if available, + /// instead of programmatically. func renewLoan( with delegate: LCPRenewDelegate, prefersWebPage: Bool diff --git a/Sources/LCP/LCPLicenseRepository.swift b/Sources/LCP/LCPLicenseRepository.swift index d69acdc36c..f8d99f9093 100644 --- a/Sources/LCP/LCPLicenseRepository.swift +++ b/Sources/LCP/LCPLicenseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/LCPPassphraseRepository.swift b/Sources/LCP/LCPPassphraseRepository.swift index 0c489764ac..b7d369a270 100644 --- a/Sources/LCP/LCPPassphraseRepository.swift +++ b/Sources/LCP/LCPPassphraseRepository.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,21 +9,23 @@ import Foundation /// Represents an LCP passphrase hash. public typealias LCPPassphraseHash = String -/// The passphrase repository stores passphrase hashes associated to a license document, user ID and -/// provider. +/// The passphrase repository stores passphrase hashes associated to a license +/// document, user ID and provider. public protocol LCPPassphraseRepository { /// Returns the passphrase hash associated with the given `licenseID`. func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? - /// Returns a list of passphrase hashes that may match the given `userID`, and `provider`. - func passphrasesMatching( - userID: User.ID?, - provider: LicenseDocument.Provider - ) async throws -> [LCPPassphraseHash] + /// Returns a list of passphrase hashes that may match the given `userID` + /// and `provider`. + func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] + + /// Returns all the saved passphrase hashes. + func passphrases() async throws -> [LCPPassphraseHash] /// Adds a new passphrase hash to the repository. /// - /// If a passphrase is already associated with the given `licenseID`, it will be updated. + /// If a passphrase is already associated with the given `licenseID`, it + /// will be updated. func addPassphrase( _ hash: LCPPassphraseHash, for licenseID: LicenseDocument.ID, diff --git a/Sources/LCP/LCPProgress.swift b/Sources/LCP/LCPProgress.swift index d32996600b..bdf8fa971e 100644 --- a/Sources/LCP/LCPProgress.swift +++ b/Sources/LCP/LCPProgress.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 29b2d34b49..190f948705 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -54,7 +54,7 @@ public class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { } } - private var webPageContinuation: CheckedContinuation? = nil + private var webPageContinuation: CheckedContinuation? } extension LCPDefaultRenewDelegate: UIAdaptivePresentationControllerDelegate { diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 947edf67a7..5ab0018f55 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,12 +21,18 @@ public final class LCPService: Loggable { private let licenses: LicensesService private let assetRetriever: AssetRetriever - /// - Parameter deviceName: Device name used when registering a license to an LSD server. - /// If not provided, the device name will be the default `UIDevice.current.name`. - /// - Parameter deviceId: Device ID used when registering a license to an LSD server. - /// You must ensure the identifier is unique and stable for the device (persist and - /// reuse across app launches). If not provided, the device ID will be generated as - /// a random UUID. + /// - Parameters: + /// - client: The LCP client used for core license operations. + /// - licenseRepository: Repository for managing stored licenses. + /// - passphraseRepository: Repository for managing user passphrases. + /// - assetRetriever: The retriever used to fetch protected assets. + /// - httpClient: The HTTP client used for network requests to LSD/LCP servers. + /// - deviceName: Device name used when registering a license to an LSD server. + /// If not provided, the device name will be the default `UIDevice.current.name`. + /// - deviceId: Device ID used when registering a license to an LSD server. + /// You must ensure the identifier is unique and stable for the device (persist and + /// reuse across app launches). If not provided, the device ID will be generated as + /// a random UUID. public init( client: LCPClient, licenseRepository: LCPLicenseRepository, @@ -95,14 +101,15 @@ public final class LCPService: Loggable { /// Opens the LCP license of a protected publication, to access its DRM /// metadata and decipher its content. /// - /// If the updated license cannot be stored into the ``Asset``, you'll get + /// If the updated license cannot be stored into the `Asset`, you'll get /// an exception if the license points to a LSD server that cannot be /// reached, for instance because no Internet gateway is available. /// - /// Updated licenses can currently be stored only into ``Asset``s whose + /// Updated licenses can currently be stored only into `Asset`s whose /// source property points to a `file://` URL. /// /// - Parameters: + /// - asset: The asset whose license is to be retrieved. /// - authentication: Used to retrieve the user passphrase if it is not /// already known. The request will be cancelled if no passphrase is /// found in the LCP passphrase storage and in the given diff --git a/Sources/LCP/License/Container/ContainerLicenseContainer.swift b/Sources/LCP/License/Container/ContainerLicenseContainer.swift index 9e8771b2c2..3ab1994a28 100644 --- a/Sources/LCP/License/Container/ContainerLicenseContainer.swift +++ b/Sources/LCP/License/Container/ContainerLicenseContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Container/LicenseContainer.swift b/Sources/LCP/License/Container/LicenseContainer.swift index cffcada6cb..fdd8adab38 100644 --- a/Sources/LCP/License/Container/LicenseContainer.swift +++ b/Sources/LCP/License/Container/LicenseContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Container/ResourceLicenseContainer.swift b/Sources/LCP/License/Container/ResourceLicenseContainer.swift index ed8ea9ef32..1ed7a33e36 100644 --- a/Sources/LCP/License/Container/ResourceLicenseContainer.swift +++ b/Sources/LCP/License/Container/ResourceLicenseContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/LCPError+wrap.swift b/Sources/LCP/License/LCPError+wrap.swift index 9d2d157c40..740fc8b3b4 100644 --- a/Sources/LCP/License/LCPError+wrap.swift +++ b/Sources/LCP/License/LCPError+wrap.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -25,19 +25,16 @@ extension LCPError { return .parsing(error) } - if let error = error as? HTTPError { + if let error = (error as? HTTPError) ?? HTTPError.wrap(error) { return .network(error) } let nsError = error as NSError - switch nsError.domain { - case "R2LCPClient.LCPClientError": + if nsError.domain == "R2LCPClient.LCPClientError" { return .licenseIntegrity(LCPClientError(rawValue: nsError.code) ?? .unknown) - case NSURLErrorDomain: - return .network(nsError) - default: - return .unknown(error) } + + return .unknown(error) } static func wrap(_ completion: @escaping (Result) -> Void) -> (Result) -> Void { diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index da345b0509..e8fab8f935 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,7 +9,7 @@ import ReadiumShared import ReadiumZIPFoundation final class License: Loggable { - // Last Documents which passed the integrity checks. + /// Last Documents which passed the integrity checks. private var documents: ValidatedDocuments // Dependencies @@ -37,19 +37,19 @@ final class License: Loggable { /// Public API extension License: LCPLicense { - public var license: LicenseDocument { + var license: LicenseDocument { documents.license } - public var status: StatusDocument? { + var status: StatusDocument? { documents.status } - public var isRestricted: Bool { + var isRestricted: Bool { documents.context.getOrNil() == nil } - public var error: LCPError? { + var error: LCPError? { switch documents.context { case .success: return nil @@ -65,11 +65,11 @@ extension License: LCPLicense { } } - public var encryptionProfile: String? { + var encryptionProfile: String? { license.encryption.profile } - public func decipher(_ data: Data) throws -> Data? { + func decipher(_ data: Data) throws -> Data? { let context = try documents.context.get() return client.decrypt(data: data, using: context) } @@ -153,7 +153,7 @@ extension License: LCPLicense { return } - rights.copy = max(0, printLeft - pageCount) + rights.print = max(0, printLeft - pageCount) } return allowed @@ -185,7 +185,7 @@ extension License: LCPLicense { } } - // Finds the renew link according to `prefersWebPage`. + /// Finds the renew link according to `prefersWebPage`. func findRenewLink() -> Link? { guard let status = documents.status else { return nil @@ -208,7 +208,7 @@ extension License: LCPLicense { return status.linkWithNoType(for: .renew) } - // Renew the loan by presenting a web page to the user. + /// Renew the loan by presenting a web page to the user. func renewWithWebPage(_ link: Link) async throws -> Data { guard let statusURL = try? license.url(for: .status, preferredType: .lcpStatusDocument), @@ -227,9 +227,9 @@ extension License: LCPLicense { .get() } - // Programmatically renew the loan with a PUT request. + /// Programmatically renew the loan with a PUT request. func renewProgrammatically(_ link: Link) async throws -> Data { - // Asks the delegate for a renew date if there's an `end` parameter. + /// Asks the delegate for a renew date if there's an `end` parameter. func preferredEndDate() async throws -> Date? { (link.templateParameters.contains("end")) ? try await delegate.preferredEndDate(maximum: maxRenewDate) diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index e0e087c543..cc23150d00 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -25,7 +25,7 @@ private let supportedProfiles = [ typealias Context = Result -// Holds the License/Status Documents and the DRM context, once validated. +/// Holds the License/Status Documents and the DRM context, once validated. struct ValidatedDocuments { let license: LicenseDocument let context: Context @@ -54,12 +54,12 @@ final actor LicenseValidation: Loggable { fileprivate let httpClient: HTTPClient fileprivate let passphrases: PassphrasesService - // List of observers notified when the Documents are validated, or if an error occurred. + /// List of observers notified when the Documents are validated, or if an error occurred. fileprivate var observers: [(callback: Observer, policy: ObserverPolicy)] = [] fileprivate let onLicenseValidated: (LicenseDocument) async throws -> Void - // Current state in the validation steps. + /// Current state in the validation steps. private(set) var state: State = .start { didSet { log(.debug, "* \(state)") @@ -90,7 +90,7 @@ final actor LicenseValidation: Loggable { self.onLicenseValidated = onLicenseValidated } - // Raw Document's data to validate. + /// Raw Document's data to validate. enum Document { case license(Data) case status(Data) @@ -153,12 +153,14 @@ extension LicenseValidation { } else { self = .fetchStatus(license) } + case let (.validateLicense(_, _), .failed(error)): self = .failure(error) // 2. Fetch the status document case let (.fetchStatus(license), .retrievedStatusData(data)): self = .validateStatus(license, data) + case let (.fetchStatus(license), .failed(_)): // We ignore any error while fetching the Status Document, as it is optional self = .checkLicenseStatus(license, nil, statusDocumentTakesPrecedence: false) @@ -171,6 +173,7 @@ extension LicenseValidation { } else { self = .checkLicenseStatus(license, status, statusDocumentTakesPrecedence: false) } + case let (.validateStatus(license, _), .failed(_)): // We ignore any error while validating the Status Document, as it is optional self = .checkLicenseStatus(license, nil, statusDocumentTakesPrecedence: false) @@ -178,6 +181,7 @@ extension LicenseValidation { // 3. Get an updated license if needed case let (.fetchLicense(_, status), .retrievedLicenseData(data)): self = .validateLicense(data, status) + case let (.fetchLicense(license, status), .failed(_)): // We ignore any error while fetching the updated License Document // Note: since we failed to get the updated License, then the Status Document will take precedence over the License when checking the status. @@ -194,8 +198,10 @@ extension LicenseValidation { // 5. Get the passphrase associated with the license case let (.requestPassphrase(license, status), .retrievedPassphrase(passphrase)): self = .validateIntegrity(license, status, passphrase: passphrase) + case let (.requestPassphrase, .failed(error)): self = .failure(error) + case let (.requestPassphrase(license, status), .passphraseNotFound): self = .valid(ValidatedDocuments(license, .failure(.missingPassphrase), status)) @@ -207,6 +213,7 @@ extension LicenseValidation { } else { self = .valid(documents) } + case let (.validateIntegrity(_, _, _), .failed(error)): self = .failure(error) @@ -217,6 +224,7 @@ extension LicenseValidation { } else { self = .valid(documents) } + case let (.registerDevice(documents, _), .failed(_)): // We ignore any error while registrating the device self = .valid(documents) @@ -236,25 +244,25 @@ extension LicenseValidation { } fileprivate enum Event { - // Raised when reading the License from its container, or when updating it from an LCP server. + /// Raised when reading the License from its container, or when updating it from an LCP server. case retrievedLicenseData(Data) - // Raised when the License Document is parsed and its structure is validated. + /// Raised when the License Document is parsed and its structure is validated. case validatedLicense(LicenseDocument) - // Raised after fetching the Status Document, or receiving it as a response of an LSD interaction. + /// Raised after fetching the Status Document, or receiving it as a response of an LSD interaction. case retrievedStatusData(Data) - // Raised after parsing and validating a Status Document's data. + /// Raised after parsing and validating a Status Document's data. case validatedStatus(StatusDocument) - // Raised after the License's status was checked, with any occurred status error. + /// Raised after the License's status was checked, with any occurred status error. case checkedLicenseStatus(StatusError?) - // Raised when we retrieved the passphrase from the local database, or from prompting the user. + /// Raised when we retrieved the passphrase from the local database, or from prompting the user. case retrievedPassphrase(String) - // Raised after validating the integrity of the License using liblcp.a. + /// Raised after validating the integrity of the License using liblcp.a. case validatedIntegrity(LCPClientContext) - // Raised when the device is registered, with an optional updated Status Document. + /// Raised when the device is registered, with an optional updated Status Document. case registeredDevice(Data?) - // Raised when any error occurs during the validation workflow. + /// Raised when any error occurs during the validation workflow. case failed(Error) - // Raised when no passphrase could be found or given by the user. + /// Raised when no passphrase could be found or given by the user. case passphraseNotFound } @@ -420,9 +428,9 @@ extension LicenseValidation { typealias Observer = (Result) -> Void enum ObserverPolicy { - // The observer is automatically removed when called. + /// The observer is automatically removed when called. case once - // The observer is called everytime the validation is finished. + /// The observer is called everytime the validation is finished. case always } diff --git a/Sources/LCP/License/Model/Components/LCP/ContentKey.swift b/Sources/LCP/License/Model/Components/LCP/ContentKey.swift index 5f780c37d1..6de43fa966 100644 --- a/Sources/LCP/License/Model/Components/LCP/ContentKey.swift +++ b/Sources/LCP/License/Model/Components/LCP/ContentKey.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/LCP/Encryption.swift b/Sources/LCP/License/Model/Components/LCP/Encryption.swift index f2545a8409..cd95b3c52a 100644 --- a/Sources/LCP/License/Model/Components/LCP/Encryption.swift +++ b/Sources/LCP/License/Model/Components/LCP/Encryption.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/LCP/Rights.swift b/Sources/LCP/License/Model/Components/LCP/Rights.swift index 8d5653fc72..f229a0841d 100644 --- a/Sources/LCP/License/Model/Components/LCP/Rights.swift +++ b/Sources/LCP/License/Model/Components/LCP/Rights.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/LCP/Signature.swift b/Sources/LCP/License/Model/Components/LCP/Signature.swift index dd78554ba6..5d642989af 100644 --- a/Sources/LCP/License/Model/Components/LCP/Signature.swift +++ b/Sources/LCP/License/Model/Components/LCP/Signature.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/LCP/User.swift b/Sources/LCP/License/Model/Components/LCP/User.swift index cbe313b951..e0bea43b11 100644 --- a/Sources/LCP/License/Model/Components/LCP/User.swift +++ b/Sources/LCP/License/Model/Components/LCP/User.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/LCP/UserKey.swift b/Sources/LCP/License/Model/Components/LCP/UserKey.swift index ba25f1e743..7bdd7c7857 100644 --- a/Sources/LCP/License/Model/Components/LCP/UserKey.swift +++ b/Sources/LCP/License/Model/Components/LCP/UserKey.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/LSD/Event.swift b/Sources/LCP/License/Model/Components/LSD/Event.swift index da792c868e..8ae430f521 100644 --- a/Sources/LCP/License/Model/Components/LSD/Event.swift +++ b/Sources/LCP/License/Model/Components/LSD/Event.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,15 +9,15 @@ import Foundation /// Event related to the change in status of a License Document. public struct Event { public enum EventType: String { - // Signals a successful registration event by a device. + /// Signals a successful registration event by a device. case register - // Signals a successful renew event. + /// Signals a successful renew event. case renew - // Signals a successful return event. + /// Signals a successful return event. case `return` - // Signals a revocation event. + /// Signals a revocation event. case revoke - // Signals a cancellation event. + /// Signals a cancellation event. case cancel } diff --git a/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift b/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift index 4ba1ba6769..01d4c3a602 100644 --- a/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift +++ b/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/Link.swift b/Sources/LCP/License/Model/Components/Link.swift index b737af4bcd..42360eb131 100644 --- a/Sources/LCP/License/Model/Components/Link.swift +++ b/Sources/LCP/License/Model/Components/Link.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/Components/Links.swift b/Sources/LCP/License/Model/Components/Links.swift index 02d6979416..ae4bf71b9e 100644 --- a/Sources/LCP/License/Model/Components/Links.swift +++ b/Sources/LCP/License/Model/Components/Links.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index ca47d6ddc5..460a837276 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -13,17 +13,17 @@ public struct LicenseDocument { public typealias ID = String public typealias Provider = String - // The possible rel of Links. + /// The possible rel of Links. public enum Rel: String { - // Location where a Reading System can redirect a User looking for additional information about the User Passphrase. + /// Location where a Reading System can redirect a User looking for additional information about the User Passphrase. case hint - // Location where the Publication associated with the License Document can be downloaded + /// Location where the Publication associated with the License Document can be downloaded case publication - // As defined in the IANA registry of link relations: "Conveys an identifier for the link's context." + /// As defined in the IANA registry of link relations: "Conveys an identifier for the link's context." case `self` - // Support resources for the user (either a website, an email or a telephone number). + /// Support resources for the user (either a website, an email or a telephone number). case support - // Location to the Status Document for this license. + /// Location to the Status Document for this license. case status } @@ -35,7 +35,7 @@ public struct LicenseDocument { public let issued: Date /// Date when the license was last updated. public let updated: Date - // Encryption object. + /// Encryption object. public let encryption: Encryption /// Used to associate the License Document with resources that are not locally available. public let links: Links @@ -83,7 +83,7 @@ public struct LicenseDocument { jsonData = data self.jsonString = jsonString - /// Checks that `links` contains at least one link with `publication` relation. + // Checks that `links` contains at least one link with `publication` relation. guard link(for: .publication) != nil else { throw ParsingError.licenseDocument } diff --git a/Sources/LCP/License/Model/StatusDocument.swift b/Sources/LCP/License/Model/StatusDocument.swift index c9f25d7d82..aca43cda32 100644 --- a/Sources/LCP/License/Model/StatusDocument.swift +++ b/Sources/LCP/License/Model/StatusDocument.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,17 +11,17 @@ import ReadiumShared /// https://github.com/readium/lcp-specs/blob/master/schema/status.schema.json public struct StatusDocument { public enum Status: String { - // The License Document is available, but the user hasn't accessed the License and/or Status Document yet. + /// The License Document is available, but the user hasn't accessed the License and/or Status Document yet. case ready - // The license is active, and a device has been successfully registered for this license. This is the default value if the License Document does not contain a registration link, or a registration mechanism through the license itself. + /// The license is active, and a device has been successfully registered for this license. This is the default value if the License Document does not contain a registration link, or a registration mechanism through the license itself. case active - // The license is no longer active, it has been invalidated by the Issuer. + /// The license is no longer active, it has been invalidated by the Issuer. case revoked - // The license is no longer active, it has been invalidated by the User. + /// The license is no longer active, it has been invalidated by the User. case returned - // The license is no longer active because it was cancelled prior to activation. + /// The license is no longer active because it was cancelled prior to activation. case cancelled - // The license is no longer active because it has expired. + /// The license is no longer active because it has expired. case expired } diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift new file mode 100644 index 0000000000..c42db9b1a4 --- /dev/null +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift @@ -0,0 +1,273 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal +import ReadiumShared + +/// Errors occurring in ``LCPKeychainLicenseRepository``. +public enum LCPKeychainLicenseRepositoryError: Error { + /// The license with the given `id` was not found in the repository. + case licenseNotFound(id: LicenseDocument.ID) + + /// An error occurred while accessing the keychain. + case keychain(KeychainError) + + /// An error occurred while decoding or encoding a License. + case coding(Error) +} + +/// Keychain-based implementation of ``LCPLicenseRepository``. +/// +/// Stores license data securely in the iOS/macOS Keychain with optional iCloud +/// synchronization. +public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { + /// Internal data structure for storing license information in the Keychain. + private struct License: Codable { + /// Unique identifier for this license. + let licenseID: LicenseDocument.ID + + /// JSON representation of the ``LicenseDocument``. + var licenseJSON: String? + + /// Remaining pages to print. + var printsLeft: Int? + + /// Remaining number of characters to copy. + var copiesLeft: Int? + + /// Date when the device was registered for this license. + var registered: Bool + + /// Date this license was added to the Keychain. + let created: Date + + /// Date this license was updated in the Keychain. + var updated: Date + } + + private let keychain: Keychain + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Initializes a Keychain-based license repository. + /// + /// - Parameters: + /// - synchronizable: Whether items should sync via iCloud Keychain. + public init(synchronizable: Bool = true) { + keychain = Keychain( + serviceName: "org.readium.lcp.licenses", + synchronizable: synchronizable + ) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - LCPLicenseRepository + + public func addLicense(_ licenseDocument: LicenseDocument) async throws { + if var license = try getLicense(for: licenseDocument.id) { + // License exists - update it without overwriting consumable rights + license.licenseJSON = licenseDocument.jsonString + try updateLicense(license, for: licenseDocument.id) + + } else { + // New license - initialize with rights from license document + let newLicense = License( + licenseID: licenseDocument.id, + licenseJSON: licenseDocument.jsonString, + printsLeft: licenseDocument.rights.print, + copiesLeft: licenseDocument.rights.copy, + registered: false, + created: Date(), + updated: Date() + ) + + try addLicense(newLicense, for: licenseDocument.id) + } + } + + public func license(for id: LicenseDocument.ID) async throws -> LicenseDocument? { + guard + let licenseData = try getLicense(for: id), + let jsonString = licenseData.licenseJSON, + let jsonData = jsonString.data(using: .utf8), + let licenseDocument = try? LicenseDocument(data: jsonData) + else { + return nil + } + + return licenseDocument + } + + public func isDeviceRegistered(for id: LicenseDocument.ID) async throws -> Bool { + try requireLicense(for: id).registered + } + + public func registerDevice(for id: LicenseDocument.ID) async throws { + var license = try requireLicense(for: id) + license.registered = true + try updateLicense(license, for: id) + } + + public func userRights(for id: LicenseDocument.ID) async throws -> LCPConsumableUserRights { + guard let licenseData = try getLicense(for: id) else { + throw LCPKeychainLicenseRepositoryError.licenseNotFound(id: id) + } + + return LCPConsumableUserRights( + print: licenseData.printsLeft, + copy: licenseData.copiesLeft + ) + } + + public func updateUserRights( + for id: LicenseDocument.ID, + with changes: (inout LCPConsumableUserRights) -> Void + ) async throws { + var license = try requireLicense(for: id) + + // Get current rights + var currentRights = LCPConsumableUserRights( + print: license.printsLeft, + copy: license.copiesLeft + ) + + // Apply changes + changes(¤tRights) + + // Update the data + license.printsLeft = currentRights.print + license.copiesLeft = currentRights.copy + + try updateLicense(license, for: id) + } + + /// Removes all licenses from the repository. + public func clear() async throws { + do { + try keychain.deleteAll() + } catch { + throw LCPKeychainLicenseRepositoryError.keychain(error) + } + } + + // MARK: - Migration Support + + /// Imports license rights from an external source without requiring the + /// full ``LicenseDocument``. + /// + /// This is used during migration from repositories that don't store the + /// full document, like the legacy SQLite repositories. + /// + /// When the publication is later opened, `addLicense()` will add the full + /// document while preserving these migrated rights. + /// + /// - Parameters: + /// - licenseID: The license identifier + /// - rights: The consumable user rights to store + /// - registered: Whether the device is registered for this license + public func importLicenseRights( + for licenseID: LicenseDocument.ID, + rights: LCPConsumableUserRights, + registered: Bool + ) async throws { + // We don't overwrite the rights if the license already exists. + guard try getLicense(for: licenseID) == nil else { + return + } + + // Create new entry without the full license document, which will + // be added when the publication is opened again. + let newData = License( + licenseID: licenseID, + licenseJSON: nil, + printsLeft: rights.print, + copiesLeft: rights.copy, + registered: registered, + created: Date(), + updated: Date() + ) + try addLicense(newData, for: licenseID) + } + + // MARK: - Keychain Access + + private func requireLicense(for licenseID: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) -> License { + guard let license = try getLicense(for: licenseID) else { + throw .licenseNotFound(id: licenseID) + } + return license + } + + /// Gets a license from the Keychain for the given license ID. + private func getLicense(for licenseID: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) -> License? { + guard let data = try getFromKeychain(id: licenseID) else { + return nil + } + + return try decode(data) + } + + /// Adds a new license to the Keychain. + private func addLicense(_ license: License, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { + try addToKeychain(data: encode(license), for: id) + } + + /// Updates an existing license in the Keychain. + private func updateLicense(_ license: License, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { + var license = license + license.updated = Date() + let data = try encode(license) + try updateKeychain(data: data, for: id) + } + + // MARK: - Low-Level Helpers + + private func getFromKeychain(id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) -> Data? { + do { + return try keychain.load(forKey: id) + } catch { + throw .keychain(error) + } + } + + private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { + do { + try keychain.save(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainLicenseRepositoryError) { + do { + try keychain.update(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func decode(_ data: Data) throws(LCPKeychainLicenseRepositoryError) -> License { + do { + return try decoder.decode(License.self, from: data) + } catch { + throw .coding(error) + } + } + + private func encode(_ license: License) throws(LCPKeychainLicenseRepositoryError) -> Data { + do { + return try encoder.encode(license) + } catch { + throw .coding(error) + } + } +} diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift new file mode 100644 index 0000000000..13ff5b1f22 --- /dev/null +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift @@ -0,0 +1,207 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal +import ReadiumShared + +/// Errors occurring in ``LCPKeychainPassphraseRepository``. +public enum LCPKeychainPassphraseRepositoryError: Error { + /// An error occurred while accessing the keychain. + case keychain(KeychainError) + + /// An error occurred while decoding or encoding a passphrase. + case coding(Error) +} + +/// Keychain-based implementation of ``LCPPassphraseRepository``. +/// +/// Stores passphrase hashes securely in the iOS/macOS Keychain with optional +/// iCloud synchronization. +public actor LCPKeychainPassphraseRepository: LCPPassphraseRepository, Loggable { + /// Internal data structure for storing passphrase information in the + /// Keychain. + private struct Passphrase: Codable { + /// Unique identifier for the license this passphrase belongs to. + let licenseID: LicenseDocument.ID + + /// The hashed passphrase. + var passphraseHash: LCPPassphraseHash + + /// The license provider. + var provider: LicenseDocument.Provider + + /// The user identifier. + var userID: User.ID? + + /// Date this passphrase was added to the Keychain. + let created: Date + + /// Date this passphrase was updated in the Keychain. + var updated: Date + } + + private let keychain: Keychain + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Initializes a Keychain-based passphrase repository. + /// + /// - Parameters: + /// - synchronizable: Whether items should sync via iCloud Keychain. + public init(synchronizable: Bool = true) { + keychain = Keychain( + serviceName: "org.readium.lcp.passphrases", + synchronizable: synchronizable + ) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - LCPPassphraseRepository + + public func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? { + try getPassphrase(for: licenseID)?.passphraseHash + } + + public func passphrasesMatching( + userID: User.ID?, + provider: LicenseDocument.Provider + ) async throws -> [LCPPassphraseHash] { + try await getAllPassphrases() + .filter { passphrase in + passphrase.provider == provider && (userID == nil || passphrase.userID == userID) + } + .map(\.passphraseHash) + } + + public func passphrases() async throws -> [LCPPassphraseHash] { + try await getAllPassphrases() + .map(\.passphraseHash) + } + + public func addPassphrase( + _ hash: LCPPassphraseHash, + for licenseID: LicenseDocument.ID, + userID: User.ID?, + provider: LicenseDocument.Provider + ) async throws { + if var passphrase = try getPassphrase(for: licenseID) { + passphrase.passphraseHash = hash + passphrase.provider = provider + passphrase.userID = userID + try updatePassphrase(passphrase, for: licenseID) + } else { + let passphrase = Passphrase( + licenseID: licenseID, + passphraseHash: hash, + provider: provider, + userID: userID, + created: Date(), + updated: Date() + ) + + try addPassphrase(passphrase, for: licenseID) + } + } + + /// Removes all passphrases from the repository. + public func clear() async throws { + do { + try keychain.deleteAll() + } catch { + throw LCPKeychainPassphraseRepositoryError.keychain(error) + } + } + + // MARK: - Keychain Access + + private func getAllPassphrases() async throws(LCPKeychainPassphraseRepositoryError) -> [Passphrase] { + try getAllFromKeychain() + .compactMap { _, data in + guard let passphrase = try? decoder.decode(Passphrase.self, from: data) else { + return nil + } + return passphrase + } + } + + /// Gets a passphrase from the Keychain for the given license ID. + private func getPassphrase(for licenseID: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) -> Passphrase? { + guard let data = try getFromKeychain(id: licenseID) else { + return nil + } + + return try decode(data) + } + + /// Adds a new passphrase to the Keychain. + private func addPassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { + try addToKeychain(data: encode(passphrase), for: id) + } + + /// Updates an existing passphrase in the Keychain. + private func updatePassphrase(_ passphrase: Passphrase, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { + var passphrase = passphrase + passphrase.updated = Date() + let data = try encode(passphrase) + try updateKeychain(data: data, for: id) + } + + // MARK: - Low-Level Helpers + + private func getFromKeychain(id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) -> Data? { + do { + return try keychain.load(forKey: id) + } catch { + throw .keychain(error) + } + } + + private func getAllFromKeychain() throws(LCPKeychainPassphraseRepositoryError) -> [String: Data] { + do { + return try keychain.allItems() + } catch { + throw .keychain(error) + } + } + + private func addToKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { + do { + try keychain.save(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func updateKeychain(data: Data, for id: LicenseDocument.ID) throws(LCPKeychainPassphraseRepositoryError) { + do { + try keychain.update(data: data, forKey: id) + } catch { + throw .keychain(error) + } + } + + private func decode(_ data: Data) throws(LCPKeychainPassphraseRepositoryError) -> Passphrase { + do { + return try decoder.decode(Passphrase.self, from: data) + } catch { + throw .coding(error) + } + } + + private func encode(_ passphrase: Passphrase) throws(LCPKeychainPassphraseRepositoryError) -> Data { + do { + return try encoder.encode(passphrase) + } catch { + throw .coding(error) + } + } +} diff --git a/Sources/LCP/Resources/en.lproj/Localizable.strings b/Sources/LCP/Resources/en.lproj/Localizable.strings index 8e15d16ff9..e2ae1f828f 100644 --- a/Sources/LCP/Resources/en.lproj/Localizable.strings +++ b/Sources/LCP/Resources/en.lproj/Localizable.strings @@ -1,44 +1,13 @@ -/* - Copyright 2025 Readium Foundation. All rights reserved. - Use of this source code is governed by the BSD-style license - available in the top-level LICENSE file of the project. -*/ - -/* LCP Dialog Authentication */ - -"ReadiumLCP.dialog.title" = "Enter Password"; -"ReadiumLCP.dialog.cancel" = "Cancel"; -"ReadiumLCP.dialog.continue" = "Continue"; -"ReadiumLCP.dialog.forgotYourPassphrase" = "Forgot Your Password?"; -"ReadiumLCP.dialog.hint" = "**Hint:** %@"; -"ReadiumLCP.dialog.header" = "This publication requires an LCP password to open, which is provided by your library or bookstore. Enter it once, and you're all set to read on this device."; -"ReadiumLCP.dialog.details.title" = "What is LCP?"; -"ReadiumLCP.dialog.details.body" = "This publication is protected by LCP (Licensed Content Protection), a DRM technology that prevents unauthorized copying while keeping your reading experience simple. LCP is an open standard that balances user-friendliness with the needs of publishers."; -"ReadiumLCP.dialog.details.more" = "Learn more…"; -"ReadiumLCP.dialog.passphrase.placeholder" = "Password"; -"ReadiumLCP.dialog.error.incorrectPassphrase" = "Incorrect password."; - -// MARK: - Legacy strings (LCPDialogViewController) - -/* Prompt messages when asking for the passphrase */ -"ReadiumLCP.dialog.prompt.message1" = "This publication is protected by Readium LCP."; -"ReadiumLCP.dialog.prompt.message2" = "In order to open it, we need to know the passphrase required by:\n\n%@\n\nTo help you remember it, the following hint is available:"; -/* Reason to ask for the passphrase when it was not found */ -"ReadiumLCP.dialog.reason.passphraseNotFound" = "Passphrase Required"; -/* Reason to ask for the passphrase when the one entered was incorrect */ -"ReadiumLCP.dialog.reason.invalidPassphrase" = "Incorrect Passphrase"; -/* Forgot passphrase button */ -"ReadiumLCP.dialog.prompt.forgotPassphrase" = "Forgot your passphrase?"; -/* Support button */ -"ReadiumLCP.dialog.prompt.support" = "Need more help?"; -/* Continue button */ -"ReadiumLCP.dialog.prompt.continue" = "Continue"; -/* Passphrase placeholder */ -"ReadiumLCP.dialog.prompt.passphrase" = "Passphrase"; - -/* Button to contact the support when entering the passphrase */ -"ReadiumLCP.dialog.support" = "Support"; -"ReadiumLCP.dialog.support.website" = "Website"; -"ReadiumLCP.dialog.support.phone" = "Phone"; -"ReadiumLCP.dialog.support.mail" = "Mail"; - +// DO NOT EDIT. File generated automatically from the en JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.lcp.dialog.actions.cancel" = "Cancel"; +"readium.lcp.dialog.actions.continue" = "Continue"; +"readium.lcp.dialog.actions.recoverPassphrase" = "Forgot Your Password?"; +"readium.lcp.dialog.errors.incorrectPassphrase" = "Incorrect password."; +"readium.lcp.dialog.info.body" = "This publication is protected by LCP (Licensed Content Protection), a DRM technology that prevents unauthorized copying while keeping your reading experience simple. LCP is an open standard that balances user-friendliness with the needs of publishers."; +"readium.lcp.dialog.info.more" = "Learn more…"; +"readium.lcp.dialog.info.title" = "What is LCP?"; +"readium.lcp.dialog.message" = "This publication requires an LCP password to open, which is provided by your library or bookstore. Enter it once, and you're all set to read on this device."; +"readium.lcp.dialog.passphrase.hint" = "**Hint:** %1$@"; +"readium.lcp.dialog.passphrase.placeholder" = "Password"; +"readium.lcp.dialog.title" = "Enter Password"; diff --git a/Sources/LCP/Resources/fr.lproj/Localizable.strings b/Sources/LCP/Resources/fr.lproj/Localizable.strings new file mode 100644 index 0000000000..f231575c7b --- /dev/null +++ b/Sources/LCP/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,13 @@ +// DO NOT EDIT. File generated automatically from the fr JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.lcp.dialog.actions.cancel" = "Annuler"; +"readium.lcp.dialog.actions.continue" = "Continuer"; +"readium.lcp.dialog.actions.recoverPassphrase" = "Mot de passe oubliΓ©β€―?"; +"readium.lcp.dialog.errors.incorrectPassphrase" = "Mot de passe incorrect."; +"readium.lcp.dialog.info.body" = "Cette publication est protΓ©gΓ©e par LCP (Licensed Content Protection), une technologie DRM qui empΓͺche la copie non autorisΓ©e tout en simplifiant votre expΓ©rience de lecture. LCP est un standard ouvert qui Γ©quilibre la facilitΓ© d'utilisation et les besoins des Γ©diteurs."; +"readium.lcp.dialog.info.more" = "En savoir plus…"; +"readium.lcp.dialog.info.title" = "Qu'est-ce que LCPβ€―?"; +"readium.lcp.dialog.message" = "Cette publication est protΓ©gΓ©e par LCP. Veuillez saisir le mot de passe fourni par votre bibliothΓ¨que ou votre libraire pour l'ouvrir."; +"readium.lcp.dialog.passphrase.hint" = "**IndiceΒ :** %1$@"; +"readium.lcp.dialog.passphrase.placeholder" = "Mot de passe"; +"readium.lcp.dialog.title" = "Saisir le mot de passe"; diff --git a/Sources/LCP/Resources/it.lproj/Localizable.strings b/Sources/LCP/Resources/it.lproj/Localizable.strings new file mode 100644 index 0000000000..459bf0b777 --- /dev/null +++ b/Sources/LCP/Resources/it.lproj/Localizable.strings @@ -0,0 +1,13 @@ +// DO NOT EDIT. File generated automatically from the it JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.lcp.dialog.actions.cancel" = "Annulla"; +"readium.lcp.dialog.actions.continue" = "Continua"; +"readium.lcp.dialog.actions.recoverPassphrase" = "Hai dimenticato la password?"; +"readium.lcp.dialog.errors.incorrectPassphrase" = "Password errata."; +"readium.lcp.dialog.info.body" = "Questa pubblicazione Γ¨ protetta con LCP (Licensed Content Protection), una tecnologia DRM che impedisce la riproduzione non autorizzata mantenendo semplice la tua esperienza di lettura. LCP Γ¨ uno standard aperto che coniuga la facilitΓ  d'uso con le esigenze degli editori."; +"readium.lcp.dialog.info.more" = "Per saperne di più…"; +"readium.lcp.dialog.info.title" = "Che cos'Γ¨ LCP?"; +"readium.lcp.dialog.message" = "Questa pubblicazione richiede una password LCP per essere aperta, fornita dalla tua biblioteca o dalla tua libreria online. Inseriscila e potrai iniziare a leggere su questa periferica."; +"readium.lcp.dialog.passphrase.hint" = "**Indizio:** %1$@"; +"readium.lcp.dialog.passphrase.placeholder" = "Password"; +"readium.lcp.dialog.title" = "Inserisci la password"; diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index e5b9282c30..043a9413ed 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,7 +9,7 @@ import ReadiumShared /// Certificate Revocation List final class CRLService { - // Number of days before the CRL cache expires. + /// Number of days before the CRL cache expires. private static let expiration = 7 private static let crlKey = "org.readium.r2-lcp-swift.CRL" diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index fdcade3998..f9d10e207a 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -38,7 +38,7 @@ final class DeviceService { self.httpClient = httpClient } - // Device ID and name as query parameters for HTTP requests. + /// Device ID and name as query parameters for HTTP requests. var asQueryParameters: [String: String] { [ "id": id, diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 437f9d78e4..303447acc0 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,7 +8,7 @@ import Foundation import ReadiumShared final class LicensesService: Loggable { - // Mapping between an unprotected format to the matching LCP protected format. + /// Mapping between an unprotected format to the matching LCP protected format. private let mediaTypesMapping: [MediaType: MediaType] = [ .readiumAudiobook: .lcpProtectedAudiobook, .pdf: .lcpProtectedPDF, @@ -138,7 +138,7 @@ final class LicensesService: Loggable { _ license: LicenseDocument, in url: FileURL ) async throws { - let _ = try await injectLicenseAndGetFormat(license, in: url, mediaTypeHint: nil) + _ = try await injectLicenseAndGetFormat(license, in: url, mediaTypeHint: nil) } private func injectLicenseAndGetFormat( diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index c36ca3b7ff..6708ec35fe 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -38,12 +38,7 @@ final class PassphrasesService { return passphrase } - // Look for alternative candidates based on the provider and user ID. - let candidates = try await repository.passphrasesMatching( - userID: license.user.id, - provider: license.provider - ) - var passphrase: LCPPassphraseHash? = findValidPassphrase(in: candidates, for: license) + var passphrase = try await findAlternatePassphrase(for: license) // Fallback on the provided `LCPAuthenticating` implementation. if passphrase == nil, let authentication = authentication { @@ -64,6 +59,22 @@ final class PassphrasesService { return passphrase } + private func findAlternatePassphrase(for license: LicenseDocument) async throws -> LCPPassphraseHash? { + // Look for alternative candidates based on the provider and user ID. + let candidates = try await repository.passphrasesMatching( + userID: license.user.id, + provider: license.provider + ) + if let passphrase = findValidPassphrase(in: candidates, for: license) { + return passphrase + } + + // The legacy SQLite database did not save all the new (passphrase, + // userID, provider) tuples. So we need to fall back on checking all the + // saved passphrases for a match. + return try await findValidPassphrase(in: repository.passphrases(), for: license) + } + private func findValidPassphrase(in hashes: [LCPPassphraseHash], for license: LicenseDocument) -> LCPPassphraseHash? { guard !hashes.isEmpty else { return nil diff --git a/Sources/LCP/Toolkit/Bundle.swift b/Sources/LCP/Toolkit/Bundle.swift index 775f2e014c..cd8019ceda 100644 --- a/Sources/LCP/Toolkit/Bundle.swift +++ b/Sources/LCP/Toolkit/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/LCP/Toolkit/DataCompression.swift b/Sources/LCP/Toolkit/DataCompression.swift index 7808a6fa02..e6b93a33ab 100644 --- a/Sources/LCP/Toolkit/DataCompression.swift +++ b/Sources/LCP/Toolkit/DataCompression.swift @@ -1,29 +1,35 @@ -/// -/// DataCompression -/// -/// A libcompression wrapper as an extension for the `Data` type -/// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) -/// -/// Created by Markus Wanke, 2016/12/05 -/// - -/// -/// Apache License, Version 2.0 -/// -/// Copyright 2016, Markus Wanke -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +// +// DataCompression +// +// A libcompression wrapper as an extension for the `Data` type +// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) +// +// Created by Markus Wanke, 2016/12/05 +// + +// +// Apache License, Version 2.0 +// +// Copyright 2016, Markus Wanke +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// import Compression import Foundation @@ -210,11 +216,15 @@ public extension Data { } } if has_fname { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_cmmnt { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_crc16 { @@ -255,7 +265,7 @@ public struct Crc32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::crc32` + /// C convention function pointer type matching the signature of `libz::crc32` private typealias ZLibCrc32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -344,7 +354,7 @@ public struct Adler32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::adler32` + /// C convention function pointer type matching the signature of `libz::adler32` private typealias ZLibAdler32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -398,8 +408,7 @@ public struct Adler32: CustomStringConvertible { } private extension Data { - func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType - { + func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType { try withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> ResultType in try body(rawBufferPointer.bindMemory(to: ContentType.self).baseAddress!) } @@ -419,8 +428,7 @@ private extension Data.CompressionAlgorithm { private typealias Config = (operation: compression_stream_operation, algorithm: compression_algorithm) -private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? -{ +private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? { guard config.operation == COMPRESSION_STREAM_ENCODE || sourceSize > 0 else { return nil } let streamBase = UnsafeMutablePointer.allocate(capacity: 1) diff --git a/Sources/LCP/Toolkit/Streamable.swift b/Sources/LCP/Toolkit/ReadResult.swift similarity index 62% rename from Sources/LCP/Toolkit/Streamable.swift rename to Sources/LCP/Toolkit/ReadResult.swift index 2f560f0f77..7107631acb 100644 --- a/Sources/LCP/Toolkit/Streamable.swift +++ b/Sources/LCP/Toolkit/ReadResult.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -7,10 +7,10 @@ import Foundation import ReadiumShared -extension Streamable { - /// Reads the whole content as a LCP License Document. - func readAsLCPL() async -> ReadResult { - await read().flatMap { data in +extension ReadResult { + /// Decodes the data as a LCP License Document. + func asLCPL() -> ReadResult { + flatMap { data in do { return try .success(LicenseDocument(data: data)) } catch { diff --git a/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift index c67b079002..b8894b94dd 100644 --- a/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift +++ b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -13,7 +13,7 @@ func ReadiumLCPLocalizedString(_ key: String, _ values: CVarArg...) -> String { } func ReadiumLCPLocalizedString(_ key: String, _ values: [CVarArg]) -> String { - ReadiumLocalizedString("ReadiumLCP.\(key)", in: Bundle.module, values) + ReadiumLocalizedString("readium.lcp.\(key)", in: Bundle.module, values) } func ReadiumLCPLocalizedStringKey(_ key: String, _ values: CVarArg...) -> LocalizedStringKey { diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 33261e6ba1..a7753ec62a 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -66,7 +66,9 @@ public struct MediaPlaybackInfo { public extension AudioNavigatorDelegate { func navigator(_ navigator: AudioNavigator, playbackDidChange info: MediaPlaybackInfo) {} - func navigator(_ navigator: AudioNavigator, shouldPlayNextResource info: MediaPlaybackInfo) -> Bool { true } + func navigator(_ navigator: AudioNavigator, shouldPlayNextResource info: MediaPlaybackInfo) -> Bool { + true + } func navigator(_ navigator: AudioNavigator, loadedTimeRangesDidChange ranges: [Range]) {} } @@ -112,7 +114,9 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private let initialLocation: Locator? private let config: Configuration - public var audioConfiguration: AudioSession.Configuration { config.audioSession } + public var audioConfiguration: AudioSession.Configuration { + config.audioSession + } public init( publication: Publication, diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift index ebbaa9f5a1..54e4da684e 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift index 57e8ace33c..3a92538b14 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift index 4e2a658bea..be042b74d7 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index e4b634575d..0c918bb768 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -13,7 +13,7 @@ import ReadiumShared /// /// Useful for local resources or when you need to customize the way HTTP requests are sent. final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Loggable, @unchecked Sendable { - public enum AssetError: Error { + enum AssetError: Error { /// Can't produce an URL to create an AVAsset for the given HREF. case invalidHREF(String) } diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift index 66c76e5655..c539f87fcf 100644 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,9 +8,11 @@ import ReadiumInternal import ReadiumShared import UIKit +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") public protocol CBZNavigatorDelegate: VisualNavigatorDelegate {} /// A view controller used to render a CBZ `Publication`. +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") open class CBZNavigatorViewController: InputObservableViewController, VisualNavigator, Loggable @@ -254,6 +256,7 @@ open class CBZNavigatorViewController: } } +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") extension CBZNavigatorViewController: UIPageViewControllerDataSource { public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { guard let imageVC = viewController as? ImageViewController else { @@ -284,6 +287,7 @@ extension CBZNavigatorViewController: UIPageViewControllerDataSource { } } +@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") extension CBZNavigatorViewController: UIPageViewControllerDelegate { public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { if completed, let position = currentLocation { diff --git a/Sources/Navigator/CBZ/ImageViewController.swift b/Sources/Navigator/CBZ/ImageViewController.swift index b8712d6e49..3d6644487a 100644 --- a/Sources/Navigator/CBZ/ImageViewController.swift +++ b/Sources/Navigator/CBZ/ImageViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index 38b09f8d45..9881aee46a 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -27,7 +27,9 @@ public protocol DecorableNavigator { /// Registers new callbacks for decoration interactions in the given `group`. /// - /// - Parameter onActivated: Called when the user activates the decoration, e.g. with a click or tap. + /// - Parameters: + /// - group: The name of the decoration group to observe. + /// - onActivated: Called when the user activates the decoration, e.g. with a click or tap. func observeDecorationInteractions(inGroup group: String, onActivated: @escaping OnActivatedCallback) /// Called when the user activates a decoration, e.g. with a click or tap. diff --git a/Sources/Navigator/Decorator/DiffableDecoration.swift b/Sources/Navigator/Decorator/DiffableDecoration.swift index 9a476b5858..75ed4ee8f6 100644 --- a/Sources/Navigator/Decorator/DiffableDecoration.swift +++ b/Sources/Navigator/Decorator/DiffableDecoration.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,7 +10,9 @@ import ReadiumShared struct DiffableDecoration: Hashable, Differentiable { let decoration: Decoration - var differenceIdentifier: Decoration.Id { decoration.id } + var differenceIdentifier: Decoration.Id { + decoration.id + } } enum DecorationChange { diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index bfa1a574c2..3b81922aad 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -100,7 +100,9 @@ public final class DirectionalNavigationAdapter { private let pointerPolicy: PointerPolicy private let keyboardPolicy: KeyboardPolicy private let animatedTransition: Bool + private let onNavigation: @MainActor () -> Void + @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") private weak var navigator: VisualNavigator? /// Initializes a new `DirectionalNavigationAdapter`. @@ -110,14 +112,17 @@ public final class DirectionalNavigationAdapter { /// - keyboardPolicy: Policy on page turns using the keyboard. /// - animatedTransition: Indicates whether the page turns should be /// animated. + /// - onNavigation: Callback called when a navigation is triggered. public init( pointerPolicy: PointerPolicy = PointerPolicy(), keyboardPolicy: KeyboardPolicy = KeyboardPolicy(), - animatedTransition: Bool = false + animatedTransition: Bool = false, + onNavigation: @escaping @MainActor () -> Void = {} ) { self.pointerPolicy = pointerPolicy self.keyboardPolicy = keyboardPolicy self.animatedTransition = animatedTransition + self.onNavigation = onNavigation } /// Binds the adapter to the given visual navigator. @@ -162,7 +167,6 @@ public final class DirectionalNavigationAdapter { } let bounds = navigator.view.bounds - let options = NavigatorGoOptions(animated: animatedTransition) if pointerPolicy.edges.contains(.horizontal) { let horizontalEdgeSize = pointerPolicy.horizontalEdgeThresholdPercent @@ -172,9 +176,9 @@ public final class DirectionalNavigationAdapter { let rightRange = (bounds.width - horizontalEdgeSize) ... bounds.width if rightRange.contains(point.x) { - return await navigator.goRight(options: options) + return await goRight(in: navigator) } else if leftRange.contains(point.x) { - return await navigator.goLeft(options: options) + return await goLeft(in: navigator) } } @@ -186,9 +190,9 @@ public final class DirectionalNavigationAdapter { let bottomRange = (bounds.height - verticalEdgeSize) ... bounds.height if bottomRange.contains(point.y) { - return await navigator.goForward(options: options) + return await goForward(in: navigator) } else if topRange.contains(point.y) { - return await navigator.goBackward(options: options) + return await goBackward(in: navigator) } } @@ -200,24 +204,44 @@ public final class DirectionalNavigationAdapter { return false } - let options = NavigatorGoOptions(animated: animatedTransition) - switch event.key { case .arrowUp where keyboardPolicy.handleArrowKeys: - return await navigator.goBackward(options: options) + return await goBackward(in: navigator) case .arrowDown where keyboardPolicy.handleArrowKeys: - return await navigator.goForward(options: options) + return await goForward(in: navigator) case .arrowLeft where keyboardPolicy.handleArrowKeys: - return await navigator.goLeft(options: options) + return await goLeft(in: navigator) case .arrowRight where keyboardPolicy.handleArrowKeys: - return await navigator.goRight(options: options) + return await goRight(in: navigator) case .space where keyboardPolicy.handleSpaceKey: - return await navigator.goForward(options: options) + return await goForward(in: navigator) default: return false } } + @MainActor private func goBackward(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goBackward(options: $0) } + } + + @MainActor private func goForward(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goForward(options: $0) } + } + + @MainActor private func goLeft(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goLeft(options: $0) } + } + + @MainActor private func goRight(in navigator: VisualNavigator) async -> Bool { + await go { await navigator.goRight(options: $0) } + } + + @MainActor private func go(_ action: (NavigatorGoOptions) async -> Bool) async -> Bool { + onNavigation() + let options = NavigatorGoOptions(animated: animatedTransition) + return await action(options) + } + @available(*, deprecated, message: "Use the new initializer without the navigator parameter and call `bind(to:)`. See the migration guide.") public init( navigator: VisualNavigator, @@ -240,6 +264,7 @@ public final class DirectionalNavigationAdapter { ) keyboardPolicy = KeyboardPolicy() self.animatedTransition = animatedTransition + onNavigation = {} } @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md index 518fe6289e..a5588f5585 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadMe.md @@ -27,10 +27,6 @@ Disabled user settings: - `hyphens`; - `letter-spacing`. -Added user settings: - -- `font-variant-ligatures` (mapped to `--USER__ligatures` CSS variable). - ## CJK Chinese, Japanese, Korean, and Mongolian can be either written `horizontal-tb` or `vertical-*`. Consequently, there are stylesheets for horizontal and vertical writing modes. @@ -52,6 +48,7 @@ Disabled user settings: - `text-align`; - `hyphens`; +- `ligatures`; - paragraphs’ indent; - `word-spacing`. @@ -88,6 +85,7 @@ Disabled user settings: - `column-count` (number of columns); - `text-align`; - `hyphens`; +- `ligatures`; - paragraphs’ indent; - `word-spacing`. diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css index 9f837f48a1..2aa47e209e 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -64,11 +71,14 @@ body{ width:100%; max-width:var(--RS__defaultLineLength) !important; - padding:0 var(--RS__pageGutter) !important; margin:0 auto !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:0 var(--RS__pageGutter) !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -133,145 +143,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -346,7 +217,15 @@ body{ } :root[style*="--USER__textAlign"] body, -:root[style*="--USER__textAlign"] p:not(blockquote p):not(figcaption p):not(hgroup p), +:root[style*="--USER__textAlign"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +), :root[style*="--USER__textAlign"] li, :root[style*="--USER__textAlign"] dd{ text-align:var(--USER__textAlign) !important; @@ -383,39 +262,28 @@ body{ font-family:revert !important; } -:root[style*="AccessibleDfA"]{ - font-family:AccessibleDfA, Verdana, Tahoma, "Trebuchet MS", sans-serif !important; -} - -:root[style*="IA Writer Duospace"]{ - font-family:"IA Writer Duospace", Menlo, "DejaVu Sans Mono", "Bitstream Vera Sans Mono", Courier, monospace !important; -} - -:root[style*="AccessibleDfA"],:root[style*="IA Writer Duospace"], :root[style*="readium-a11y-on"]{ font-style:normal !important; font-weight:normal !important; } -:root[style*="AccessibleDfA"] *:not(code):not(var):not(kbd):not(samp),:root[style*="IA Writer Duospace"] *:not(code):not(var):not(kbd):not(samp), -:root[style*="readium-a11y-on"] *:not(code):not(var):not(kbd):not(samp){ +:root[style*="readium-a11y-on"] body *:not(code):not(var):not(kbd):not(samp){ font-family:inherit !important; font-style:inherit !important; font-weight:inherit !important; } -:root[style*="AccessibleDfA"] *,:root[style*="IA Writer Duospace"] *, -:root[style*="readium-a11y-on"] *{ +:root[style*="readium-a11y-on"] body *:not(a){ text-decoration:none !important; +} + +:root[style*="readium-a11y-on"] body *{ font-variant-caps:normal !important; font-variant-numeric:normal !important; font-variant-position:normal !important; } -:root[style*="AccessibleDfA"] sup,:root[style*="IA Writer Duospace"] sup, :root[style*="readium-a11y-on"] sup, -:root[style*="AccessibleDfA"] sub, -:root[style*="IA Writer Duospace"] sub, :root[style*="readium-a11y-on"] sub{ font-size:1rem !important; vertical-align:baseline !important; @@ -425,10 +293,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ @@ -456,7 +350,15 @@ body{ margin-bottom:var(--USER__paraSpacing) !important; } -:root[style*="--USER__paraIndent"] p{ +:root[style*="--USER__paraIndent"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +){ text-indent:var(--USER__paraIndent) !important; } @@ -494,6 +396,14 @@ body{ font-variant:none; } +:root[style*="--USER__ligatures"]{ + font-variant-ligatures:var(--USER__ligatures) !important; +} + +:root[style*="--USER__ligatures"] *{ + font-variant-ligatures:inherit !important; +} + :root[style*="--USER__fontWeight"] body{ font-weight:var(--USER__fontWeight) !important; } diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css index 99ea6292fe..85c9f00a23 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -218,34 +225,6 @@ math{ --RS__lineHeightCompensation:1.167; } -@font-face{ - font-family:AccessibleDfA; - font-style:normal; - font-weight:normal; - src:local("AccessibleDfA"), url("fonts/AccessibleDfA-Regular.woff2") format("woff2"), url("fonts/AccessibleDfA-Regular.woff") format("woff"); -} - -@font-face{ - font-family:AccessibleDfA; - font-style:normal; - font-weight:bold; - src:local("AccessibleDfA"), url("fonts/AccessibleDfA-Bold.woff2") format("woff2"); -} - -@font-face{ - font-family:AccessibleDfA; - font-style:italic; - font-weight:normal; - src:local("AccessibleDfA"), url("fonts/AccessibleDfA-Italic.woff2") format("woff2"); -} - -@font-face{ - font-family:"IA Writer Duospace"; - font-style:normal; - font-weight:normal; - src:local("iAWriterDuospace-Regular"), url("fonts/iAWriterDuospace-Regular.ttf") format("truetype"); -} - body{ widows:2; orphans:2; @@ -421,6 +400,15 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css index a95fcd4bc7..1d2df4acc0 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css index f4bcc17f67..e48936f8cf 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -64,11 +71,14 @@ body{ width:100%; max-width:var(--RS__defaultLineLength) !important; - padding:0 var(--RS__pageGutter) !important; margin:0 auto !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:0 var(--RS__pageGutter) !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -133,145 +143,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -353,10 +224,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css index 6a99eca103..85c9f00a23 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -393,6 +400,15 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css index 83c9fce6be..f85fd8b9df 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-horizontal/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css index 601def5e8d..cdd18565d9 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -75,11 +82,14 @@ body{ width:100%; max-height:var(--RS__defaultLineLength) !important; - padding:var(--RS__pageGutter) 0 !important; margin:auto 0 !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:var(--RS__pageGutter) 0 !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -140,145 +150,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -338,10 +209,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css index 004a75a63f..2ed2433215 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -393,6 +400,16 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; @@ -402,5 +419,5 @@ audio{ table{ max-height:var(--RS__maxMediaWidth); - box-sizing:var(--RS__boxSizingTable) + box-sizing:var(--RS__boxSizingTable); } \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css index 065c8c1b42..2d26579faf 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css index 1a2c8fa2ce..c5a1bef48c 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -20,7 +27,7 @@ --RS__pageGutter:0; - --RS__defaultLineLength:40rem; + --RS__defaultLineLength:100%; --RS__colGap:0; @@ -64,11 +71,14 @@ body{ width:100%; max-width:var(--RS__defaultLineLength) !important; - padding:0 var(--RS__pageGutter) !important; margin:0 auto !important; box-sizing:border-box; } +:root:not([style*="readium-scroll-on"]) body{ + padding:0 var(--RS__pageGutter) !important; +} + :root:not([style*="readium-noOverflow-on"]) body{ overflow:hidden; } @@ -133,145 +143,6 @@ body{ padding-right:var(--RS__scrollPaddingRight) !important; } -:root[style*="readium-night-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#0099E5; - - --RS__linkColor:#63caff; - - --RS__textColor:#FEFEFE; - - --RS__backgroundColor:#000000; -} - -:root[style*="readium-night-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; - border-color:currentcolor !important; -} - -:root[style*="readium-night-on"] svg text{ - fill:currentcolor !important; - stroke:none !important; -} - -:root[style*="readium-night-on"] a:link, -:root[style*="readium-night-on"] a:link *{ - color:var(--RS__linkColor) !important; -} - -:root[style*="readium-night-on"] a:visited, -:root[style*="readium-night-on"] a:visited *{ - color:var(--RS__visitedColor) !important; -} - -:root[style*="readium-night-on"] img[class*="gaiji"], -:root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, -:root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:invert(100%); - filter:invert(100%); -} - -:root[style*="readium-sepia-on"]{ - - --RS__selectionTextColor:inherit; - - --RS__selectionBackgroundColor:#b4d8fe; - - --RS__visitedColor:#551A8B; - - --RS__linkColor:#0000EE; - - --RS__textColor:#121212; - - --RS__backgroundColor:#faf4e8; -} - -:root[style*="readium-sepia-on"] *:not(a){ - color:inherit !important; - background-color:transparent !important; -} - -:root[style*="readium-sepia-on"] a:link, -:root[style*="readium-sepia-on"] a:link *{ - color:var(--RS__linkColor); -} - -:root[style*="readium-sepia-on"] a:visited, -:root[style*="readium-sepia-on"] a:visited *{ - color:var(--RS__visitedColor); -} - -@media screen and (-ms-high-contrast: active){ - - :root{ - color:windowText !important; - background-color:window !important; - } - - :root :not(#\#):not(#\#):not(#\#), - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) - :root :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#) :not(#\#):not(#\#):not(#\#){ - color:inherit !important; - background-color:inherit !important; - } - - .readiumCSS-mo-active-default{ - color:highlightText !important; - background-color:highlight !important; - } -} - -@media screen and (-ms-high-contrast: white-on-black){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (inverted-colors){ - - :root[style*="readium-night-on"] img[class*="gaiji"], - :root[style*="readium-night-on"] *[epub\:type~="titlepage"] img:only-child, - :root[style*="readium-night-on"] *[epub|type~="titlepage"] img:only-child{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-invert-on"] img{ - -webkit-filter:none !important; - filter:none !important; - } - - :root[style*="readium-night-on"][style*="readium-darken-on"][style*="readium-invert-on"] img{ - -webkit-filter:brightness(80%); - filter:brightness(80%); - } -} - -@media screen and (monochrome){ -} - -@media screen and (prefers-reduced-motion){ -} - :root[style*="--USER__backgroundColor"]{ background-color:var(--USER__backgroundColor) !important; } @@ -346,7 +217,15 @@ body{ } :root[style*="--USER__textAlign"] body, -:root[style*="--USER__textAlign"] p:not(blockquote p):not(figcaption p):not(hgroup p), +:root[style*="--USER__textAlign"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +), :root[style*="--USER__textAlign"] li, :root[style*="--USER__textAlign"] dd{ text-align:var(--USER__textAlign) !important; @@ -367,10 +246,36 @@ body{ zoom:var(--USER__fontSize) !important; } -:root[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ +:root:not([style*="readium-deprecatedFontSize-on"])[style*="readium-iOSPatch-on"][style*="--USER__fontSize"] body{ -webkit-text-size-adjust:var(--USER__fontSize) !important; } +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] table{ + zoom:calc(100% / var(--USER__fontSize)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] th{ + zoom:var(--USER__fontSize) !important; + } +} + @supports not (zoom: 1){ :root[style*="--USER__fontSize"]{ @@ -398,7 +303,15 @@ body{ margin-bottom:var(--USER__paraSpacing) !important; } -:root[style*="--USER__paraIndent"] p{ +:root[style*="--USER__paraIndent"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +){ text-indent:var(--USER__paraIndent) !important; } diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css index 6a99eca103..85c9f00a23 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); @@ -393,6 +400,15 @@ img, svg|svg, video{ break-inside:avoid; } +@supports (zoom: 1) and (not ((-webkit-column-axis: horizontal) and (-webkit-column-progression: normal))){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] img, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] svg|svg, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-deprecatedFontSize-on"]):not([style*="readium-iOSPatch-on"])[style*="--USER__fontSize"] video{ + zoom:calc(100% / var(--USER__fontSize)); + } +} + audio{ max-width:100%; -webkit-column-break-inside:avoid; diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css index f2702105b7..8a0a18760e 100644 --- a/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css @@ -1,10 +1,17 @@ -/* - * Readium CSS (v. 2.0.0-beta.18) - * Developers: Jiminy Panoz - * Copyright (c) 2017. Readium Foundation. All rights reserved. +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. * Use of this source code is governed by a BSD-style license which is detailed in the * LICENSE file present in the project repository where this source code is maintained. -*/ + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ @namespace url("http://www.w3.org/1999/xhtml"); diff --git a/Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css b/Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css new file mode 100644 index 0000000000..5b38e8b580 --- /dev/null +++ b/Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css @@ -0,0 +1,275 @@ +/*! + * Readium CSS v.2.0.0 + * Copyright (c) 2017–2026. Readium Foundation. All rights reserved. + * Use of this source code is governed by a BSD-style license which is detailed in the + * LICENSE file present in the project repository where this source code is maintained. + * Core maintainer: Jiminy Panoz + * Contributors: + * Daniel Weck + * Hadrien Gardeur + * Innovimax + * L. Le Meur + * Mickaël Menu + * k_taka + */ + +:root[style*="--USER__textAlign"]{ + text-align:var(--USER__textAlign); +} + +:root[style*="--USER__textAlign"] body, +:root[style*="--USER__textAlign"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +), +:root[style*="--USER__textAlign"] li, +:root[style*="--USER__textAlign"] dd{ + text-align:var(--USER__textAlign) !important; + -moz-text-align-last:auto !important; + -epub-text-align-last:auto !important; + text-align-last:auto !important; +} + +:root[style*="--USER__bodyHyphens"]{ + -webkit-hyphens:var(--USER__bodyHyphens) !important; + -moz-hyphens:var(--USER__bodyHyphens) !important; + -ms-hyphens:var(--USER__bodyHyphens) !important; + -epub-hyphens:var(--USER__bodyHyphens) !important; + hyphens:var(--USER__bodyHyphens) !important; +} + +:root[style*="--USER__bodyHyphens"] body, +:root[style*="--USER__bodyHyphens"] p, +:root[style*="--USER__bodyHyphens"] li, +:root[style*="--USER__bodyHyphens"] div, +:root[style*="--USER__bodyHyphens"] dd{ + -webkit-hyphens:inherit; + -moz-hyphens:inherit; + -ms-hyphens:inherit; + -epub-hyphens:inherit; + hyphens:inherit; +} + +:root[style*="--USER__fontFamily"]{ + font-family:var(--USER__fontFamily) !important; +} + +:root[style*="--USER__fontFamily"] *{ + font-family:revert !important; +} + +:root[style*="readium-a11y-on"]{ + font-style:normal !important; + font-weight:normal !important; +} + +:root[style*="readium-a11y-on"] body *:not(code):not(var):not(kbd):not(samp){ + font-family:inherit !important; + font-style:inherit !important; + font-weight:inherit !important; +} + +:root[style*="readium-a11y-on"] body *:not(a){ + text-decoration:none !important; +} + +:root[style*="readium-a11y-on"] body *{ + font-variant-caps:normal !important; + font-variant-numeric:normal !important; + font-variant-position:normal !important; +} + +:root[style*="readium-a11y-on"] sup, +:root[style*="readium-a11y-on"] sub{ + font-size:1rem !important; + vertical-align:baseline !important; +} + +:root:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] body{ + zoom:var(--USER__zoom) !important; +} + +:root[style*="readium-iOSPatch-on"][style*="--USER__zoom"] body{ + -webkit-text-size-adjust:var(--USER__zoom) !important; +} + +@supports selector(figure:has(> img)){ + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> img), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> video), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> svg), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> canvas), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> iframe), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figure:has(> audio), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> img:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> video:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> svg:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> canvas:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> iframe:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] div:has(> audio:only-child), + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] table{ + zoom:calc(100% / var(--USER__zoom)) !important; + } + + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] figcaption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] caption, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] td, + :root[style*="readium-experimentalZoom-on"]:not([style*="readium-iOSPatch-on"])[style*="--USER__zoom"] th{ + zoom:var(--USER__zoom) !important; + } +} + +:root[style*="--USER__lineHeight"]{ + line-height:var(--USER__lineHeight) !important; +} + +:root[style*="--USER__lineHeight"] body, +:root[style*="--USER__lineHeight"] p, +:root[style*="--USER__lineHeight"] li, +:root[style*="--USER__lineHeight"] div{ + line-height:inherit; +} + +:root[style*="--USER__paraSpacing"] p{ + margin-top:var(--USER__paraSpacing) !important; + margin-bottom:var(--USER__paraSpacing) !important; +} + +:root[style*="--USER__paraIndent"] p:not( + blockquote p, + figcaption p, + header p, + hgroup p, + :root[style*="readium-experimentalHeaderFiltering-on"] p[class*="title"], + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > h1 + p, + :root[style*="readium-experimentalHeaderFiltering-on"] div:has(+ *) > p:has(+ h1) +){ + text-indent:var(--USER__paraIndent) !important; +} + +:root[style*="--USER__paraIndent"] p *, +:root[style*="--USER__paraIndent"] p:first-letter{ + text-indent:0 !important; +} + +:root[style*="--USER__wordSpacing"] h1, +:root[style*="--USER__wordSpacing"] h2, +:root[style*="--USER__wordSpacing"] h3, +:root[style*="--USER__wordSpacing"] h4, +:root[style*="--USER__wordSpacing"] h5, +:root[style*="--USER__wordSpacing"] h6, +:root[style*="--USER__wordSpacing"] p, +:root[style*="--USER__wordSpacing"] li, +:root[style*="--USER__wordSpacing"] div, +:root[style*="--USER__wordSpacing"] dt, +:root[style*="--USER__wordSpacing"] dd{ + word-spacing:var(--USER__wordSpacing); +} + +:root[style*="--USER__letterSpacing"] h1, +:root[style*="--USER__letterSpacing"] h2, +:root[style*="--USER__letterSpacing"] h3, +:root[style*="--USER__letterSpacing"] h4, +:root[style*="--USER__letterSpacing"] h5, +:root[style*="--USER__letterSpacing"] h6, +:root[style*="--USER__letterSpacing"] p, +:root[style*="--USER__letterSpacing"] li, +:root[style*="--USER__letterSpacing"] div, +:root[style*="--USER__letterSpacing"] dt, +:root[style*="--USER__letterSpacing"] dd{ + letter-spacing:var(--USER__letterSpacing); + font-variant:none; +} + +:root[style*="--USER__fontWeight"] body{ + font-weight:var(--USER__fontWeight) !important; +} + +:root[style*="--USER__fontWeight"] b, +:root[style*="--USER__fontWeight"] strong{ + font-weight:bolder; +} + +:root[style*="--USER__fontWidth"] body{ + font-stretch:var(--USER__fontWidth) !important; +} + +:root[style*="--USER__fontOpticalSizing"] body{ + font-optical-sizing:var(--USER__fontOpticalSizing) !important; +} + +:root[style*="readium-noRuby-on"] body rt, +:root[style*="readium-noRuby-on"] body rp{ + display:none; +} + +:root[style*="--USER__ligatures"]{ + font-variant-ligatures:var(--USER__ligatures) !important; +} + +:root[style*="--USER__ligatures"] *{ + font-variant-ligatures:inherit !important; +} + +:root[style*="readium-iPadOSPatch-on"] body{ + -webkit-text-size-adjust:none; +} + +:root[style*="readium-iPadOSPatch-on"] p, +:root[style*="readium-iPadOSPatch-on"] h1, +:root[style*="readium-iPadOSPatch-on"] h2, +:root[style*="readium-iPadOSPatch-on"] h3, +:root[style*="readium-iPadOSPatch-on"] h4, +:root[style*="readium-iPadOSPatch-on"] h5, +:root[style*="readium-iPadOSPatch-on"] h6, +:root[style*="readium-iPadOSPatch-on"] li, +:root[style*="readium-iPadOSPatch-on"] th, +:root[style*="readium-iPadOSPatch-on"] td, +:root[style*="readium-iPadOSPatch-on"] dt, +:root[style*="readium-iPadOSPatch-on"] dd, +:root[style*="readium-iPadOSPatch-on"] pre, +:root[style*="readium-iPadOSPatch-on"] address, +:root[style*="readium-iPadOSPatch-on"] details, +:root[style*="readium-iPadOSPatch-on"] summary, +:root[style*="readium-iPadOSPatch-on"] figcaption, +:root[style*="readium-iPadOSPatch-on"] div:not(:has(p, h1, h2, h3, h4, h5, h6, li, th, td, dt, dd, pre, address, aside, details, figcaption, summary)), +:root[style*="readium-iPadOSPatch-on"] aside:not(:has(p, h1, h2, h3, h4, h5, h6, li, th, td, dt, dd, pre, address, aside, details, figcaption, summary)){ + -webkit-text-zoom:reset; +} + +:root[style*="readium-iPadOSPatch-on"] abbr, +:root[style*="readium-iPadOSPatch-on"] b, +:root[style*="readium-iPadOSPatch-on"] bdi, +:root[style*="readium-iPadOSPatch-on"] bdo, +:root[style*="readium-iPadOSPatch-on"] cite, +:root[style*="readium-iPadOSPatch-on"] code, +:root[style*="readium-iPadOSPatch-on"] dfn, +:root[style*="readium-iPadOSPatch-on"] em, +:root[style*="readium-iPadOSPatch-on"] i, +:root[style*="readium-iPadOSPatch-on"] kbd, +:root[style*="readium-iPadOSPatch-on"] mark, +:root[style*="readium-iPadOSPatch-on"] q, +:root[style*="readium-iPadOSPatch-on"] rp, +:root[style*="readium-iPadOSPatch-on"] rt, +:root[style*="readium-iPadOSPatch-on"] ruby, +:root[style*="readium-iPadOSPatch-on"] s, +:root[style*="readium-iPadOSPatch-on"] samp, +:root[style*="readium-iPadOSPatch-on"] small, +:root[style*="readium-iPadOSPatch-on"] span, +:root[style*="readium-iPadOSPatch-on"] strong, +:root[style*="readium-iPadOSPatch-on"] sub, +:root[style*="readium-iPadOSPatch-on"] sup, +:root[style*="readium-iPadOSPatch-on"] time, +:root[style*="readium-iPadOSPatch-on"] u, +:root[style*="readium-iPadOSPatch-on"] var{ + -webkit-text-zoom:normal; +} + +:root[style*="readium-iPadOSPatch-on"] p:not(:has(b, cite, em, i, q, s, small, span, strong)):first-line{ + -webkit-text-zoom:normal; +} \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js index a1f95ec215..6eb8689fac 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();var e=function(t){var e=null,n=null,i=null,o=document.getElementById("page");o.addEventListener("load",(function(){var t=o.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var n,i=/(\w+) *= *([^\s,]+)/g,l={};n=i.exec(t.content);)l[n[1]]=n[2];var a=Number.parseFloat(l.width),s=Number.parseFloat(l.height);a&&s&&(e={width:a,height:s},r())}}));var l=o.closest(".viewport");function r(){if(e&&n&&i){o.style.width=e.width+"px",o.style.height=e.height+"px",o.style.marginTop=i.top-i.bottom+"px";var t=n.width/e.width,l=n.height/e.height,r=Math.min(t,l);document.querySelector("meta[name=viewport]").content="initial-scale="+r+", minimum-scale="+r}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,o.addEventListener("load",(function i(){o.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,o.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),o.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,e=null,o.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return o.contentWindow.eval(t)},setViewport:function(t,e){n=t,i=e,r()},show:function(){l.style.display="block"},hide:function(){l.style.display="none"}}}();t.g.spread={load:function(t){0!==t.length&&e.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,n){var i;if("#"===t||""===t||(null===(i=e.link)||void 0===i?void 0:i.href)===t)return e.eval(n)},setViewport:function(t,n){e.setViewport(t,n)}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};var i=function(t,i){var l=null,o=null,a=null,r=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById("page");u.addEventListener("load",(function(){var t,e,n;l=null!==(t=null!==(e=function(){var t=u.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var l=Number.parseFloat(i.width),o=Number.parseFloat(i.height);return l&&o?{width:l,height:o}:null}())&&void 0!==e?e:(n=u.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:o,d()}));var c=u.closest(".viewport");function d(){if(l&&o&&a){u.style.width=l.width+"px",u.style.height=l.height+"px";var t,i=o.width/l.width,c=o.height/l.height;t=r===n.WIDTH?i:Math.min(i,c);var d=l.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&d>o.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var m=a.top-a.bottom;u.style.top="calc(50% + "+m+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}function h(t){u.src.startsWith("blob:")&&URL.revokeObjectURL(u.src),u.src=t}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,u.addEventListener("load",(function i(){u.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,u.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)}));var i=function(t){if((e=t.link.type)&&e.startsWith("image/")&&!e.includes("svg")){let e=function(t,e){let n=document.implementation.createHTMLDocument(""),i=n.createElement("meta");i.name="viewport",i.content="width=device-width, height=device-height",n.head.appendChild(i);let l=n.createElement("style");l.textContent="body { margin: 0; }\nimg { display: block; width: 100%; height: 100%; object-fit: contain; }",n.head.appendChild(l);let o=n.createElement("img");return o.src=t,e&&(o.alt=e),n.body.appendChild(o),"\n"+n.documentElement.outerHTML}(t.url,t.link.title),n=new Blob([e],{type:"text/html"});return URL.createObjectURL(n)}return t.url;var e}(t);h(i)}else e&&e()},reset:function(){this.link&&(this.link=null,l=null,h("about:blank"))},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){o=t,a=e,Object.values(n).includes(i)&&(r=i),d()},show:function(){c.style.display="block"},hide:function(){c.style.display="none"}}}(0,e.SINGLE);t.g.spread={load:function(t){0!==t.length&&i.load(t[0],(function(){webkit.messageHandlers.spreadLoaded.postMessage({})}))},eval:function(t,e){var n;if("#"===t||""===t||(null===(n=i.link)||void 0===n?void 0:n.href)===t)return i.eval(e)},setViewport:function(t,e,n){i.setViewport(t,e,n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-one.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js index 8ee445d82e..244222298b 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-two.js @@ -1,2 +1,2 @@ -(()=>{"use strict";var t={};function e(t){var e=null,n=null,i=null,o=document.getElementById(t);o.addEventListener("load",(function(){var t=o.contentWindow.document.querySelector("meta[name=viewport]");if(t){for(var n,i=/(\w+) *= *([^\s,]+)/g,r={};n=i.exec(t.content);)r[n[1]]=n[2];var a=Number.parseFloat(r.width),s=Number.parseFloat(r.height);a&&s&&(e={width:a,height:s},l())}}));var r=o.closest(".viewport");function l(){if(e&&n&&i){o.style.width=e.width+"px",o.style.height=e.height+"px",o.style.marginTop=i.top-i.bottom+"px";var t=n.width/e.width,r=n.height/e.height,l=Math.min(t,r);document.querySelector("meta[name=viewport]").content="initial-scale="+l+", minimum-scale="+l}}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,o.addEventListener("load",(function i(){o.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,o.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)})),o.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,e=null,o.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return o.contentWindow.eval(t)},setViewport:function(t,e){n=t,i=e,l()},show:function(){r.style.display="block"},hide:function(){r.style.display="none"}}}t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();var n={left:e("page-left"),right:e("page-right"),center:e("page-center")};function i(t){for(const e in n)t(n[e])}t.g.spread={load:function(t){function e(){n.left.isLoading||n.right.isLoading||n.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}i((function(t){t.reset(),t.hide()}));for(const i in t){const o=t[i],r=n[o.page];r&&(r.show(),r.load(o,e))}},eval:function(t,e){if("#"===t||""===t)i((function(t){t.eval(e)}));else{var o=function(t){for(const o in n){var e,i=n[o];if((null===(e=i.link)||void 0===e?void 0:e.href)===t)return i}return null}(t);if(o)return o.eval(e)}},setViewport:function(t,e){t.width/=2,n.left.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:e.left}),n.right.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:0}),n.center.setViewport(t,{top:e.top,right:0,bottom:e.bottom,left:0})}}})(); +(()=>{"use strict";var t={};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}();const e={SINGLE:"single",SPREAD_LEFT:"spread-left",SPREAD_RIGHT:"spread-right",SPREAD_CENTER:"spread-center"},n={AUTO:"auto",PAGE:"page",WIDTH:"width"};function i(t,i){var o=null,l=null,r=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,c=document.getElementById(t);c.addEventListener("load",(function(){var t,e,n;o=null!==(t=null!==(e=function(){var t=c.contentWindow.document.querySelector("meta[name=viewport]");if(!t)return null;for(var e,n=/(\w+) *= *([^\s,]+)/g,i={};e=n.exec(t.content);)i[e[1]]=e[2];var o=Number.parseFloat(i.width),l=Number.parseFloat(i.height);return o&&l?{width:o,height:l}:null}())&&void 0!==e?e:(n=c.contentWindow.document.querySelector("img"))&&n.naturalWidth&&n.naturalHeight?{width:n.naturalWidth,height:n.naturalHeight}:null)&&void 0!==t?t:l,h()}));var u=c.closest(".viewport");function h(){if(o&&l&&r){c.style.width=o.width+"px",c.style.height=o.height+"px";var t,i=l.width/o.width,u=l.height/o.height;t=a===n.WIDTH?i:Math.min(i,u);var h=o.height*t,d=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&h>l.height)c.style.top=r.top+"px",c.style.transform=d?"translateX(-50%)":"none";else{var f=r.top-r.bottom;c.style.top="calc(50% + "+f+"px)",c.style.transform=d?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+t}}function d(t){c.src.startsWith("blob:")&&URL.revokeObjectURL(c.src),c.src=t}return{isLoading:!1,link:null,load:function(t,e){if(t.link&&t.url){var n=this;n.link=t.link,n.isLoading=!0,c.addEventListener("load",(function i(){c.removeEventListener("load",i),setTimeout((function(){n.isLoading=!1,c.contentWindow.eval(`readium.link = ${JSON.stringify(t.link)};`),e&&e()}),100)}));var i=function(t){if((e=t.link.type)&&e.startsWith("image/")&&!e.includes("svg")){let e=function(t,e){let n=document.implementation.createHTMLDocument(""),i=n.createElement("meta");i.name="viewport",i.content="width=device-width, height=device-height",n.head.appendChild(i);let o=n.createElement("style");o.textContent="body { margin: 0; }\nimg { display: block; width: 100%; height: 100%; object-fit: contain; }",n.head.appendChild(o);let l=n.createElement("img");return l.src=t,e&&(l.alt=e),n.body.appendChild(l),"\n"+n.documentElement.outerHTML}(t.url,t.link.title),n=new Blob([e],{type:"text/html"});return URL.createObjectURL(n)}return t.url;var e}(t);d(i)}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,d("about:blank"))},eval:function(t){if(this.link&&!this.isLoading)return c.contentWindow.eval(t)},setViewport:function(t,e,i){l=t,r=e,Object.values(n).includes(i)&&(a=i),h()},show:function(){u.style.display="block"},hide:function(){u.style.display="none"}}}var o={left:i("page-left",e.SPREAD_LEFT),right:i("page-right",e.SPREAD_RIGHT),center:i("page-center",e.SPREAD_CENTER)};function l(t){for(const e in o)t(o[e])}t.g.spread={load:function(t){function e(){o.left.isLoading||o.right.isLoading||o.center.isLoading||webkit.messageHandlers.spreadLoaded.postMessage({})}l((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],l=o[i.page];l&&(l.show(),l.load(i,e))}},eval:function(t,e){if("#"===t||""===t)l((function(t){t.eval(e)}));else{var n=function(t){for(const i in o){var e,n=o[i];if((null===(e=n.link)||void 0===e?void 0:e.href)===t)return n}return null}(t);if(n)return n.eval(e)}},setViewport:function(t,e,n){var i={width:t.width/2,height:t.height};o.left.setViewport(i,{top:e.top,right:0,bottom:e.bottom,left:e.left},n),o.right.setViewport(i,{top:e.top,right:e.right,bottom:e.bottom,left:0},n),o.center.setViewport(t,{top:e.top,right:e.right,bottom:e.bottom,left:e.left},n)}}})(); //# sourceMappingURL=readium-fixed-wrapper-two.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/fxl-spread-two.html b/Sources/Navigator/EPUB/Assets/fxl-spread-two.html index 466977e6a8..07768fe23e 100644 --- a/Sources/Navigator/EPUB/Assets/fxl-spread-two.html +++ b/Sources/Navigator/EPUB/Assets/fxl-spread-two.html @@ -26,8 +26,8 @@ } #viewport-center { - left: 50%; - transform: translateX(-50%); + width: 100%; + left: 0; } .page { diff --git a/Sources/Navigator/EPUB/CSS/CSSLayout.swift b/Sources/Navigator/EPUB/CSS/CSSLayout.swift index 138c691bb6..60a1c1a33f 100644 --- a/Sources/Navigator/EPUB/CSS/CSSLayout.swift +++ b/Sources/Navigator/EPUB/CSS/CSSLayout.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/EPUB/CSS/CSSProperties.swift b/Sources/Navigator/EPUB/CSS/CSSProperties.swift index 9525680574..3881ac6711 100644 --- a/Sources/Navigator/EPUB/CSS/CSSProperties.swift +++ b/Sources/Navigator/EPUB/CSS/CSSProperties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -136,7 +136,7 @@ public struct CSSUserProperties: CSSProperties { /// It impacts font style, weight and variant, text decoration, super and subscripts. public var a11yNormalize: Bool? - // Additional overrides for extensions and adjustments. + /// Additional overrides for extensions and adjustments. public var overrides: [String: String?] public init( @@ -359,7 +359,7 @@ public struct CSSRSProperties: CSSProperties { /// The value can be another variable e.g. var(-RS__monospaceTf). public var codeFontFamily: [String]? - // Additional overrides for extensions and adjustments. + /// Additional overrides for extensions and adjustments. public var overrides: [String: String?] public init( @@ -496,7 +496,9 @@ public enum CSSView: String, CSSConvertible { case paged = "readium-paged-on" case scroll = "readium-scroll-on" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } @available(*, unavailable, message: "Column count is now an integer") @@ -505,14 +507,18 @@ public enum CSSColCount: String, CSSConvertible { case one = "1" case two = "2" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSAppearance: String, CSSConvertible { case night = "readium-night-on" case sepia = "readium-sepia-on" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public struct CSSPercent: CSSConvertible { @@ -522,7 +528,9 @@ public struct CSSPercent: CSSConvertible { self.value = value } - public func css() -> String? { (value * 100).css(unit: "%") } + public func css() -> String? { + (value * 100).css(unit: "%") + } } public protocol CSSColor: CSSConvertible {} @@ -553,7 +561,9 @@ public struct CSSHexColor: CSSColor { self.color = color } - public func css() -> String? { color } + public func css() -> String? { + color + } } public struct CSSIntColor: CSSColor { @@ -580,7 +590,9 @@ public struct CSSCmLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "cm") } + public func css() -> String? { + value.css(unit: "cm") + } } /// Millimeters @@ -591,7 +603,9 @@ public struct CSSMmLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "mm") } + public func css() -> String? { + value.css(unit: "mm") + } } /// Inches @@ -602,7 +616,9 @@ public struct CSSInLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "in") } + public func css() -> String? { + value.css(unit: "in") + } } /// Pixels @@ -613,7 +629,9 @@ public struct CSSPxLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "px") } + public func css() -> String? { + value.css(unit: "px") + } } /// Points @@ -624,7 +642,9 @@ public struct CSSPtLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "pt") } + public func css() -> String? { + value.css(unit: "pt") + } } /// Picas @@ -635,7 +655,9 @@ public struct CSSPcLength: CSSAbsoluteLength { self.value = value } - public func css() -> String? { value.css(unit: "pc") } + public func css() -> String? { + value.css(unit: "pc") + } } public protocol CSSRelativeLength: CSSLength {} @@ -648,7 +670,9 @@ public struct CSSEmLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "em") } + public func css() -> String? { + value.css(unit: "em") + } } /// Relative to the width of the "0" (zero). @@ -659,7 +683,9 @@ public struct CSSChLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "ch") } + public func css() -> String? { + value.css(unit: "ch") + } } /// Relative to font-size of the root element. @@ -670,7 +696,9 @@ public struct CSSRemLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "rem") } + public func css() -> String? { + value.css(unit: "rem") + } } /// Relative to 1% of the width of the viewport. @@ -681,7 +709,9 @@ public struct CSSVwLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vw") } + public func css() -> String? { + value.css(unit: "vw") + } } /// Relative to 1% of the height of the viewport. @@ -692,7 +722,9 @@ public struct CSSVhLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vh") } + public func css() -> String? { + value.css(unit: "vh") + } } /// Relative to 1% of viewport's smaller dimension. @@ -703,7 +735,9 @@ public struct CSSVMinLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vmin") } + public func css() -> String? { + value.css(unit: "vmin") + } } /// Relative to 1% of viewport's larger dimension. @@ -714,7 +748,9 @@ public struct CSSVMaxLength: CSSRelativeLength { self.value = value } - public func css() -> String? { value.css(unit: "vmax") } + public func css() -> String? { + value.css(unit: "vmax") + } } /// Relative to the parent element. @@ -725,7 +761,9 @@ public struct CSSPercentLength: CSSRelativeLength { self.value = value } - public func css() -> String? { (value * 100).css(unit: "%") } + public func css() -> String? { + (value * 100).css(unit: "%") + } } public enum CSSTextAlign: String, CSSConvertible { @@ -734,7 +772,9 @@ public enum CSSTextAlign: String, CSSConvertible { case right case justify - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } /// Line height supports unitless numbers. @@ -756,21 +796,27 @@ public enum CSSHyphens: String, CSSConvertible { case none case auto - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSLigatures: String, CSSConvertible { case none case common = "common-ligatures" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } public enum CSSBoxSizing: String, CSSConvertible { case contentBox = "content-box" case borderBox = "border-box" - public func css() -> String? { rawValue } + public func css() -> String? { + rawValue + } } private extension Double { diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index c9411d0de1..186e7985d2 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -19,19 +19,24 @@ public protocol HTMLFontFamilyDeclaration { /// Injects this font family declaration in the given `html` document. /// - /// Use `servingFile` to convert a file URL into an http one to make a local - /// file available to the web views. - func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String + /// Use `servingFile` to convert a file URL into a URL accessible from the + /// web views. + func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String } /// A type-erasing `HTMLFontFamilyDeclaration` object public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { private let _fontFamily: () -> FontFamily private let _alternates: () -> [FontFamily] - private let _inject: (String, (FileURL) throws -> HTTPURL) throws -> String + private let _inject: (String, (FileURL) throws -> any AbsoluteURL) throws -> String - public var fontFamily: FontFamily { _fontFamily() } - public var alternates: [FontFamily] { _alternates() } + public var fontFamily: FontFamily { + _fontFamily() + } + + public var alternates: [FontFamily] { + _alternates() + } public init(_ declaration: T) { _fontFamily = { declaration.fontFamily } @@ -39,7 +44,7 @@ public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { _inject = { try declaration.inject(in: $0, servingFile: $1) } } - public func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { + public func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { try _inject(html, servingFile) } } @@ -65,7 +70,7 @@ public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration { self.fontFaces = fontFaces } - public func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { + public func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { var injections = try fontFaces.flatMap { try $0.injections(for: html, servingFile: servingFile) } @@ -109,15 +114,17 @@ public struct CSSFontFace { /// Returns a new CSSFontFace after adding a linked source for this font /// face. /// - /// - Parameter preload: Indicates whether this source will be declared for - /// preloading in the HTML using ``. + /// - Parameters: + /// - file: The URL to the font file to be added as a source. + /// - preload: Indicates whether this source will be declared for + /// preloading in the HTML using ``. public func addingSource(file: FileURL, preload: Bool = false) -> Self { var copy = self copy.sources.append((file, preload)) return copy } - func injections(for html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> [HTMLInjection] { + func injections(for html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> [HTMLInjection] { try sources .filter(\.preload) .map { source in @@ -126,7 +133,7 @@ public struct CSSFontFace { } } - func css(for fontFamily: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { + func css(for fontFamily: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { let urls = try sources.map { try servingFile($0.file) } var descriptors: [String: String] = [ "font-family": "\"\(fontFamily)\"", diff --git a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift index 78b7a98ca9..8525eef31e 100644 --- a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift +++ b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -16,7 +16,7 @@ struct ReadiumCSS { var userProperties: CSSUserProperties = .init() /// Base URL of the Readium CSS assets. - var baseURL: HTTPURL + var baseURL: any AbsoluteURL var fontFamilyDeclarations: [AnyHTMLFontFamilyDeclaration] = [] } diff --git a/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift b/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift index adb499a5a7..d34d262555 100644 --- a/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift +++ b/Sources/Navigator/EPUB/DiffableDecoration+HTML.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/EPUB/EPUBExtensions.swift b/Sources/Navigator/EPUB/EPUBExtensions.swift new file mode 100644 index 0000000000..f8d43769ad --- /dev/null +++ b/Sources/Navigator/EPUB/EPUBExtensions.swift @@ -0,0 +1,13 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared + +extension Metadata { + var epubLayout: EPUBLayout { + layout == .fixed ? .fixed : .reflowable + } +} diff --git a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift index 6b529f4b3c..f9f92e5b62 100644 --- a/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBFixedSpreadView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -47,14 +47,14 @@ final class EPUBFixedSpreadView: EPUBSpreadView { scrollView.backgroundColor = UIColor.clear // Loads the wrapper page into the web view. - let spreadFile = "fxl-spread-\(spread.spread ? "two" : "one")" + let spreadFile = "fxl-spread-\(viewModel.spreadEnabled ? "two" : "one")" if let wrapperPageURL = Bundle.module.url(forResource: spreadFile, withExtension: "html", subdirectory: "Assets"), var wrapperPage = try? String(contentsOf: wrapperPageURL, encoding: .utf8) { wrapperPage = wrapperPage.replacingOccurrences( of: "{{ASSETS_URL}}", - with: viewModel.assetsURL.string + with: viewModel.assetsBaseURL.string ) // The publication's base URL is used to make sure we can access the resources through the iframe with JavaScript. @@ -88,11 +88,13 @@ final class EPUBFixedSpreadView: EPUBSpreadView { insets.right = horizontalInsets let viewportSize = bounds.inset(by: insets).size + let fitString = viewModel.settings.fit.rawValue webView.evaluateJavaScript(""" spread.setViewport( {'width': \(Int(viewportSize.width)), 'height': \(Int(viewportSize.height))}, - {'top': \(Int(insets.top)), 'left': \(Int(insets.left)), 'bottom': \(Int(insets.bottom)), 'right': \(Int(insets.right))} + {'top': \(Int(insets.top)), 'left': \(Int(insets.left)), 'bottom': \(Int(insets.bottom)), 'right': \(Int(insets.right))}, + '\(fitString)' ); """) } @@ -105,7 +107,7 @@ final class EPUBFixedSpreadView: EPUBSpreadView { // to be executed before the spread is loaded. let spreadJSON = spread.jsonString( forBaseURL: viewModel.publicationBaseURL, - readingOrder: viewModel.readingOrder + readingProgression: viewModel.readingProgression ) webView.evaluateJavaScript("spread.load(\(spreadJSON));") } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 55ffe5b318..a130a7dfc2 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -43,6 +43,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// Failed to serve the publication or assets with the provided HTTP /// server. + @available(*, deprecated, message: "The HTTP server is no longer needed for the EPUB navigator.") case serverFailure(Error) } @@ -195,16 +196,19 @@ open class EPUBNavigatorViewController: InputObservableViewController, // All events are ignored when loading spreads, except for `loaded` and `load`. case (.loading, .loaded): self = .idle + case (.loading, _): return false case let (.idle, .jump(locator)): self = .jumping(pendingLocator: locator) + case let (.idle, .move(direction)): self = .moving(direction: direction) case (.jumping, .jumped): self = .idle + // Moving or jumping to another locator is not allowed during a pending jump. case (.jumping, .jump), (.jumping, .move): @@ -212,6 +216,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, case (.moving, .moved): self = .idle + // Moving or jumping to another locator is not allowed during a pending move. case (.moving, .jump), (.moving, .move): @@ -266,9 +271,13 @@ open class EPUBNavigatorViewController: InputObservableViewController, private var positionsByReadingOrder: [[Locator]] = [] private let viewModel: EPUBNavigatorViewModel - public var publication: Publication { viewModel.publication } + public var publication: Publication { + viewModel.publication + } - var config: Configuration { viewModel.config } + var config: Configuration { + viewModel.config + } /// Creates a new instance of `EPUBNavigatorViewController`. /// @@ -279,14 +288,11 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// - readingOrder: Custom order of resources to display. Used for example /// to display a non-linear resource on its own. /// - config: Additional navigator configuration. - /// - httpServer: HTTP server used to serve the publication resources to - /// the web views. public convenience init( publication: Publication, initialLocation: Locator?, readingOrder: [Link]? = nil, - config: Configuration = .init(), - httpServer: HTTPServer + config: Configuration = .init() ) throws { precondition(readingOrder.map { !$0.isEmpty } ?? true) @@ -294,16 +300,16 @@ open class EPUBNavigatorViewController: InputObservableViewController, throw EPUBError.publicationRestricted } - let viewModel = try EPUBNavigatorViewModel( + let viewModel = EPUBNavigatorViewModel( publication: publication, - config: config, - httpServer: httpServer + readingOrder: readingOrder ?? publication.readingOrder, + config: config ) self.init( viewModel: viewModel, initialLocation: initialLocation, - readingOrder: readingOrder ?? publication.readingOrder, + readingOrder: viewModel.readingOrder, positionsByReadingOrder: // Positions and total progression only make sense in the context // of the publication's actual reading order. Therefore when @@ -314,6 +320,23 @@ open class EPUBNavigatorViewController: InputObservableViewController, ) } + /// Creates a new instance of `EPUBNavigatorViewController`. + @available(*, deprecated, message: "The HTTP server is no longer needed for the EPUB navigator.") + public convenience init( + publication: Publication, + initialLocation: Locator?, + readingOrder: [Link]? = nil, + config: Configuration = .init(), + httpServer: HTTPServer + ) throws { + try self.init( + publication: publication, + initialLocation: initialLocation, + readingOrder: readingOrder, + config: config + ) + } + private init( viewModel: EPUBNavigatorViewModel, initialLocation: Locator?, @@ -390,9 +413,15 @@ open class EPUBNavigatorViewController: InputObservableViewController, @objc private func didBecomeActive() { isActive = true + // The device may have rotated since the last time the app was active. + // We may need to refresh the spreads in this situation. Unfortunately, + // the `viewWillTransition(to:with:)` API is called before we receive + // the `didBecomeActive` notification, so we cannot rely on it here. + viewModel.viewSizeWillChange(view.bounds.size) + if needsReloadSpreadsOnActive { needsReloadSpreadsOnActive = false - reloadSpreads(force: true) + reloadSpreads() } } @@ -413,7 +442,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, applySettings() - _reloadSpreads(force: true) + _reloadSpreads() onInitializedCallbacks.complete() } @@ -449,10 +478,8 @@ open class EPUBNavigatorViewController: InputObservableViewController, override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - viewModel.viewSizeWillChange(size) - - coordinator.animate(alongsideTransition: nil) { [weak self] _ in - self?.reloadSpreads(force: false) + if isActive { + viewModel.viewSizeWillChange(size) } } @@ -551,7 +578,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, } paginationView.isScrollEnabled = isPaginationViewScrollingEnabled - reloadSpreads(force: true) + reloadSpreads() } private var spreads: [EPUBSpread] = [] @@ -563,25 +590,31 @@ open class EPUBNavigatorViewController: InputObservableViewController, private var needsReloadSpreadsOnActive = false - private func reloadSpreads(force: Bool) { + private func reloadSpreads() { guard state != .initializing, - isViewLoaded, - isActive + isViewLoaded else { return } - _reloadSpreads(force: force) + guard isActive else { + // If we reload the spreads while the app is in the background, the + // web view will reset to progression 0 instead of the current one. + // We need to wait for the application to return to the foreground + // to maintain the current location. + needsReloadSpreadsOnActive = true + return + } + + _reloadSpreads() } - private func _reloadSpreads(force: Bool) { + private func _reloadSpreads() { let locator = currentLocation guard let paginationView = paginationView, - // Already loaded with the expected amount of spreads? - force || spreads.first?.spread != viewModel.spreadEnabled, on(.load(locator)) else { return @@ -591,7 +624,8 @@ open class EPUBNavigatorViewController: InputObservableViewController, for: publication, readingOrder: readingOrder, readingProgression: viewModel.readingProgression, - spread: viewModel.spreadEnabled + spread: viewModel.spreadEnabled, + offsetFirstPage: viewModel.offsetFirstPage ) let initialIndex: ReadingOrder.Index = { @@ -905,7 +939,9 @@ open class EPUBNavigatorViewController: InputObservableViewController, // MARK: - Configurable - public var settings: EPUBSettings { viewModel.settings } + public var settings: EPUBSettings { + viewModel.settings + } public func submitPreferences(_ preferences: EPUBPreferences) { viewModel.submitPreferences(preferences) @@ -1031,7 +1067,16 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { // the application's bars. var insets = view.window?.safeAreaInsets ?? .zero - if publication.metadata.layout != .fixed { + switch publication.metadata.epubLayout { + case .fixed: + // With iPadOS and macOS, we aim to display content edge-to-edge + // since there are no physical notches or Dynamic Island like on the + // iPhone. + if UIDevice.current.userInterfaceIdiom != .phone { + insets = .zero + } + + case .reflowable: let configInset = config.contentInset(for: view.traitCollection.verticalSizeClass) insets.top = max(insets.top, configInset.top) insets.bottom = max(insets.bottom, configInset.bottom) @@ -1230,15 +1275,7 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { } func spreadViewDidTerminate() { - if !isActive { - // If we reload the spreads while the app is in the background, the - // web view will reset to progression 0 instead of the current one. - // We need to wait for the application to return to the foreground - // to maintain the current location. - needsReloadSpreadsOnActive = true - } else { - reloadSpreads(force: true) - } + reloadSpreads() } } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 6e93fecf8b..74b1d65049 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -20,79 +20,72 @@ enum EPUBScriptScope { case resource(href: AnyURL) } -final class EPUBNavigatorViewModel: Loggable { - enum Error: Swift.Error { - case noHTTPServer - } - +@MainActor final class EPUBNavigatorViewModel: Loggable { let publication: Publication let config: EPUBNavigatorViewController.Configuration let editingActions: EditingActionsController - private let httpServer: HTTPServer? - private let publicationEndpoint: HTTPServerEndpoint? - private(set) var publicationBaseURL: HTTPURL! - let assetsURL: HTTPURL - weak var delegate: EPUBNavigatorViewModelDelegate? - /// Local file URL associated to the HTTP URL used to serve the file on the - /// `httpServer`. This is used to serve custom font files, for example. - @Atomic private var servedFiles: [FileURL: HTTPURL] = [:] + /// The base URL for the publication resources. + private(set) var publicationBaseURL: AbsoluteURL! + + /// The base URL for Readium assets (CSS, scripts, etc.) and fonts. + let assetsBaseURL: any AbsoluteURL - var readingOrder: ReadingOrder { publication.readingOrder } + /// The server used to serve publication resources and static assets to + /// the web view. + let server: WebViewServer + + /// Format sniffer used to infer the media type of resources served with + /// the `server`. + let formatSniffer: FormatSniffer + + weak var delegate: EPUBNavigatorViewModelDelegate? + + let readingOrder: ReadingOrder convenience init( publication: Publication, - config: EPUBNavigatorViewController.Configuration, - httpServer: HTTPServer - ) throws { - let uuidEndpoint: HTTPServerEndpoint = UUID().uuidString - let publicationEndpoint: HTTPServerEndpoint? - if publication.baseURL != nil { - publicationEndpoint = nil - } else { - publicationEndpoint = uuidEndpoint - } + readingOrder: ReadingOrder, + config: EPUBNavigatorViewController.Configuration + ) { + let assetsDirectory = Bundle.module.resourceURL!.fileURL! + .appendingPath("Assets/Static", isDirectory: true) - try self.init( + let formatSniffer = DefaultFormatSniffer() + let server = WebViewServer(scheme: "readium", formatSniffer: formatSniffer) + + // Serve static assets directory. + let assetsBaseURL = server.serve(directory: assetsDirectory, at: "assets") + + self.init( publication: publication, + readingOrder: readingOrder, config: config, - httpServer: httpServer, - publicationEndpoint: publicationEndpoint, - assetsURL: httpServer.serve( - at: "readium", - contentsOf: Bundle.module.resourceURL!.fileURL! - .appendingPath("Assets/Static", isDirectory: true) - ) + server: server, + assetsBaseURL: assetsBaseURL, + formatSniffer: formatSniffer ) if let url = publication.baseURL { + // The publication already has an HTTP base URL (e.g. served + // remotely). Use it directly; the server only needs to serve + // assets. publicationBaseURL = url } else { - publicationBaseURL = try httpServer.serve( - at: uuidEndpoint, // serving the chapters endpoint - publication: publication, - onFailure: { [weak self] request, error in - guard let self = self, let href = request.href else { - return - } - self.delegate?.epubNavigatorViewModel(self, didFailToLoadResourceAt: href, withError: error) - } - ) - } - - if let endpoint = publicationEndpoint { - try httpServer.transformResources(at: endpoint) { [weak self] href, resource in - self?.injectReadiumCSS(in: resource, at: href) ?? resource + // Serve publication resources. + publicationBaseURL = server.serve(at: UUID().uuidString) { [weak self] in + await self?.serve(href: $0) } } } private init( publication: Publication, + readingOrder: ReadingOrder, config: EPUBNavigatorViewController.Configuration, - httpServer: HTTPServer?, - publicationEndpoint: HTTPServerEndpoint?, - assetsURL: HTTPURL + server: WebViewServer, + assetsBaseURL: any AbsoluteURL, + formatSniffer: FormatSniffer ) { var config = config @@ -123,14 +116,15 @@ final class EPUBNavigatorViewModel: Loggable { } self.publication = publication + self.readingOrder = readingOrder self.config = config editingActions = EditingActionsController( actions: config.editingActions, publication: publication ) - self.httpServer = httpServer - self.publicationEndpoint = publicationEndpoint - self.assetsURL = assetsURL + self.server = server + self.assetsBaseURL = assetsBaseURL + self.formatSniffer = formatSniffer preferences = config.preferences settings = EPUBSettings(publication: publication, config: config) @@ -138,7 +132,7 @@ final class EPUBNavigatorViewModel: Loggable { css = ReadiumCSS( layout: CSSLayout(), rsProperties: config.readiumCSSRSProperties, - baseURL: assetsURL.appendingPath("readium-css", isDirectory: true), + baseURL: assetsBaseURL.appendingPath("readium-css", isDirectory: true), fontFamilyDeclarations: config.fontFamilyDeclarations ) @@ -154,30 +148,12 @@ final class EPUBNavigatorViewModel: Loggable { deinit { NotificationCenter.default.removeObserver(self) - - if let endpoint = publicationEndpoint { - try? httpServer?.remove(at: endpoint) - } } func url(to link: Link) -> AnyURL { link.url(relativeTo: publicationBaseURL) } - private func serveFile(at file: FileURL, baseEndpoint: HTTPServerEndpoint) throws -> HTTPURL { - if let url = servedFiles[file] { - return url - } - - guard let httpServer = httpServer else { - throw Error.noHTTPServer - } - let endpoint = baseEndpoint.addingSuffix("/") + file.lastPathSegment - let url = try httpServer.serve(at: endpoint, contentsOf: file) - $servedFiles.write { $0[file] = url } - return url - } - private var needsInvalidatePagination = false private func setNeedsInvalidatePagination() { guard !needsInvalidatePagination else { @@ -190,6 +166,36 @@ final class EPUBNavigatorViewModel: Loggable { } } + // MARK: - Web View Server + + private func serve(href: RelativeURL) async -> (Resource, MediaType)? { + guard var resource = publication.get(href) else { + return nil + } + let mediaType = await resolveMediaType(for: resource, at: href) + resource = injectReadiumCSS(in: resource, at: href) + return (resource, mediaType) + } + + /// Resolves the media type to use to serve the given `resource`. + /// + /// The media type declared in the manifest takes precedence, before falling + /// back on the `Resource` properties and sniffing the `href`. + /// + /// The manifest takes precedence because a file with a `.xml` extension + /// might be declared as `application/xhtml+xml` in the OPF. + private func resolveMediaType(for resource: Resource, at href: RelativeURL) async -> MediaType { + if let mediaType = publication.linkWithHREF(href)?.mediaType { + return mediaType + } + if let mediaType = await resource.properties().getOrNil()?.mediaType { + return mediaType + } + + return href.pathExtension.flatMap { formatSniffer.sniffHints(.init(fileExtension: $0))?.mediaType } + ?? .binary + } + // MARK: - User preferences /// Currently applied settings. @@ -220,6 +226,8 @@ final class EPUBNavigatorViewModel: Loggable { || oldSettings.verticalText != newSettings.verticalText || oldSettings.scroll != newSettings.scroll || oldSettings.spread != newSettings.spread + || oldSettings.fit != newSettings.fit + || oldSettings.offsetFirstPage != newSettings.offsetFirstPage // We don't commit the CSS changes if we invalidate the pagination, as // the resources will be reloaded anyway. @@ -238,11 +246,29 @@ final class EPUBNavigatorViewModel: Loggable { ) } - var readingProgression: ReadingProgression { settings.readingProgression } - var theme: Theme { settings.theme } - var scroll: Bool { settings.scroll } - var verticalText: Bool { settings.verticalText } - var spread: Spread { settings.spread } + var readingProgression: ReadingProgression { + settings.readingProgression + } + + var theme: Theme { + settings.theme + } + + var scroll: Bool { + settings.scroll + } + + var verticalText: Bool { + settings.verticalText + } + + var spread: Spread { + settings.spread + } + + var offsetFirstPage: Bool? { + settings.offsetFirstPage + } // MARK: Spread @@ -279,16 +305,13 @@ final class EPUBNavigatorViewModel: Loggable { // MARK: - Readium CSS private var css: ReadiumCSS - - private func serveFont(at file: FileURL) throws -> HTTPURL { - try serveFile(at: file, baseEndpoint: "custom-fonts/\(UUID().uuidString)") - } + private var servedFonts: [FileURL: AbsoluteURL] = [:] func injectReadiumCSS(in resource: Resource, at href: HREF) -> Resource { guard let link = publication.linkWithHREF(href), link.mediaType?.isHTML == true, - publication.metadata.layout == .reflowable + publication.metadata.epubLayout == .reflowable else { return resource } @@ -301,7 +324,18 @@ final class EPUBNavigatorViewModel: Loggable { do { var content = try css.inject(in: content) for ff in config.fontFamilyDeclarations { - content = try ff.inject(in: content, servingFile: serveFont) + content = try ff.inject( + in: content, + servingFile: { [server] file in + if let url = self.servedFonts[file] { + return url + } + let name = file.lastPathSegment ?? UUID().uuidString + let url = server.serve(file: file, at: "assets/fonts/\(name)") + self.servedFonts[file] = url + return url + } + ) } return content } catch { diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 24eda8d15a..1d58e5961c 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -33,6 +33,16 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { ) } + override func clear() { + super.clear() + + // Clean up go to continuations. + for continuation in goToContinuations { + continuation.resume() + } + goToContinuations.removeAll() + } + override func setupWebView() { super.setupWebView() @@ -71,8 +81,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { log(.error, "Only one document at a time can be displayed in a reflowable spread") return } - let link = viewModel.readingOrder[spread.leading] - let url = viewModel.url(to: link) + let url = viewModel.url(to: spread.first.link) webView.load(URLRequest(url: url.url)) } @@ -125,7 +134,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { override func progression(in index: ReadingOrder.Index) -> ClosedRange { guard - spread.leading == index, + spread.first.index == index, let progression = progression else { return 0 ... 0 @@ -134,10 +143,8 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { } override func spreadDidLoad() async { - if - let link = viewModel.readingOrder.getOrNil(spread.leading), - let linkJSON = serializeJSONString(link.json) - { + let link = spread.first.link + if let linkJSON = serializeJSONString(link.json) { await evaluateScript("readium.link = \(linkJSON);") } @@ -190,10 +197,9 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { return true } - // Location to scroll to in the resource once the page is loaded. + /// Location to scroll to in the resource once the page is loaded. private var pendingLocation: PageLocation = .start - @MainActor override func go(to location: PageLocation) async { guard isSpreadLoaded else { // Delays moving to the location until the document is loaded. @@ -215,14 +221,12 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { didCompleteGoTo() } - @MainActor private func waitGoToCompletion() async { await withCheckedContinuation { continuation in goToContinuations.append(continuation) } } - @MainActor private func didCompleteGoTo() { for cont in goToContinuations { cont.resume() @@ -230,7 +234,6 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { goToContinuations.removeAll() } - @MainActor private var goToContinuations: [CheckedContinuation] = [] @discardableResult @@ -310,12 +313,12 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { // MARK: - Progression - // Current progression range in the page. + /// Current progression range in the page. private var progression: ClosedRange? - // To check if a progression change was cancelled or not. + /// To check if a progression change was cancelled or not. private var previousProgression: ClosedRange? - // Called by the javascript code to notify that scrolling ended. + /// Called by the javascript code to notify that scrolling ended. private func progressionDidChange(_ body: Any) { guard isSpreadLoaded, diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 573c963714..284f3b6154 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -7,106 +7,76 @@ import Foundation import ReadiumShared -/// A list of EPUB resources to be displayed together on the screen, as one-page -/// or two-pages spread. -struct EPUBSpread: Loggable { - /// Indicates whether two pages are displayed side by side. - var spread: Bool +/// Common interface for spread types. +protocol EPUBSpreadProtocol { + /// Returns whether the spread contains the resource at the given reading + /// order index. + func contains(index: ReadingOrder.Index) -> Bool - /// Indices for the resources displayed in the spread, in reading order. - /// - /// Note: it's possible to have less links than the amount of `pageCount` - /// available, because a single page might be displayed in a two-page spread - /// (eg. with Properties.Page center, left or right). - var readingOrderIndices: ReadingOrderIndices + /// Return the number of positions contained in the spread. + func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int - /// Spread reading progression direction. - var readingProgression: ReadingProgression + /// Returns a JSON representation of the links in the spread. + /// + /// The JSON is an array of link objects in reading progression order. + /// Each link object contains: + /// - link: Link object of the resource in the Publication + /// - url: Full URL to the resource. + /// - page [left|center|right]: (optional) Page position of the linked resource in the spread. + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] +} - init(spread: Bool, readingOrderIndices: ReadingOrderIndices, readingProgression: ReadingProgression) { - precondition(!readingOrderIndices.isEmpty, "A spread must have at least one page") - precondition(spread || readingOrderIndices.count == 1, "A one-page spread must have only one page") - precondition(!spread || 1 ... 2 ~= readingOrderIndices.count, "A two-pages spread must have one or two pages max") - self.spread = spread - self.readingOrderIndices = readingOrderIndices - self.readingProgression = readingProgression - } +/// Represents a spread of EPUB resources displayed in the viewport. A spread +/// can contain one or two resources (for FXL). +enum EPUBSpread: EPUBSpreadProtocol { + /// A spread displaying a single resource. + case single(EPUBSingleSpread) + /// A spread displaying two resources side by side (FXL only). + case double(EPUBDoubleSpread) - /// Returns the left-most reading order index in the spread. - var left: ReadingOrder.Index { - switch readingProgression { - case .ltr: - readingOrderIndices.lowerBound - case .rtl: - readingOrderIndices.upperBound + /// Range of reading order indices contained in this spread. + var readingOrderIndices: ReadingOrderIndices { + switch self { + case let .single(spread): + return spread.resource.index ... spread.resource.index + case let .double(spread): + return spread.first.index ... spread.second.index } } - /// Returns the right-most reading order index in the spread. - var right: ReadingOrder.Index { - switch readingProgression { - case .ltr: - readingOrderIndices.upperBound - case .rtl: - readingOrderIndices.lowerBound + /// The leading resource in the reading progression. + var first: EPUBSpreadResource { + switch self { + case let .single(spread): + return spread.resource + case let .double(spread): + return spread.first } } - /// Returns the leading reading order index in the reading progression. - var leading: ReadingOrder.Index { - readingOrderIndices.lowerBound + private var spread: EPUBSpreadProtocol { + switch self { + case let .single(spread): + return spread + case let .double(spread): + return spread + } } - /// Returns whether the spread contains the resource at the given reading - /// order index func contains(index: ReadingOrder.Index) -> Bool { - readingOrderIndices.contains(index) + spread.contains(index: index) } - /// Return the number of positions contained in the spread. func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int { - readingOrderIndices - .map { index in - positionsByReadingOrder[index].count - } - .reduce(0, +) + spread.positionCount(in: readingOrder, positionsByReadingOrder: positionsByReadingOrder) } - /// Returns a JSON representation of the links in the spread. - /// The JSON is an array of link objects in reading progression order. - /// Each link object contains: - /// - link: Link object of the resource in the Publication - /// - url: Full URL to the resource. - /// - page [left|center|right]: (optional) Page position of the linked resource in the spread. - func json(forBaseURL baseURL: HTTPURL, readingOrder: ReadingOrder) -> [[String: Any]] { - func makeLinkJSON(_ index: ReadingOrder.Index, page: Properties.Page? = nil) -> [String: Any]? { - guard let link = readingOrder.getOrNil(index) else { - return nil - } - - let page = page ?? link.properties.page ?? readingProgression.startingPage - return [ - "index": index, - "link": link.json, - "url": link.url(relativeTo: baseURL).string, - "page": page.rawValue, - ] - } - - var json: [[String: Any]?] = [] - - if readingOrderIndices.count == 1 { - json.append(makeLinkJSON(leading)) - } else { - json.append(makeLinkJSON(left, page: .left)) - json.append(makeLinkJSON(right, page: .right)) - } - - return json.compactMap { $0 } + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] { + spread.json(forBaseURL: baseURL, readingProgression: readingProgression) } - func jsonString(forBaseURL baseURL: HTTPURL, readingOrder: ReadingOrder) -> String { - serializeJSONString(json(forBaseURL: baseURL, readingOrder: readingOrder)) ?? "[]" + func jsonString(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> String { + serializeJSONString(json(forBaseURL: baseURL, readingProgression: readingProgression)) ?? "[]" } /// Builds a list of spreads for the given Publication. @@ -115,92 +85,108 @@ struct EPUBSpread: Loggable { /// - publication: The Publication to build the spreads for. /// - readingProgression: Reading progression direction used to layout the pages. /// - spread: Indicates whether two pages are displayed side-by-side. + /// - offsetFirstPage: Indicates if the first page should be displayed in its own spread. static func makeSpreads( for publication: Publication, readingOrder: [Link], readingProgression: ReadingProgression, - spread: Bool + spread: Bool, + offsetFirstPage: Bool? = nil ) -> [EPUBSpread] { spread - ? makeTwoPagesSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression) - : makeOnePageSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression) + ? makeTwoPagesSpreads(for: publication, readingOrder: readingOrder, readingProgression: readingProgression, offsetFirstPage: offsetFirstPage) + : makeOnePageSpreads(readingOrder: readingOrder) } /// Builds a list of one-page spreads for the given Publication. private static func makeOnePageSpreads( - for publication: Publication, - readingOrder: [Link], - readingProgression: ReadingProgression + readingOrder: [Link] ) -> [EPUBSpread] { - readingOrder.enumerated().map { index, _ in - EPUBSpread( - spread: false, - readingOrderIndices: index ... index, - readingProgression: readingProgression - ) + readingOrder.enumerated().map { index, link in + .single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: index, link: link) + )) } } /// Builds a list of two-page spreads for the given Publication. + /// + /// `offsetFirstPage` is the user preference used to control if the first + /// resource is displayed on its own. private static func makeTwoPagesSpreads( for publication: Publication, readingOrder: [Link], - readingProgression: ReadingProgression + readingProgression: ReadingProgression, + offsetFirstPage: Bool? ) -> [EPUBSpread] { var spreads: [EPUBSpread] = [] var index = 0 while index < readingOrder.count { - let first = readingOrder[index] + var first = readingOrder[index] - var spread = EPUBSpread( - spread: true, - readingOrderIndices: index ... index, - readingProgression: readingProgression - ) + // The first resource (often the cover) has special rules for its + // position in the spread. + if index == 0 { + if let offsetFirstPage = offsetFirstPage { + // User explicitly chose to offset (or not) the first page. + first.properties.page = offsetFirstPage ? .center : nil + } else if first.properties.page == nil, publication.metadata.layout == .fixed { + // For FXL publications, default to displaying the first + // page (typically a cover) on its own when the publication + // doesn't provide an explicit page position. This is the + // behavior of Apple Books, so it's expected by publishers. + // + // We display it centered rather than on the left or right + // to ensure it fills the entire viewport in portrait mode. + first.properties.page = .center + } + } let nextIndex = index + 1 + // To be displayed together, two pages must be part of a fixed // layout publication and have consecutive position hints // (Properties.Page). if let second = readingOrder.getOrNil(nextIndex), publication.metadata.layout == .fixed, - publication.areConsecutive(first, second, index: index) + areConsecutive(first, second, readingProgression: publication.metadata.readingProgression) { - spread.readingOrderIndices = index ... nextIndex + spreads.append(.double( + EPUBDoubleSpread( + first: EPUBSpreadResource(index: index, link: first), + second: EPUBSpreadResource(index: nextIndex, link: second) + ) + )) index += 1 // Skips the consumed "second" page + + } else { + spreads.append(.single( + EPUBSingleSpread( + resource: EPUBSpreadResource(index: index, link: first) + ) + )) } - spreads.append(spread) index += 1 } return spreads } -} -extension Array where Element == EPUBSpread { - /// Returns the index of the first spread containing a resource with the given `href`. - func firstIndexWithReadingOrderIndex(_ index: ReadingOrder.Index) -> Int? { - firstIndex { spread in - spread.contains(index: index) - } - } -} - -private extension Publication { /// Two resources are consecutive if their position hint (Properties.Page) - /// are paired according to the reading progression. - func areConsecutive(_ first: Link, _ second: Link, index: Int) -> Bool { - guard index > 0 || first.properties.page != nil else { - return false - } - + /// are paired according to the reading progression from the publication + /// (not user preferences). + private static func areConsecutive( + _ first: Link, + _ second: Link, + readingProgression: ReadiumShared.ReadingProgression + ) -> Bool { // Here we use the default publication reading progression instead // of the custom one provided, otherwise the page position hints // might be wrong, and we could end up with only one-page spreads. - switch metadata.readingProgression { + switch readingProgression { case .ltr, .ttb, .auto: let firstPosition = first.properties.page ?? .left let secondPosition = second.properties.page ?? .right @@ -212,3 +198,115 @@ private extension Publication { } } } + +/// A resource displayed in a spread, with its reading order index. +struct EPUBSpreadResource { + /// Index of the resource in the reading order. + let index: ReadingOrder.Index + /// Link to the resource. + let link: Link + + /// Returns a JSON representation of the resource for the spread scripts. + func json(forBaseURL baseURL: AbsoluteURL, page: Properties.Page) -> [String: Any] { + [ + "index": index, + "link": link.json, + "url": link.url(relativeTo: baseURL).string, + "page": page.rawValue, + ] + } +} + +/// A spread displaying a single resource. +struct EPUBSingleSpread: EPUBSpreadProtocol, Loggable { + /// The resource displayed in the spread. + var resource: EPUBSpreadResource + + func contains(index: ReadingOrder.Index) -> Bool { + resource.index == index + } + + func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int { + positionsByReadingOrder.getOrNil(resource.index)?.count ?? 0 + } + + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] { + [ + resource.json( + forBaseURL: baseURL, + page: resource.link.properties.page ?? defaultPage(in: readingProgression) + ), + ] + } + + /// Returns the default spread position (left or right) for the single + /// resource, in the given reading progression. + /// + /// The first page (typically a cover) defaults to the starting page (right + /// for LTR). Other unpaired pages default to the leading position they + /// would have had in a spread pair. + private func defaultPage(in readingProgression: ReadingProgression) -> Properties.Page { + let isFirstPage = (resource.index == 0) + return switch readingProgression { + case .ltr: + isFirstPage ? .right : .left + case .rtl: + isFirstPage ? .left : .right + } + } +} + +/// A spread displaying two resources side by side (FXL only). +struct EPUBDoubleSpread: EPUBSpreadProtocol, Loggable { + /// The leading resource in the reading progression. + var first: EPUBSpreadResource + /// The trailing resource in the reading progression. + var second: EPUBSpreadResource + + /// Returns the left resource in the spread. + func left(for readingProgression: ReadingProgression) -> EPUBSpreadResource { + switch readingProgression { + case .ltr: + first + case .rtl: + second + } + } + + /// Returns the right resource in the spread. + func right(for readingProgression: ReadingProgression) -> EPUBSpreadResource { + switch readingProgression { + case .ltr: + second + case .rtl: + first + } + } + + func contains(index: ReadingOrder.Index) -> Bool { + first.index == index || second.index == index + } + + func positionCount(in readingOrder: ReadingOrder, positionsByReadingOrder: [[Locator]]) -> Int { + let firstPositions = positionsByReadingOrder.getOrNil(first.index)?.count ?? 0 + let secondPositions = positionsByReadingOrder.getOrNil(second.index)?.count ?? 0 + return firstPositions + secondPositions + } + + func json(forBaseURL baseURL: AbsoluteURL, readingProgression: ReadingProgression) -> [[String: Any]] { + [ + left(for: readingProgression).json(forBaseURL: baseURL, page: .left), + right(for: readingProgression).json(forBaseURL: baseURL, page: .right), + ] + } +} + +extension Array where Element == EPUBSpread { + /// Returns the index of the first spread containing a resource with the + /// given `href`. + func firstIndexWithReadingOrderIndex(_ index: ReadingOrder.Index) -> Int? { + firstIndex { spread in + spread.contains(index: index) + } + } +} diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 27a52c2b95..a038eb5d46 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -51,7 +51,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { let webView: WebView - private var lastClick: ClickEvent? = nil + private var lastClick: ClickEvent? /// If YES, the content will be faded in once loaded. let animatedLoad: Bool @@ -60,6 +60,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { private var activityIndicatorStopWorkItem: DispatchWorkItem? private(set) var isSpreadLoaded = false + private var spreadLoadTask: Task? required init( viewModel: EPUBNavigatorViewModel, @@ -70,7 +71,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { self.viewModel = viewModel self.spread = spread self.animatedLoad = animatedLoad - webView = WebView(editingActions: viewModel.editingActions) + + let config = WKWebViewConfiguration() + config.setURLSchemeHandler(viewModel.server, forURLScheme: viewModel.server.scheme) + config.mediaTypesRequiringUserActionForPlayback = .all + + // Disable the Apple Intelligence Writing tools in the web views. + // See https://github.com/readium/swift-toolkit/issues/509#issuecomment-2577780749 + if #available(iOS 18.0, *) { + config.writingToolsBehavior = .none + } + + webView = WebView(editingActions: viewModel.editingActions, configuration: config) super.init(frame: .zero) @@ -95,6 +107,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { deinit { NotificationCenter.default.removeObserver(self) + clear() + } + + /// Called when the spread view is removed from the view hierarchy, to + /// clear pending operations and retain cycles. + func clear() { + webView.stopLoading() + + spreadLoadTask?.cancel() + spreadLoadTask = nil + + // Disable JS messages to break WKUserContentController reference. disableJSMessages() } @@ -126,14 +150,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { webView.scrollView } + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + clear() + } + } + override func didMoveToSuperview() { super.didMoveToSuperview() - if superview == nil { - disableJSMessages() - // Fixing an iOS 9 bug by explicitly clearing scrollView.delegate before deinitialization - scrollView.delegate = nil - } else { + if superview != nil { enableJSMessages() scrollView.delegate = self } @@ -150,9 +178,9 @@ class EPUBSpreadView: UIView, Loggable, PageView { log(.trace, "Evaluate script: \(script)") return await withCheckedContinuation { continuation in - webView.evaluateJavaScript(script) { res, error in + webView.evaluateJavaScript(script) { [weak self] res, error in if let error = error { - self.log(.error, error) + self?.log(.error, error) continuation.resume(returning: .failure(error)) } else { continuation.resume(returning: .success(res ?? ())) @@ -266,7 +294,8 @@ class EPUBSpreadView: UIView, Loggable, PageView { /// Called by the javascript code when the spread contents is fully loaded. /// The JS message `spreadLoaded` needs to be emitted by a subclass script, EPUBSpreadView's scripts don't. private func spreadDidLoad(_ body: Any) { - Task { @MainActor in + spreadLoadTask?.cancel() + spreadLoadTask = Task { @MainActor in isSpreadLoaded = true applySettings() await spreadDidLoad() @@ -374,10 +403,9 @@ class EPUBSpreadView: UIView, Loggable, PageView { func findFirstVisibleElementLocator() async -> Locator? { let result = await evaluateScript("readium.findFirstVisibleLocator()") do { - let resource = viewModel.readingOrder[spread.leading] - let locator = try Locator(json: result.get())? - .copy(href: resource.url(), mediaType: resource.mediaType ?? .xhtml) - return locator + let link = spread.first.link + return try Locator(json: result.get())? + .copy(href: link.url(), mediaType: link.mediaType ?? .xhtml) } catch { log(.error, error) return nil @@ -426,7 +454,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { } } - // Removes message handlers (preventing strong reference cycle). + /// Removes message handlers (preventing strong reference cycle). private func disableJSMessages() { guard JSMessagesEnabled else { return @@ -515,12 +543,12 @@ extension EPUBSpreadView: WKNavigationDelegate { var policy: WKNavigationActionPolicy = .allow if navigationAction.navigationType == .linkActivated { - if let url = navigationAction.request.url?.httpURL { + if let url = navigationAction.request.url { // Check if url is internal or external if let relativeURL = viewModel.publicationBaseURL.relativize(url) { delegate?.spreadView(self, didTapOnInternalLink: relativeURL.string, clickEvent: lastClick) } else { - delegate?.spreadView(self, didTapOnExternalURL: url.url) + delegate?.spreadView(self, didTapOnExternalURL: url) } policy = .cancel @@ -603,7 +631,7 @@ private extension EPUBSpreadView { return } - trace("stopping activity indicator because spread \(viewModel.readingOrder[spread.leading].href) did not load") + trace("stopping activity indicator because spread \(spread.first.link.href) did not load") activityIndicatorView?.stopAnimating() } @@ -745,7 +773,6 @@ private extension KeyEvent { key = .tab case "Space": key = .space - case "ArrowDown": key = .arrowDown case "ArrowLeft": @@ -754,7 +781,6 @@ private extension KeyEvent { key = .arrowRight case "ArrowUp": key = .arrowUp - case "End": key = .end case "Home": @@ -763,7 +789,6 @@ private extension KeyEvent { key = .pageDown case "PageUp": key = .pageUp - case "MetaLeft", "MetaRight": key = .command case "ControlLeft", "ControlRight": @@ -772,12 +797,10 @@ private extension KeyEvent { key = .option case "ShiftLeft", "ShiftRight": key = .shift - case "Backspace": key = .backspace case "Escape": key = .escape - default: guard let char = dict["key"] as? String else { return nil diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index b5824197f8..a385dbc056 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -55,31 +55,42 @@ public struct HTMLDecorationTemplate { } /// Creates the default list of decoration styles with associated HTML templates. + /// + /// - Parameters: + /// - defaultTint: Default highlight/underline color when the decoration + /// has no tint set. + /// - lineWeight: Thickness in pixels of the underline stroke. + /// - cornerRadius: Border radius in pixels applied to each decoration box. + /// - alpha: Opacity of the highlight fill color (0–1). + /// - experimentalPositioning: When true, places decorations behind the + /// publication text using a negative z-index, preventing the highlight + /// from affecting text color. This may not work with all publications. public static func defaultTemplates( defaultTint: UIColor = .yellow, lineWeight: Int = 2, cornerRadius: Int = 3, - alpha: Double = 0.3 + alpha: Double = 0.3, + experimentalPositioning: Bool = false ) -> [Decoration.Style.Id: HTMLDecorationTemplate] { let padding = UIEdgeInsets(top: 0, left: 1, bottom: 0, right: 1) return [ - .highlight: .highlight(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha), - .underline: .underline(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha), + .highlight: .highlight(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning), + .underline: .underline(defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning), ] } /// Creates a new decoration template for the `highlight` style. - public static func highlight(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double) -> HTMLDecorationTemplate { - makeTemplate(asHighlight: true, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha) + public static func highlight(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double, experimentalPositioning: Bool = false) -> HTMLDecorationTemplate { + makeTemplate(asHighlight: true, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning) } /// Creates a new decoration template for the `underline` style. - public static func underline(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double) -> HTMLDecorationTemplate { - makeTemplate(asHighlight: false, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha) + public static func underline(defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double, experimentalPositioning: Bool = false) -> HTMLDecorationTemplate { + makeTemplate(asHighlight: false, defaultTint: defaultTint, padding: padding, lineWeight: lineWeight, cornerRadius: cornerRadius, alpha: alpha, experimentalPositioning: experimentalPositioning) } /// - Parameter asHighlight: When true, the non active style is of an highlight. Otherwise, it is an underline. - private static func makeTemplate(asHighlight: Bool, defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double) -> HTMLDecorationTemplate { + private static func makeTemplate(asHighlight: Bool, defaultTint: UIColor, padding: UIEdgeInsets, lineWeight: Int, cornerRadius: Int, alpha: Double, experimentalPositioning: Bool = false) -> HTMLDecorationTemplate { let className = makeUniqueClassName(key: asHighlight ? "highlight" : "underline") return HTMLDecorationTemplate( layout: .boxes, @@ -94,6 +105,11 @@ public struct HTMLDecorationTemplate { if !asHighlight || isActive { css += "--underline-color: \(tint.cssValue());" } + if experimentalPositioning { + // Experimental positioning: + // Decoration is placed behind the publication's text, to prevent it from affecting text-color. + css += "--decoration-z-index: -1;" + } return "
" }, stylesheet: @@ -104,6 +120,7 @@ public struct HTMLDecorationTemplate { border-radius: \(cornerRadius)px; box-sizing: border-box; border: 0 solid var(--underline-color); + z-index: var(--decoration-z-index); } /* Horizontal (default) */ @@ -121,7 +138,7 @@ public struct HTMLDecorationTemplate { [data-writing-mode="vertical-lr"].\(className), [data-writing-mode="sideways-lr"].\(className) { border-right-width: \(lineWeight)px; - } + } """ ) } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift index fbac10c668..f74058ac41 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index 7b19576774..d2a22aaf4a 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -23,6 +23,13 @@ public struct EPUBPreferences: ConfigurablePreferences { /// Darkens images by the given percentage. public var darkenImages: Double? + /// Method for fitting the content of a fixed-layout publication within the + /// viewport. + /// + /// - `auto` or `page`: Fit entire page within viewport (default). + /// - `width`: Fit page width, allow vertical scrolling if needed. + public var fit: Fit? + /// Default typeface for the text. public var fontFamily: FontFamily? @@ -56,6 +63,12 @@ public struct EPUBPreferences: ConfigurablePreferences { /// Leading line height. public var lineHeight: Double? + /// Indicates whether the first page should be displayed alone and centered + /// instead of alongside the second page. + /// + /// This is only effective if spreads are enabled. + public var offsetFirstPage: Bool? + /// Text indentation for paragraphs. public var paragraphIndent: Double? @@ -99,6 +112,7 @@ public struct EPUBPreferences: ConfigurablePreferences { blendImages: Bool? = nil, columnCount: Int? = nil, darkenImages: Double? = nil, + fit: Fit? = nil, fontFamily: FontFamily? = nil, fontSize: Double? = nil, fontWeight: Double? = nil, @@ -110,6 +124,7 @@ public struct EPUBPreferences: ConfigurablePreferences { ligatures: Bool? = nil, lineLength: Double? = nil, lineHeight: Double? = nil, + offsetFirstPage: Bool? = nil, paragraphIndent: Double? = nil, paragraphSpacing: Double? = nil, readingProgression: ReadingProgression? = nil, @@ -126,6 +141,7 @@ public struct EPUBPreferences: ConfigurablePreferences { self.blendImages = blendImages self.columnCount = columnCount self.darkenImages = darkenImages + self.fit = fit self.fontFamily = fontFamily self.fontSize = fontSize.map { max($0, 0) } self.fontWeight = fontWeight?.clamped(to: 0.0 ... 2.5) @@ -137,6 +153,7 @@ public struct EPUBPreferences: ConfigurablePreferences { self.ligatures = ligatures self.lineLength = lineLength.map { max($0, 0) } self.lineHeight = lineHeight + self.offsetFirstPage = offsetFirstPage self.paragraphIndent = paragraphIndent self.paragraphSpacing = paragraphSpacing.map { max($0, 0) } self.readingProgression = readingProgression @@ -156,6 +173,7 @@ public struct EPUBPreferences: ConfigurablePreferences { blendImages: other.blendImages ?? blendImages, columnCount: other.columnCount ?? columnCount, darkenImages: other.darkenImages ?? darkenImages, + fit: other.fit ?? fit, fontFamily: other.fontFamily ?? fontFamily, fontSize: other.fontSize ?? fontSize, fontWeight: other.fontWeight ?? fontWeight, @@ -167,6 +185,7 @@ public struct EPUBPreferences: ConfigurablePreferences { ligatures: other.ligatures ?? ligatures, lineLength: other.lineLength ?? lineLength, lineHeight: other.lineHeight ?? lineHeight, + offsetFirstPage: other.offsetFirstPage ?? offsetFirstPage, paragraphIndent: other.paragraphIndent ?? paragraphIndent, paragraphSpacing: other.paragraphSpacing ?? paragraphSpacing, readingProgression: other.readingProgression ?? readingProgression, @@ -186,6 +205,7 @@ public struct EPUBPreferences: ConfigurablePreferences { public func filterSharedPreferences() -> EPUBPreferences { var prefs = self prefs.language = nil + prefs.offsetFirstPage = nil prefs.readingProgression = nil prefs.spread = nil prefs.verticalText = nil @@ -197,6 +217,7 @@ public struct EPUBPreferences: ConfigurablePreferences { public func filterPublicationPreferences() -> EPUBPreferences { EPUBPreferences( language: language, + offsetFirstPage: offsetFirstPage, readingProgression: readingProgression, spread: spread, verticalText: verticalText @@ -204,16 +225,24 @@ public struct EPUBPreferences: ConfigurablePreferences { } @available(*, unavailable, message: "Use lineLength instead") - public var pageMargins: Double? { nil } + public var pageMargins: Double? { + nil + } @available(*, unavailable, message: "Not available anymore") - public var typeScale: Double? { nil } + public var typeScale: Double? { + nil + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: Bool? { nil } + public var publisherStyles: Bool? { + nil + } @available(*, unavailable, message: "Use invertImages or darkenImages instead") - public var imageFilter: ImageFilter? { nil } + public var imageFilter: ImageFilter? { + nil + } @available(*, unavailable, message: "Use the other initializer") public init( @@ -242,5 +271,7 @@ public struct EPUBPreferences: ConfigurablePreferences { typeScale: Double? = nil, verticalText: Bool? = nil, wordSpacing: Double? = nil - ) { fatalError() } + ) { + fatalError() + } } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index a07dacda1a..58a238e055 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,12 +21,7 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = + enumPreference( + preference: \.fit, + setting: \.fit, + defaultEffectiveValue: defaults.fit ?? .auto, + isEffective: { [layout] _ in layout == .fixed }, + supportedValues: [.auto, .page, .width] + ) + /// Default typeface for the text. /// /// Only effective with reflowable publications. @@ -288,6 +295,25 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor = + preference( + preference: \.offsetFirstPage, + setting: \.offsetFirstPage, + isEffective: { [layout] in + layout == .fixed + && $0.settings.spread != .never + } + ) + /// Text indentation for paragraphs. /// /// Only effective when: @@ -457,14 +483,22 @@ public final class EPUBPreferencesEditor: StatefulPreferencesEditor { fatalError() } + public var pageMargins: AnyRangePreference { + fatalError() + } @available(*, unavailable, message: "Not available anymore") - public var typeScale: AnyRangePreference { fatalError() } + public var typeScale: AnyRangePreference { + fatalError() + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: AnyPreference { fatalError() } + public var publisherStyles: AnyPreference { + fatalError() + } @available(*, unavailable, message: "Use darkenImages and invertImages instead") - public var imageFilter: AnyEnumPreference { fatalError() } + public var imageFilter: AnyEnumPreference { + fatalError() + } } diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index 684da00792..ba374ce0e6 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -15,6 +15,7 @@ public struct EPUBSettings: ConfigurableSettings { public var blendImages: Bool? public var columnCount: Int public var darkenImages: Double? + public var fit: Fit public var fontFamily: FontFamily? public var fontSize: Double public var fontWeight: Double? @@ -26,6 +27,7 @@ public struct EPUBSettings: ConfigurableSettings { public var ligatures: Bool? public var lineLength: Double public var lineHeight: Double? + public var offsetFirstPage: Bool? public var paragraphIndent: Double? public var paragraphSpacing: Double? public var readingProgression: ReadingProgression @@ -49,6 +51,7 @@ public struct EPUBSettings: ConfigurableSettings { blendImages: Bool?, columnCount: Int, darkenImages: Double?, + fit: Fit, fontFamily: FontFamily?, fontSize: Double, fontWeight: Double?, @@ -60,6 +63,7 @@ public struct EPUBSettings: ConfigurableSettings { ligatures: Bool?, lineLength: Double, lineHeight: Double?, + offsetFirstPage: Bool?, paragraphIndent: Double?, paragraphSpacing: Double?, readingProgression: ReadingProgression, @@ -76,6 +80,7 @@ public struct EPUBSettings: ConfigurableSettings { self.blendImages = blendImages self.columnCount = columnCount self.darkenImages = darkenImages + self.fit = fit self.fontFamily = fontFamily self.fontSize = fontSize self.fontWeight = fontWeight @@ -87,6 +92,7 @@ public struct EPUBSettings: ConfigurableSettings { self.ligatures = ligatures self.lineLength = lineLength self.lineHeight = lineHeight + self.offsetFirstPage = offsetFirstPage self.paragraphIndent = paragraphIndent self.paragraphSpacing = paragraphSpacing self.readingProgression = readingProgression @@ -131,8 +137,8 @@ public struct EPUBSettings: ConfigurableSettings { ?? defaults.scroll ?? false - /// We disable pagination with vertical text, because CSS columns don't support it properly. - /// See https://github.com/readium/swift-toolkit/discussions/370 + // We disable pagination with vertical text, because CSS columns don't support it properly. + // See https://github.com/readium/swift-toolkit/discussions/370 if verticalText { scroll = true } @@ -144,6 +150,9 @@ public struct EPUBSettings: ConfigurableSettings { ?? defaults.columnCount ?? 1, darkenImages: preferences.darkenImages, + fit: preferences.fit + ?? defaults.fit + ?? .auto, fontFamily: preferences.fontFamily, fontSize: preferences.fontSize ?? defaults.fontSize @@ -164,6 +173,8 @@ public struct EPUBSettings: ConfigurableSettings { ?? 1.0, lineHeight: preferences.lineHeight ?? defaults.lineHeight, + offsetFirstPage: preferences.offsetFirstPage + ?? defaults.offsetFirstPage, paragraphIndent: preferences.paragraphIndent ?? defaults.paragraphIndent, paragraphSpacing: preferences.paragraphSpacing @@ -188,13 +199,19 @@ public struct EPUBSettings: ConfigurableSettings { } @available(*, unavailable, message: "Not supported anymore") - public var typeScale: Double? { nil } + public var typeScale: Double? { + nil + } @available(*, unavailable, message: "Use lineLength") - public var pageMargins: Double? { nil } + public var pageMargins: Double? { + nil + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: Bool? { nil } + public var publisherStyles: Bool? { + nil + } @available(*, unavailable, message: "Use the other initializer") public init( @@ -223,7 +240,9 @@ public struct EPUBSettings: ConfigurableSettings { typeScale: Double?, verticalText: Bool, wordSpacing: Double? - ) { fatalError() } + ) { + fatalError() + } } /// Default setting values for the EPUB navigator. @@ -234,6 +253,7 @@ public struct EPUBSettings: ConfigurableSettings { /// See `EPUBPreferences`. public struct EPUBDefaults { public var columnCount: Int? + public var fit: Fit? public var fontSize: Double? public var fontWeight: Double? public var hyphens: Bool? @@ -242,6 +262,7 @@ public struct EPUBDefaults { public var ligatures: Bool? public var lineLength: Double? public var lineHeight: Double? + public var offsetFirstPage: Bool? public var paragraphIndent: Double? public var paragraphSpacing: Double? public var readingProgression: ReadingProgression? @@ -253,6 +274,7 @@ public struct EPUBDefaults { public init( columnCount: Int? = nil, + fit: Fit? = nil, fontSize: Double? = nil, fontWeight: Double? = nil, hyphens: Bool? = nil, @@ -261,6 +283,7 @@ public struct EPUBDefaults { ligatures: Bool? = nil, lineLength: Double? = nil, lineHeight: Double? = nil, + offsetFirstPage: Bool? = nil, paragraphIndent: Double? = nil, paragraphSpacing: Double? = nil, readingProgression: ReadingProgression? = nil, @@ -268,10 +291,10 @@ public struct EPUBDefaults { spread: Spread? = nil, textAlign: TextAlignment? = nil, textNormalization: Bool? = nil, - typeScale: Double? = nil, wordSpacing: Double? = nil ) { self.columnCount = columnCount + self.fit = fit self.fontSize = fontSize self.fontWeight = fontWeight self.hyphens = hyphens @@ -280,6 +303,7 @@ public struct EPUBDefaults { self.ligatures = ligatures self.lineLength = lineLength self.lineHeight = lineHeight + self.offsetFirstPage = offsetFirstPage self.paragraphIndent = paragraphIndent self.paragraphSpacing = paragraphSpacing self.readingProgression = readingProgression @@ -291,20 +315,29 @@ public struct EPUBDefaults { } @available(*, unavailable, message: "Use lineLength instead") - public var pageMargins: Double? { nil } + public var pageMargins: Double? { + nil + } @available(*, unavailable, message: "Not supported anymore") - public var typeScale: Double? { nil } + public var typeScale: Double? { + nil + } @available(*, unavailable, message: "Not needed anymore") - public var publisherStyles: Bool? { nil } + public var publisherStyles: Bool? { + nil + } @available(*, unavailable, message: "Not supported anymore as a defaults") - public var imageFilter: ImageFilter? { nil } + public var imageFilter: ImageFilter? { + nil + } @available(*, unavailable, message: "Use the other initializer") public init( columnCount: ColumnCount? = nil, + fit: Fit? = nil, fontSize: Double? = nil, fontWeight: Double? = nil, hyphens: Bool? = nil, @@ -313,6 +346,7 @@ public struct EPUBDefaults { letterSpacing: Double? = nil, ligatures: Bool? = nil, lineHeight: Double? = nil, + offsetFirstPage: Bool? = nil, pageMargins: Double? = nil, paragraphIndent: Double? = nil, paragraphSpacing: Double? = nil, @@ -324,7 +358,9 @@ public struct EPUBDefaults { textNormalization: Bool? = nil, typeScale: Double? = nil, wordSpacing: Double? = nil - ) { fatalError() } + ) { + fatalError() + } } private extension Language { diff --git a/Sources/Navigator/EPUB/Scripts/package.json b/Sources/Navigator/EPUB/Scripts/package.json index 4aad9b38ec..bd7814bf9a 100644 --- a/Sources/Navigator/EPUB/Scripts/package.json +++ b/Sources/Navigator/EPUB/Scripts/package.json @@ -7,6 +7,7 @@ "private": true, "scripts": { "bundle": "webpack", + "bundle:minify": "MINIFY_CSS=true webpack", "lint": "eslint src", "checkformat": "prettier --check '**/*.js'", "format": "prettier --list-different --write '**/*.js'" @@ -17,7 +18,10 @@ "devDependencies": { "@babel/core": "^7.23.9", "@babel/preset-env": "^7.23.9", + "@readium/css": "^2.0.0", "babel-loader": "^8.3.0", + "clean-css": "^5.3.3", + "copy-webpack-plugin": "^14.0.0", "eslint": "^7.32.0", "prettier": "2.3.1", "webpack": "^5.90.1", diff --git a/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml b/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml index 75eb2605e6..27b19169e8 100644 --- a/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml +++ b/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml @@ -25,9 +25,18 @@ devDependencies: '@babel/preset-env': specifier: ^7.23.9 version: 7.23.9(@babel/core@7.23.9) + '@readium/css': + specifier: ^2.0.0 + version: 2.0.0 babel-loader: specifier: ^8.3.0 version: 8.3.0(@babel/core@7.23.9)(webpack@5.90.1) + clean-css: + specifier: ^5.3.3 + version: 5.3.3 + copy-webpack-plugin: + specifier: ^14.0.0 + version: 14.0.0(webpack@5.90.1) eslint: specifier: ^7.32.0 version: 7.32.0 @@ -1305,6 +1314,10 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: false + /@readium/css@2.0.0: + resolution: {integrity: sha512-Cr+AlHf5JqdwWPQ3ihw4HzsN3BkYyhOnD/fx6/+Y1QrTr1cIj1/xpGmixYyxn3kfLME00CX+OfhJeScYB1ANIw==} + dev: true + /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} dependencies: @@ -1512,6 +1525,17 @@ packages: hasBin: true dev: true + /ajv-formats@2.1.1(ajv@8.12.0): + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.12.0 + dev: true + /ajv-keywords@3.5.2(ajv@6.12.6): resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -1520,6 +1544,15 @@ packages: ajv: 6.12.6 dev: true + /ajv-keywords@5.1.0(ajv@8.12.0): + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + dependencies: + ajv: 8.12.0 + fast-deep-equal: 3.1.3 + dev: true + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -1726,6 +1759,13 @@ packages: engines: {node: '>=6.0'} dev: true + /clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + dependencies: + source-map: 0.6.1 + dev: true + /clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -1781,6 +1821,20 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /copy-webpack-plugin@14.0.0(webpack@5.90.1): + resolution: {integrity: sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==} + engines: {node: '>= 20.9.0'} + peerDependencies: + webpack: ^5.1.0 + dependencies: + glob-parent: 6.0.2 + normalize-path: 3.0.0 + schema-utils: 4.3.3 + serialize-javascript: 7.0.3 + tinyglobby: 0.2.15 + webpack: 5.90.1(webpack-cli@5.1.4) + dev: true + /core-js-compat@3.35.1: resolution: {integrity: sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==} dependencies: @@ -2104,6 +2158,18 @@ packages: engines: {node: '>= 4.9.1'} dev: true + /fdir@6.5.0(picomatch@4.0.3): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.3 + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2209,6 +2275,13 @@ packages: is-glob: 4.0.3 dev: true + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + /glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: true @@ -2646,6 +2719,11 @@ packages: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} dev: true + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} dev: false @@ -2732,6 +2810,11 @@ packages: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true + /picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + dev: true + /pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -2907,6 +2990,16 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + ajv-keywords: 5.1.0(ajv@8.12.0) + dev: true + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2926,6 +3019,11 @@ packages: randombytes: 2.1.0 dev: true + /serialize-javascript@7.0.3: + resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==} + engines: {node: '>=20.0.0'} + dev: true + /set-function-length@1.2.1: resolution: {integrity: sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==} engines: {node: '>= 0.4'} @@ -3142,6 +3240,14 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + dev: true + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} diff --git a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js index f5bc1c12e1..15baa4bcf4 100644 --- a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js +++ b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js @@ -4,30 +4,66 @@ // available in the top-level LICENSE file of the project. // +// Page layout types. +export const PageType = { + SINGLE: "single", + SPREAD_LEFT: "spread-left", + SPREAD_RIGHT: "spread-right", + SPREAD_CENTER: "spread-center", +}; + +// Fit modes for scaling content. +export const Fit = { + AUTO: "auto", + PAGE: "page", + WIDTH: "width", +}; + // Manages a fixed layout resource embedded in an iframe. -export function FixedPage(iframeId) { +// @param iframeId - ID of the iframe element +// @param pageType - Type of page layout from PageType enum +export function FixedPage(iframeId, pageType) { // Fixed dimensions for the page, extracted from the viewport meta tag. var _pageSize = null; // Available viewport size to fill with the resource. var _viewportSize = null; // Margins that should not overlap the content. var _safeAreaInsets = null; + // Fit mode for scaling the page. + var _fit = Fit.AUTO; + // Type of page layout (determines centering behavior). + var _pageType = Object.values(PageType).includes(pageType) + ? pageType + : PageType.SINGLE; // iFrame containing the page. var _iframe = document.getElementById(iframeId); - _iframe.addEventListener("load", loadPageSize); + _iframe.addEventListener("load", onLoad); // Viewport element containing the iFrame. var _viewport = _iframe.closest(".viewport"); + function onLoad() { + // Parses the page size from the viewport meta tag of the loaded resource, + // or extracts natural dimensions from images loaded directly in the iframe. + // As a fallback, we consider that the page spans the size of the viewport. + _pageSize = + parsePageSizeFromViewportMetaTag() ?? + parsePageSizeFromEmbeddedImage() ?? + _viewportSize; + + layoutPage(); + } + // Parses the page size from the viewport meta tag of the loaded resource. - function loadPageSize() { + function parsePageSizeFromViewportMetaTag() { var viewport = _iframe.contentWindow.document.querySelector( "meta[name=viewport]" ); if (!viewport) { - return; + return null; } + var regex = /(\w+) *= *([^\s,]+)/g; var properties = {}; var match; @@ -36,13 +72,26 @@ export function FixedPage(iframeId) { } var width = Number.parseFloat(properties.width); var height = Number.parseFloat(properties.height); - if (width && height) { - _pageSize = { width: width, height: height }; - layoutPage(); + if (!width || !height) { + return null; } + + return { width: width, height: height }; } - // Layouts the page iframe to center its content and scale it to fill the available viewport. + // Parses the page size from the natural dimensions of images loaded directly in the iframe. + // + // When a browser loads an image URL in an iframe, it renders the image + // in a minimal HTML document with an element. + function parsePageSizeFromEmbeddedImage() { + var img = _iframe.contentWindow.document.querySelector("img"); + if (!img || !img.naturalWidth || !img.naturalHeight) { + return null; + } + return { width: img.naturalWidth, height: img.naturalHeight }; + } + + // Layouts the page iframe and scale it according to the current fit mode. function layoutPage() { if (!_pageSize || !_viewportSize || !_safeAreaInsets) { return; @@ -50,19 +99,72 @@ export function FixedPage(iframeId) { _iframe.style.width = _pageSize.width + "px"; _iframe.style.height = _pageSize.height + "px"; - _iframe.style.marginTop = - _safeAreaInsets.top - _safeAreaInsets.bottom + "px"; // Calculates the zoom scale required to fit the content to the viewport. var widthRatio = _viewportSize.width / _pageSize.width; var heightRatio = _viewportSize.height / _pageSize.height; - var scale = Math.min(widthRatio, heightRatio); + var scale; + + switch (_fit) { + case Fit.WIDTH: + // Fit to width only. + scale = widthRatio; + break; + // Auto is equivalent to page in paginated mode, we don't have a scroll mode for FXL. + case Fit.AUTO: + case Fit.PAGE: + default: + // Fit both dimensions. + scale = Math.min(widthRatio, heightRatio); + break; + } + + // Calculate the scaled height of the content + var scaledHeight = _pageSize.height * scale; + + // Determine the appropriate transform based on page type. + // Single page and center page in spread need horizontal centering. + // Left/right pages in spread don't need horizontal transform. + var needsHorizontalCenter = + _pageType === PageType.SINGLE || _pageType === PageType.SPREAD_CENTER; + + // For width fit, if content overflows vertically, align to top + // For page fit, center the content vertically + if (_fit === Fit.WIDTH && scaledHeight > _viewportSize.height) { + // Content overflows: align to top with safe area inset + // Override the CSS centering + _iframe.style.top = _safeAreaInsets.top + "px"; + if (needsHorizontalCenter) { + _iframe.style.transform = "translateX(-50%)"; + } else { + _iframe.style.transform = "none"; + } + } else { + // Content fits or is page fit: center vertically + // Keep the CSS centering but adjust for safe area insets + var verticalOffset = _safeAreaInsets.top - _safeAreaInsets.bottom; + _iframe.style.top = "calc(50% + " + verticalOffset + "px)"; + if (needsHorizontalCenter) { + _iframe.style.transform = "translate(-50%, -50%)"; + } else { + _iframe.style.transform = "translateY(-50%)"; + } + } // Sets the viewport of the wrapper page (this page) to scale the iframe. var viewport = document.querySelector("meta[name=viewport]"); viewport.content = "initial-scale=" + scale + ", minimum-scale=" + scale; } + // Sets the iframe source URL. + function setIframeSrc(url) { + // Release the memory of a previously created blob URL, if needed. + if (_iframe.src.startsWith("blob:")) { + URL.revokeObjectURL(_iframe.src); + } + _iframe.src = url; + } + return { // Returns whether the page is currently loading its contents. isLoading: false, @@ -101,17 +203,20 @@ export function FixedPage(iframeId) { } _iframe.addEventListener("load", loaded); - _iframe.src = resource.url; + + var url = resourceUrl(resource); + setIframeSrc(url); }, - // Resets the page and empty its contents. + // Resets the page and empties its contents. reset: function () { if (!this.link) { return; } this.link = null; _pageSize = null; - _iframe.src = "about:blank"; + + setIframeSrc("about:blank"); }, // Evaluates a script in the context of the page. @@ -123,9 +228,12 @@ export function FixedPage(iframeId) { }, // Updates the available viewport to display the resource. - setViewport: function (viewportSize, safeAreaInsets) { + setViewport: function (viewportSize, safeAreaInsets, fit) { _viewportSize = viewportSize; _safeAreaInsets = safeAreaInsets; + if (Object.values(Fit).includes(fit)) { + _fit = fit; + } layoutPage(); }, @@ -140,3 +248,46 @@ export function FixedPage(iframeId) { }, }; } + +// Returns the URL to load for the given resource. +// Bitmap images are wrapped in an HTML document with alt text for accessibility. +function resourceUrl(resource) { + if (isBitmapMediaType(resource.link.type)) { + let html = generateImageWrapper(resource.url, resource.link.title); + let blob = new Blob([html], { type: "text/html" }); + return URL.createObjectURL(blob); + } else { + return resource.url; + } +} + +// Helper to detect bitmap media types. +function isBitmapMediaType(type) { + if (!type) return false; + return type.startsWith("image/") && !type.includes("svg"); +} + +// Generate an HTML wrapper with alt text for the bitmap at `imageUrl`. +function generateImageWrapper(imageUrl, altText) { + let doc = document.implementation.createHTMLDocument(""); + + let meta = doc.createElement("meta"); + meta.name = "viewport"; + meta.content = "width=device-width, height=device-height"; + doc.head.appendChild(meta); + + let style = doc.createElement("style"); + style.textContent = + "body { margin: 0; }\n" + + "img { display: block; width: 100%; height: 100%; object-fit: contain; }"; + doc.head.appendChild(style); + + let img = doc.createElement("img"); + img.src = imageUrl; + if (altText) { + img.alt = altText; + } + doc.body.appendChild(img); + + return "\n" + doc.documentElement.outerHTML; +} diff --git a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js index ea0224dcb8..f1f6f43061 100644 --- a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js +++ b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-one.js @@ -6,9 +6,9 @@ // Script used for the single spread wrapper HTML page for fixed layout resources. -import { FixedPage } from "./fixed-page"; +import { FixedPage, PageType } from "./fixed-page"; -var page = FixedPage("page"); +var page = FixedPage("page", PageType.SINGLE); // Public API called from Swift. global.spread = { @@ -30,7 +30,7 @@ global.spread = { }, // Updates the available viewport to display the resources. - setViewport: function (viewportSize, safeAreaInsets) { - page.setViewport(viewportSize, safeAreaInsets); + setViewport: function (viewportSize, safeAreaInsets, fit) { + page.setViewport(viewportSize, safeAreaInsets, fit); }, }; diff --git a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js index a2848eec90..90ebe221d6 100644 --- a/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js +++ b/Sources/Navigator/EPUB/Scripts/src/index-fixed-wrapper-two.js @@ -6,12 +6,12 @@ // Script used for the single spread wrapper HTML page for fixed layout resources. -import { FixedPage } from "./fixed-page"; +import { FixedPage, PageType } from "./fixed-page"; var pages = { - left: FixedPage("page-left"), - right: FixedPage("page-right"), - center: FixedPage("page-center"), + left: FixedPage("page-left", PageType.SPREAD_LEFT), + right: FixedPage("page-right", PageType.SPREAD_RIGHT), + center: FixedPage("page-center", PageType.SPREAD_CENTER), }; function forEachPage(callback) { @@ -76,28 +76,44 @@ global.spread = { }, // Updates the available viewport to display the resources. - setViewport: function (viewportSize, safeAreaInsets) { - viewportSize.width /= 2; + setViewport: function (viewportSize, safeAreaInsets, fit) { + var halfViewportSize = { + width: viewportSize.width / 2, + height: viewportSize.height, + }; - pages.left.setViewport(viewportSize, { - top: safeAreaInsets.top, - right: 0, - bottom: safeAreaInsets.bottom, - left: safeAreaInsets.left, - }); + pages.left.setViewport( + halfViewportSize, + { + top: safeAreaInsets.top, + right: 0, + bottom: safeAreaInsets.bottom, + left: safeAreaInsets.left, + }, + fit + ); - pages.right.setViewport(viewportSize, { - top: safeAreaInsets.top, - right: safeAreaInsets.right, - bottom: safeAreaInsets.bottom, - left: 0, - }); + pages.right.setViewport( + halfViewportSize, + { + top: safeAreaInsets.top, + right: safeAreaInsets.right, + bottom: safeAreaInsets.bottom, + left: 0, + }, + fit + ); - pages.center.setViewport(viewportSize, { - top: safeAreaInsets.top, - right: 0, - bottom: safeAreaInsets.bottom, - left: 0, - }); + // Center pages use the full viewport to fit the screen. + pages.center.setViewport( + viewportSize, + { + top: safeAreaInsets.top, + right: safeAreaInsets.right, + bottom: safeAreaInsets.bottom, + left: safeAreaInsets.left, + }, + fit + ); }, }; diff --git a/Sources/Navigator/EPUB/Scripts/webpack.config.js b/Sources/Navigator/EPUB/Scripts/webpack.config.js index d5072bca02..ea411824d9 100644 --- a/Sources/Navigator/EPUB/Scripts/webpack.config.js +++ b/Sources/Navigator/EPUB/Scripts/webpack.config.js @@ -1,8 +1,11 @@ const path = require("path"); +const CopyPlugin = require("copy-webpack-plugin"); +const CleanCSS = require("clean-css"); module.exports = { mode: "production", devtool: "source-map", + // devtool: "eval-source-map", entry: { reflowable: "./src/index-reflowable.js", fixed: "./src/index-fixed.js", @@ -27,4 +30,26 @@ module.exports = { }, ], }, + plugins: [ + new CopyPlugin({ + patterns: [ + { + from: "node_modules/@readium/css/css/dist", + to: "../readium-css", + transform(content, path) { + if (path.endsWith(".css") && process.env.MINIFY_CSS === "true") { + return new CleanCSS({ + level: { + 1: { + specialComments: 0, + }, + }, + }).minify(content).styles; + } + return content; + }, + }, + ], + }), + ], }; diff --git a/Sources/Navigator/EPUB/WebViewServer.swift b/Sources/Navigator/EPUB/WebViewServer.swift new file mode 100644 index 0000000000..e85bac9337 --- /dev/null +++ b/Sources/Navigator/EPUB/WebViewServer.swift @@ -0,0 +1,358 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal +import ReadiumShared +import WebKit + +/// A generic `WKURLSchemeHandler` that serves files, directories, and +/// arbitrary resources at named routes using a custom URL scheme (e.g. +/// `readium://`). +@MainActor final class WebViewServer: NSObject, WKURLSchemeHandler, Loggable { + /// The custom scheme used to serve the content. + let scheme: String + + /// Format sniffer used to infer the media type of served resources. + let formatSniffer: FormatSniffer + + init(scheme: String, formatSniffer: FormatSniffer) { + self.scheme = scheme + self.formatSniffer = formatSniffer + super.init() + } + + // MARK: - Route registration + + private enum RouteHandler { + case file(FileURL) + case directory(FileURL) + case resources(@MainActor (RelativeURL) async -> (Resource, MediaType)?) + } + + /// Registered routes, sorted by reverse alphabetical order to ensure + /// longest-prefix matching of routes sharing a common prefix. + private var routes: [(path: String, baseURL: AbsoluteURL, handler: RouteHandler)] = [] + + /// Serves a single local file at the given route. + /// + /// - Returns: The absolute URL (e.g. `readium://assets/fonts/abc/Font.otf`) + /// to the served file. + @discardableResult + func serve(file: FileURL, at route: String) -> AbsoluteURL { + let route = normalizedRoute(route) + let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! + insertRoute((path: route, baseURL: baseURL, handler: .file(file))) + return baseURL + } + + /// Serves a local directory at the given route. + /// + /// All files under the directory are accessible. + /// + /// - Returns: The absolute base URL (e.g. `readium://assets/`) to the + /// served directory. + @discardableResult + func serve(directory: FileURL, at route: String) -> AbsoluteURL { + let route = normalizedRoute(route, isDirectory: true) + let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! + insertRoute((path: route, baseURL: baseURL, handler: .directory(directory))) + return baseURL + } + + /// Serves resources at the given route using a handler callback. + /// + /// The handler receives a relative URL and returns a `Resource`, or + /// `nil` for 404. Returned resources are automatically wrapped in a + /// `BufferingResource` cache. + /// + /// Returns the base URL (e.g. `readium://{uuid}/`). + @discardableResult + func serve(at route: String, handler: @escaping @MainActor (RelativeURL) async -> (Resource, MediaType)?) -> AbsoluteURL { + let route = normalizedRoute(route, isDirectory: true) + let baseURL = AnyURL(string: "\(scheme)://\(route)")!.absoluteURL! + insertRoute((path: route, baseURL: baseURL, handler: .resources(handler))) + return baseURL + } + + /// Removes the handler at the given route. + func remove(at route: String) { + let route = normalizedRoute(route) + routes.removeAll { $0.path.hasPrefix(route) } + } + + private func normalizedRoute(_ route: String, isDirectory: Bool = false) -> String { + var r = route.removingPrefix("/") + if isDirectory { + r = r.addingSuffix("/") + } + return r + } + + private func insertRoute(_ entry: (path: String, baseURL: AbsoluteURL, handler: RouteHandler)) { + // Remove any existing route with the same path. + routes.removeAll { $0.path == entry.path } + routes.append(entry) + // Reverse alphabetical order ensures longest-prefix matching: + // routes sharing a common prefix are grouped with longer ones first. + routes.sort { $0.path > $1.path } + } + + // MARK: - Active tasks & caching + + /// Tracks active tasks for cancellation support. + private var activeTasks: [ObjectIdentifier: Task] = [:] + + /// Bounded cache of buffered resources keyed by publication-relative URL. + /// + /// Reusing the same ``Resource`` across requests lets compressed ZIP + /// resources benefit from forward-seek optimization instead of + /// decompressing from offset 0 on every request. + /// + /// Oldest entries are evicted when the cache exceeds its capacity. + private var resourceCache = BoundedResourceCache() + + // MARK: - WKURLSchemeHandler + + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask) + activeTasks[taskID] = Task { + await serve(urlSchemeTask) + _ = activeTasks.removeValue(forKey: taskID) + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask) + activeTasks.removeValue(forKey: taskID)?.cancel() + } + + // MARK: - Serving + + private func serve(_ urlSchemeTask: WKURLSchemeTask) async { + guard let requestURL = urlSchemeTask.request.url else { + await fail(urlSchemeTask, with: URLError(.badURL)) + return + } + + // Find the matching route (longest prefix wins). + for route in routes { + switch route.handler { + case let .file(file): + guard route.baseURL.isEquivalentTo(requestURL) else { + continue + } + await serveFile(urlSchemeTask, at: file, requestURL: requestURL) + return + + case let .directory(directory): + guard + let relativeURL = route.baseURL.relativize(requestURL), + let file = directory.resolve(relativeURL)?.fileURL, + directory.isParent(of: file) + else { + continue + } + await serveFile(urlSchemeTask, at: file, requestURL: requestURL) + return + + case let .resources(handler): + guard let relativeURL = route.baseURL.relativize(requestURL) else { + continue + } + await serveResource( + urlSchemeTask, + relativeURL: relativeURL, + handler: handler, + requestURL: requestURL + ) + return + } + } + + await fail(urlSchemeTask, with: URLError(.fileDoesNotExist)) + } + + /// Serves a resource from a handler callback, with caching. + private func serveResource( + _ urlSchemeTask: WKURLSchemeTask, + relativeURL: RelativeURL, + handler: @MainActor (RelativeURL) async -> (Resource, MediaType)?, + requestURL: URL + ) async { + // Reuse a cached buffered resource to benefit from forward-seek + // optimization and read-ahead buffering, or create and cache a new + // one. + let resource: Resource + let mediaType: MediaType + if let (cachedResource, cachedMediaType) = resourceCache[relativeURL] { + resource = cachedResource + mediaType = cachedMediaType + } else { + guard let (newResource, newMediaType) = await handler(relativeURL) else { + await fail(urlSchemeTask, with: URLError(.fileDoesNotExist)) + return + } + resource = newResource.buffered(size: 256 * 1024) + mediaType = newMediaType + resourceCache.set(relativeURL, resource: resource, mediaType: mediaType) + } + + await serveResource( + resource, + with: urlSchemeTask, + mediaType: mediaType, + requestURL: requestURL + ) + } + + /// Reads a local file and sends it as a response. + private func serveFile( + _ urlSchemeTask: WKURLSchemeTask, + at file: FileURL, + requestURL: URL + ) async { + await serveResource( + FileResource(file: file), + with: urlSchemeTask, + mediaType: mediaTypeFromURL(file), + requestURL: requestURL + ) + } + + private func serveResource( + _ resource: Resource, + with urlSchemeTask: WKURLSchemeTask, + mediaType: MediaType?, + requestURL: URL + ) async { + // Try to serve a byte range if the client requested one and the + // resource length is known. + if + let totalLength = await (try? resource.estimatedLength().get()).flatMap({ $0 }), + let range = urlSchemeTask.request.byteRange(in: totalLength) + { + let result = await resource.read(range: range) + switch result { + case let .success(data): + await respond(urlSchemeTask, with: data, range: range, totalLength: totalLength, mediaType: mediaType, url: requestURL) + case let .failure(error): + log(.error, "Failed to read resource \(requestURL.path) range \(range): \(error)") + await fail(urlSchemeTask, with: URLError(.resourceUnavailable)) + } + return + } + + // Full read fallback. + let result = await resource.read() + switch result { + case let .success(data): + await respond(urlSchemeTask, with: data, range: nil, totalLength: UInt64(data.count), mediaType: mediaType, url: requestURL) + case let .failure(error): + log(.error, "Failed to read resource \(requestURL.path): \(error)") + await fail(urlSchemeTask, with: URLError(.resourceUnavailable)) + } + } + + private func mediaTypeFromURL(_ url: URLConvertible) -> MediaType? { + guard let ext = url.anyURL.pathExtension else { + return nil + } + return formatSniffer.sniffHints(FormatHints(fileExtension: ext))?.mediaType + } + + // MARK: - Response helpers + + /// Sends data as a response, optionally as a 206 Partial Content when a + /// byte range was requested. + /// + /// - Parameters: + /// - range: The byte range being served, or `nil` for a full 200 + /// response. + /// - totalLength: The total size of the resource (used in + /// `Content-Range`). + private func respond( + _ urlSchemeTask: WKURLSchemeTask, + with data: Data, + range: Range?, + totalLength: UInt64, + mediaType: MediaType?, + url: URL + ) async { + var headers: [String: String] = [ + "Content-Length": "\(data.count)", + "Accept-Ranges": "bytes", + ] + + if let mediaType { + headers["Content-Type"] = mediaType.string + } + + let statusCode: Int + if let range = range { + statusCode = 206 + headers["Content-Range"] = "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(totalLength)" + } else { + statusCode = 200 + } + + guard let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers + ) else { + await fail(urlSchemeTask, with: URLError(.unknown)) + return + } + + // Guard against task cancellation to avoid calling WKURLSchemeTask + // methods after WebKit has stopped the task. + guard !Task.isCancelled else { return } + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + private func fail(_ urlSchemeTask: WKURLSchemeTask, with error: Error) async { + guard !Task.isCancelled else { return } + urlSchemeTask.didFailWithError(error) + } +} + +private extension URLRequest { + /// Parses an HTTP `Range` header value (RFC 7233) into a byte range. + func byteRange(in totalLength: UInt64) -> Range? { + Range(httpRange: value(forHTTPHeaderField: "Range") ?? "", in: totalLength) + } +} + +/// A simple bounded FIFO cache for ``Resource`` instances. +/// +/// Evicts the oldest entries when the number of cached resources exceeds +/// ``capacity``, preventing unbounded memory growth as the user navigates +/// through chapters. +private struct BoundedResourceCache { + private let capacity = 8 + private var entries: [RelativeURL: (Resource, MediaType)] = [:] + private var order: [RelativeURL] = [] + + subscript(key: RelativeURL) -> (Resource, MediaType)? { + entries[key] + } + + mutating func set(_ key: RelativeURL, resource: Resource, mediaType: MediaType) { + if entries[key] == nil { + order.append(key) + } + entries[key] = (resource, mediaType) + + while order.count > capacity { + let evicted = order.removeFirst() + entries.removeValue(forKey: evicted) + } + } +} diff --git a/Sources/Navigator/EditingAction.swift b/Sources/Navigator/EditingAction.swift index 7203c9b9ac..4a001d6c9b 100644 --- a/Sources/Navigator/EditingAction.swift +++ b/Sources/Navigator/EditingAction.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/CompositeInputObserver.swift b/Sources/Navigator/Input/CompositeInputObserver.swift index 18716b4d63..0beb8783eb 100644 --- a/Sources/Navigator/Input/CompositeInputObserver.swift +++ b/Sources/Navigator/Input/CompositeInputObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/InputObservable+Legacy.swift b/Sources/Navigator/Input/InputObservable+Legacy.swift index b2074bc4f9..150357025c 100644 --- a/Sources/Navigator/Input/InputObservable+Legacy.swift +++ b/Sources/Navigator/Input/InputObservable+Legacy.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/InputObservable.swift b/Sources/Navigator/Input/InputObservable.swift index e34236397c..363c0ab49c 100644 --- a/Sources/Navigator/Input/InputObservable.swift +++ b/Sources/Navigator/Input/InputObservable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,19 +9,19 @@ import Foundation /// A type broadcasting user input events (e.g. touch or keyboard events) to /// a set of observers. @MainActor public protocol InputObservable { - /// Registers a new ``InputObserver`` for the observable receiver. + /// Registers a new `InputObserver` for the observable receiver. /// /// - Returns: An opaque token which can be used to remove the observer with - /// `removeInputObserver`. + /// `removeObserver`. @discardableResult func addObserver(_ observer: InputObserving) -> InputObservableToken - /// Unregisters an ``InputObserver`` from this receiver using the given - /// `token` returned by `addInputObserver`. + /// Unregisters an `InputObserver` from this receiver using the given + /// `token` returned by `addObserver`. func removeObserver(_ token: InputObservableToken) } -/// A token which can be used to remove an ``InputObserver`` from an +/// A token which can be used to remove an `InputObserver` from an /// ``InputObservable``. public struct InputObservableToken: Hashable, Identifiable { public let id: AnyHashable diff --git a/Sources/Navigator/Input/InputObservableViewController.swift b/Sources/Navigator/Input/InputObservableViewController.swift index bc96642d4f..fa6ba1744d 100644 --- a/Sources/Navigator/Input/InputObservableViewController.swift +++ b/Sources/Navigator/Input/InputObservableViewController.swift @@ -1,12 +1,12 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // import UIKit -/// Base implementation of ``UIViewController`` which implements +/// Base implementation of `UIViewController` which implements /// ``InputObservable`` to forward UIKit touches and presses events to /// observers. open class InputObservableViewController: UIViewController, InputObservable { @@ -31,7 +31,9 @@ open class InputObservableViewController: UIViewController, InputObservable { // MARK: - UIResponder - override open var canBecomeFirstResponder: Bool { true } + override open var canBecomeFirstResponder: Bool { + true + } override open func resignFirstResponder() -> Bool { // Force end editing of the view to make sure any subview is also @@ -197,7 +199,6 @@ extension Key { self = .shift case .keyboardEscape: self = .escape - default: let character = key.charactersIgnoringModifiers guard character != "" else { diff --git a/Sources/Navigator/Input/InputObserving.swift b/Sources/Navigator/Input/InputObserving.swift index 35ddb1b751..762e9e0d04 100644 --- a/Sources/Navigator/Input/InputObserving.swift +++ b/Sources/Navigator/Input/InputObserving.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift b/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift index 3383b71724..7a51105365 100644 --- a/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift +++ b/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/Key/Key.swift b/Sources/Navigator/Input/Key/Key.swift index 7ecd05ce39..8c5f5de167 100644 --- a/Sources/Navigator/Input/Key/Key.swift +++ b/Sources/Navigator/Input/Key/Key.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,7 +8,7 @@ import Foundation import UIKit public enum Key: Equatable, CustomStringConvertible { - // Printable character. + /// Printable character. case character(String) // Whitespace keys. diff --git a/Sources/Navigator/Input/Key/KeyEvent.swift b/Sources/Navigator/Input/Key/KeyEvent.swift index bef1086144..e170a27163 100644 --- a/Sources/Navigator/Input/Key/KeyEvent.swift +++ b/Sources/Navigator/Input/Key/KeyEvent.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/Key/KeyModifiers.swift b/Sources/Navigator/Input/Key/KeyModifiers.swift index 99338e2fa6..98542fce85 100644 --- a/Sources/Navigator/Input/Key/KeyModifiers.swift +++ b/Sources/Navigator/Input/Key/KeyModifiers.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/Key/KeyObserver.swift b/Sources/Navigator/Input/Key/KeyObserver.swift index b13b39000e..ddf1bdcd95 100644 --- a/Sources/Navigator/Input/Key/KeyObserver.swift +++ b/Sources/Navigator/Input/Key/KeyObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift b/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift index c952296105..5765e05bf9 100644 --- a/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift +++ b/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Input/Pointer/DragPointerObserver.swift b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift new file mode 100644 index 0000000000..2b3728ba23 --- /dev/null +++ b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift @@ -0,0 +1,138 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +public extension InputObserving where Self == DragPointerObserver { + static func drag( + onStart: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }, + onMove: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }, + onEnd: @MainActor @escaping (PointerEvent) -> Bool = { _ in false }, + onCancel: @MainActor @escaping (PointerEvent) -> Bool = { _ in false } + ) -> DragPointerObserver { + DragPointerObserver( + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + } +} + +/// Pointer observer recognizing drag gestures. +@MainActor public final class DragPointerObserver: InputObserving { + private let onStart: @MainActor (PointerEvent) -> Bool + private let onMove: @MainActor (PointerEvent) -> Bool + private let onEnd: @MainActor (PointerEvent) -> Bool + private let onCancel: @MainActor (PointerEvent) -> Bool + + public init( + onStart: @MainActor @escaping (PointerEvent) -> Bool, + onMove: @MainActor @escaping (PointerEvent) -> Bool, + onEnd: @MainActor @escaping (PointerEvent) -> Bool, + onCancel: @MainActor @escaping (PointerEvent) -> Bool + ) { + self.onStart = onStart + self.onMove = onMove + self.onEnd = onEnd + self.onCancel = onCancel + } + + private var state: State = .idle + + private enum State { + case idle + case pending(id: AnyHashable, startLocation: CGPoint) + case dragging(id: AnyHashable, lastEvent: PointerEvent) + case failed(activePointers: Set) + } + + private enum Action { + case start(PointerEvent) + case move(PointerEvent) + case end(PointerEvent) + case cancel(PointerEvent) + case none + } + + public func didReceive(_ event: KeyEvent) async -> Bool { + false + } + + public func didReceive(_ event: PointerEvent) async -> Bool { + let (newState, action) = transition(state: state, event: event) + state = newState + + switch action { + case let .start(event): + return onStart(event) + case let .move(event): + return onMove(event) + case let .end(event): + return onEnd(event) + case let .cancel(event): + return onCancel(event) + case .none: + return false + } + } + + private func transition(state: State, event: PointerEvent) -> (State, Action) { + let id = event.pointer.id + + switch (state, event.phase) { + case (.idle, .down): + return (.pending(id: id, startLocation: event.location), .none) + + case let (.pending(pendingID, _), .down) where pendingID != id: + return (.failed(activePointers: [pendingID, id]), .none) + + case let (.pending(pendingID, _), .cancel) where pendingID == id: + return (.idle, .none) + + case let (.pending(pendingID, startLocation), .move) where pendingID == id: + // Check if pointer has moved enough to start dragging. + if abs(startLocation.x - event.location.x) > 1 || abs(startLocation.y - event.location.y) > 1 { + return (.dragging(id: pendingID, lastEvent: event), .start(event)) + } else { + return (.pending(id: pendingID, startLocation: startLocation), .none) + } + + case let (.pending(pendingID, _), .up) where pendingID == id: + // Pointer went up without moving - this is a tap, not a drag. + return (.idle, .none) + + case let (.dragging(draggingID, lastEvent), .down) where draggingID != id: + // Second pointer detected during drag - cancel the drag + return (.failed(activePointers: [draggingID, id]), .cancel(lastEvent)) + + case let (.dragging(draggingID, lastEvent), .cancel) where draggingID == id: + return (.idle, .cancel(lastEvent)) + + case let (.dragging(draggingID, _), .move) where draggingID == id: + return (.dragging(id: draggingID, lastEvent: event), .move(event)) + + case let (.dragging(draggingID, _), .up) where draggingID == id: + return (.idle, .end(event)) + + case var (.failed(activePointers), .down): + activePointers.insert(id) + return (.failed(activePointers: activePointers), .none) + + case var (.failed(activePointers), .up), + var (.failed(activePointers), .cancel): + activePointers.remove(id) + if activePointers.isEmpty { + return (.idle, .none) + } else { + return (.failed(activePointers: activePointers), .none) + } + + default: + return (state, .none) + } + } +} diff --git a/Sources/Navigator/Input/Pointer/PointerEvent.swift b/Sources/Navigator/Input/Pointer/PointerEvent.swift index aeabdf0f48..f2a5973ef5 100644 --- a/Sources/Navigator/Input/Pointer/PointerEvent.swift +++ b/Sources/Navigator/Input/Pointer/PointerEvent.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Navigator.swift b/Sources/Navigator/Navigator.swift index 36652b3955..fb50bd5c26 100644 --- a/Sources/Navigator/Navigator.swift +++ b/Sources/Navigator/Navigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -25,7 +25,7 @@ public protocol Navigator: AnyObject { @discardableResult func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool - /// Moves to the position in the publication targeted by the given link. + // Moves to the position in the publication targeted by the given link. /// - Returns: Whether the navigator is able to move to the locator. The /// completion block is only called if true was returned. @@ -59,7 +59,7 @@ public struct NavigatorGoOptions { set { otherOptionsJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherOptionsJSON: JSONDictionary public init(animated: Bool = false, otherOptions: [String: Any] = [:]) { diff --git a/Sources/Navigator/PDF/PDFDocumentHolder.swift b/Sources/Navigator/PDF/PDFDocumentHolder.swift index 990ca9e6ba..aaebce28a8 100644 --- a/Sources/Navigator/PDF/PDFDocumentHolder.swift +++ b/Sources/Navigator/PDF/PDFDocumentHolder.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -26,7 +26,7 @@ extension PDFDocumentHolder: ReadiumShared.PDFDocumentFactory { return document } - public func open(resource: Resource, at href: HREF, password: String?) async throws -> ReadiumShared.PDFDocument { + func open(resource: Resource, at href: HREF, password: String?) async throws -> ReadiumShared.PDFDocument { guard let document = document, self.href == href.anyURL else { throw PDFDocumentError.openFailed } diff --git a/Sources/Navigator/PDF/PDFDocumentView.swift b/Sources/Navigator/PDF/PDFDocumentView.swift index d8de952200..bb61f6873c 100644 --- a/Sources/Navigator/PDF/PDFDocumentView.swift +++ b/Sources/Navigator/PDF/PDFDocumentView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -50,11 +50,31 @@ public final class PDFDocumentView: PDFView { } private func updateContentInset() { - let insets = documentViewDelegate?.pdfDocumentViewContentInset(self) ?? window?.safeAreaInsets ?? .zero + let insets = contentInset firstScrollView?.contentInset.top = insets.top firstScrollView?.contentInset.bottom = insets.bottom } + private var contentInset: UIEdgeInsets { + if let contentInset = documentViewDelegate?.pdfDocumentViewContentInset(self) { + return contentInset + } + + // We apply the window's safe area insets (representing the system + // status bar, but ignoring app bars) on iPhones only because in most + // cases we prefer to display the content edge-to-edge. + // iPhones are a special case because they are the only devices with a + // physical notch (or Dynamic Island) which is included in the window's + // safe area insets. Therefore, we must always take it into account to + // avoid hiding the content. + if UIDevice.current.userInterfaceIdiom == .phone { + return window?.safeAreaInsets ?? .zero + } else { + // Edge-to-edge on macOS and iPadOS. + return .zero + } + } + override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { super.canPerformAction(action, withSender: sender) && editingActions.canPerformAction(action) } @@ -70,4 +90,209 @@ public final class PDFDocumentView: PDFView { editingActions.buildMenu(with: builder) super.buildMenu(with: builder) } + + var isPaginated: Bool { + isUsingPageViewController || displayMode == .twoUp || displayMode == .singlePage + } + + var isSpreadEnabled: Bool { + displayMode == .twoUp || displayMode == .twoUpContinuous + } + + /// Returns whether the document is currently zoomed to match the given + /// `fit`. + func isAtScaleFactor(for fit: Fit) -> Bool { + let scaleFactorToFit = scaleFactor(for: fit) + // 1% tolerance for floating point comparison + let tolerance: CGFloat = 0.01 + return abs(scaleFactor - scaleFactorToFit) < tolerance + } + + /// Calculates the appropriate scale factor based on the fit preference. + /// + /// Only used in scroll mode, as the paginated mode doesn't support custom + /// scale factors without visual hiccups when swiping pages. + func scaleFactor(for fit: Fit) -> CGFloat { + // While a `width` fit works in scroll mode, the pagination mode has + // critical limitations when zooming larger than the page fit, so it + // does not support a `width` fit. + // + // - Visual snap: There is no API to pre-set the zoom scale for the next + // page. PDFView resets the scale per page, causing a visible snap + // when swiping. We don’t see the issue with edge taps. + // - Incorrect anchoring: When zooming larger than the page fit, the + // viewport centers vertically instead of showing the top. The API to + // fix this works in scroll mode but is ignored in paginated mode. + // + // So we only support a `page` fit in paginated mode. + if isPaginated { + return scaleFactorForSizeToFitVisiblePages + } + + switch fit { + case .auto, .width: + // Use PDFKit's default auto-fit behavior + return scaleFactorForSizeToFit + case .page: + return scaleFactorForLargestPage + } + } + + /// Calculates the scale factor to fit the visible pages (by area) to the + /// viewport. + private var scaleFactorForSizeToFitVisiblePages: CGFloat { + // The native `scaleFactorForSizeToFit` is incorrect when displaying + // paginated spreads, so we need to use a custom implementation. + if !isPaginated || !isSpreadEnabled { + scaleFactorForSizeToFit + } else { + calculateScale( + for: spreadSize(for: visiblePages), + viewSize: bounds.size, + insets: contentInset + ) + } + } + + /// Calculates the scale factor to fit the largest page or spread (by area) + /// to the viewport. + private var scaleFactorForLargestPage: CGFloat { + guard let document = document else { + return 1.0 + } + + // Check cache before expensive calculation + let viewSize = bounds.size + let insets = contentInset + if + let cached = cachedScaleFactorForLargestPage, + cached.document == ObjectIdentifier(document), + cached.viewSize == viewSize, + cached.contentInset == insets, + cached.spread == isSpreadEnabled, + cached.displaysAsBook == displaysAsBook + { + return cached.scaleFactor + } + + var maxSize: CGSize = .zero + var maxArea: CGFloat = 0 + + if !isSpreadEnabled { + // No spreads: find largest individual page + for pageIndex in 0 ..< document.pageCount { + guard let page = document.page(at: pageIndex) else { continue } + let pageSize = page.bounds(for: displayBox).size + let area = pageSize.width * pageSize.height + + if area > maxArea { + maxArea = area + maxSize = pageSize + } + } + } else { + // Spreads enabled: find largest spread + let pageCount = document.pageCount + + if displaysAsBook, pageCount > 0 { + // First page displayed alone - check its size + if let firstPage = document.page(at: 0) { + let firstSize = firstPage.bounds(for: displayBox).size + let firstArea = firstSize.width * firstSize.height + if firstArea > maxArea { + maxArea = firstArea + maxSize = firstSize + } + } + } + + // Check spreads (pairs of pages) + let startIndex = displaysAsBook ? 1 : 0 + for pageIndex in stride(from: startIndex, to: pageCount, by: 2) { + let leftIndex = pageIndex + let rightIndex = pageIndex + 1 + + guard let leftPage = document.page(at: leftIndex) else { continue } + + if rightIndex < pageCount, let rightPage = document.page(at: rightIndex) { + // Two-page spread + let currentSpreadSize = spreadSize(for: [leftPage, rightPage]) + let spreadArea = currentSpreadSize.width * currentSpreadSize.height + + if spreadArea > maxArea { + maxArea = spreadArea + maxSize = currentSpreadSize + } + } else { + // Last page alone (odd page count) + let leftSize = leftPage.bounds(for: displayBox).size + let singleArea = leftSize.width * leftSize.height + if singleArea > maxArea { + maxArea = singleArea + maxSize = leftSize + } + } + } + } + + let scale = calculateScale( + for: maxSize, + viewSize: viewSize, + insets: insets + ) + + cachedScaleFactorForLargestPage = ( + document: ObjectIdentifier(document), + scaleFactor: scale, + viewSize: viewSize, + contentInset: insets, + spread: isSpreadEnabled, + displaysAsBook: displaysAsBook + ) + return scale + } + + /// Cache for expensive largest page scale calculation. + private var cachedScaleFactorForLargestPage: ( + document: ObjectIdentifier, + scaleFactor: CGFloat, + viewSize: CGSize, + contentInset: UIEdgeInsets, + spread: Bool, + displaysAsBook: Bool + )? + + /// Calculates the combined size of pages laid out side-by-side horizontally. + private func spreadSize(for pages: [PDFPage]) -> CGSize { + var size = CGSize.zero + for page in pages { + let pageBounds = page.bounds(for: displayBox) + size.height = max(size.height, pageBounds.height) + size.width += pageBounds.width + } + return size + } + + /// Calculates the scale factor needed to fit the given content size within + /// the available viewport, accounting for content insets. + private func calculateScale( + for contentSize: CGSize, + viewSize: CGSize, + insets: UIEdgeInsets + ) -> CGFloat { + guard contentSize.width > 0, contentSize.height > 0 else { + return 1.0 + } + + let availableSize = CGSize( + width: viewSize.width - insets.left - insets.right, + height: viewSize.height - insets.top - insets.bottom + ) + + let widthScale = availableSize.width / contentSize.width + let heightScale = availableSize.height / contentSize.height + + // Use the smaller scale to ensure both dimensions fit + return min(widthScale, heightScale) + } } diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 93ac53efe7..96e77fb5d7 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -57,7 +57,10 @@ open class PDFNavigatorViewController: } /// Whether the pages is always scaled to fit the screen, unless the user zoomed in. - public var scalesDocumentToFit = true + @available(*, unavailable, message: "This API is deprecated") + public var scalesDocumentToFit: Bool { + true + } public weak var delegate: PDFNavigatorDelegate? public private(set) var pdfView: PDFDocumentView? @@ -76,6 +79,8 @@ open class PDFNavigatorViewController: // Holds a reference to make sure they are not garbage-collected. private var tapGestureController: PDFTapGestureController? private var clickGestureController: PDFTapGestureController? + private var swipeLeftGestureRecognizer: UISwipeGestureRecognizer? + private var swipeRightGestureRecognizer: UISwipeGestureRecognizer? private let server: HTTPServer? private let publicationEndpoint: HTTPServerEndpoint? @@ -184,7 +189,7 @@ open class PDFNavigatorViewController: super.viewWillAppear(animated) // Hack to layout properly the first page when opening the PDF. - if let pdfView = pdfView, scalesDocumentToFit { + if let pdfView = pdfView { pdfView.scaleFactor = pdfView.minScaleFactor if let page = pdfView.currentPage { pdfView.go(to: page.bounds(for: pdfView.displayBox), on: page) @@ -195,14 +200,13 @@ open class PDFNavigatorViewController: override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - if let pdfView = pdfView, scalesDocumentToFit { - // Makes sure that the PDF is always properly scaled down when rotating the screen, if the user didn't zoom in. - let isAtMinScaleFactor = (pdfView.scaleFactor == pdfView.minScaleFactor) + if let pdfView = pdfView { + // Makes sure that the PDF is always properly scaled when rotating + // the screen, if the user didn't set a custom zoom. + let isAtScaleFactor = pdfView.isAtScaleFactor(for: settings.fit) + coordinator.animate(alongsideTransition: { _ in - self.updateScaleFactors() - if isAtMinScaleFactor { - pdfView.scaleFactor = pdfView.minScaleFactor - } + self.updateScaleFactors(zoomToFit: isAtScaleFactor) // Reset the PDF view to update the spread if needed. if self.settings.spread == .auto { @@ -263,11 +267,14 @@ open class PDFNavigatorViewController: target: self, action: #selector(didClick) ) + swipeLeftGestureRecognizer = recognizeSwipe(in: pdfView, direction: .left) + swipeRightGestureRecognizer = recognizeSwipe(in: pdfView, direction: .right) apply(settings: settings, to: pdfView) delegate?.navigator(self, setupPDFView: pdfView) NotificationCenter.default.addObserver(self, selector: #selector(pageDidChange), name: .PDFViewPageChanged, object: pdfView) + NotificationCenter.default.addObserver(self, selector: #selector(visiblePagesDidChange), name: .PDFViewVisiblePagesChanged, object: pdfView) NotificationCenter.default.addObserver(self, selector: #selector(selectionDidChange), name: .PDFViewSelectionChanged, object: pdfView) if let locator = locator { @@ -328,7 +335,7 @@ open class PDFNavigatorViewController: pdfView.displaysRTL = isRTL pdfView.displaysPageBreaks = true - pdfView.autoScales = !scalesDocumentToFit + pdfView.autoScales = false if let scrollView = pdfView.firstScrollView { let showScrollbar = settings.visibleScrollbar @@ -341,6 +348,10 @@ open class PDFNavigatorViewController: } pdfView.backgroundColor = settings.backgroundColor?.uiColor ?? pdfViewDefaultBackgroundColor + + let enableSwipes = !settings.scroll && spread + swipeLeftGestureRecognizer?.isEnabled = enableSwipes + swipeRightGestureRecognizer?.isEnabled = enableSwipes } @objc private func didTap(_ gesture: UITapGestureRecognizer) { @@ -367,6 +378,25 @@ open class PDFNavigatorViewController: delegate?.navigator(self, didTapAt: location) } + private func recognizeSwipe(in view: UIView, direction: UISwipeGestureRecognizer.Direction) -> UISwipeGestureRecognizer { + let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(didSwipe)) + recognizer.direction = direction + recognizer.numberOfTouchesRequired = 1 + view.addGestureRecognizer(recognizer) + return recognizer + } + + @objc private func didSwipe(_ gesture: UISwipeGestureRecognizer) { + switch gesture.direction { + case .left: + Task { await goRight(options: .animated) } + case .right: + Task { await goLeft(options: .animated) } + default: + break + } + } + @objc private func pageDidChange() { guard let locator = currentPosition else { return @@ -374,6 +404,15 @@ open class PDFNavigatorViewController: delegate?.navigator(self, locationDidChange: locator) } + @objc private func visiblePagesDidChange() { + // In paginated mode, we want to refresh the scale factors to properly + // fit the newly visible pages. This is especially important for + // paginated spreads. + if !settings.scroll { + updateScaleFactors(zoomToFit: true) + } + } + @discardableResult private func go(to locator: Locator, isJump: Bool) async -> Bool { let locator = publication.normalizeLocator(locator) @@ -418,7 +457,7 @@ open class PDFNavigatorViewController: } if currentResourceIndex != index { - guard let document = PDFDocument(url: url.url) else { + guard let document = await makeDocument(at: url) else { log(.error, "Can't open PDF document at \(url)") return false } @@ -426,7 +465,7 @@ open class PDFNavigatorViewController: currentResourceIndex = index documentHolder.set(document, at: href) pdfView.document = document - updateScaleFactors() + updateScaleFactors(zoomToFit: true) } guard let document = pdfView.document else { @@ -446,12 +485,36 @@ open class PDFNavigatorViewController: return true } - private func updateScaleFactors() { - guard let pdfView = pdfView, scalesDocumentToFit else { + private func makeDocument(at url: AbsoluteURL) async -> PDFKit.PDFDocument? { + let task = Task.detached(priority: .userInitiated) { + PDFDocument(url: url.url) + } + return await task.value + } + + /// Updates the scale factors to match the currently visible pages. + /// + /// - Parameter zoomToFit: When true, the document will be zoomed to fit the + /// visible pages. + private func updateScaleFactors(zoomToFit: Bool) { + guard let pdfView = pdfView else { return } - pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit + + let scaleFactorToFit = pdfView.scaleFactor(for: settings.fit) + + if settings.scroll { + // Allow zooming out to 25% in scroll mode. + pdfView.minScaleFactor = 0.25 + } else { + pdfView.minScaleFactor = scaleFactorToFit + } + pdfView.maxScaleFactor = 4.0 + + if zoomToFit { + pdfView.scaleFactor = scaleFactorToFit + } } private func pageNumber(for locator: Locator) -> Int? { @@ -528,7 +591,9 @@ open class PDFNavigatorViewController: // MARK: - SelectableNavigator - public var currentSelection: Selection? { editingActions.selection } + public var currentSelection: Selection? { + editingActions.selection + } public func clearSelection() { pdfView?.clearSelection() @@ -597,7 +662,7 @@ open class PDFNavigatorViewController: public var currentLocation: Locator? { currentPosition?.copy(text: { [weak self] in - /// Adds some context for bookmarking + // Adds some context for bookmarking if let page = self?.pdfView?.currentPage { $0 = .init(highlight: String(page.string?.prefix(280) ?? "")) } diff --git a/Sources/Navigator/PDF/PDFTapGestureController.swift b/Sources/Navigator/PDF/PDFTapGestureController.swift index c3a7671f23..a652c23a02 100644 --- a/Sources/Navigator/PDF/PDFTapGestureController.swift +++ b/Sources/Navigator/PDF/PDFTapGestureController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift index 8c32120247..723565f582 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,7 +14,13 @@ public struct PDFPreferences: ConfigurablePreferences { /// Background color behind the document pages. public var backgroundColor: Color? - /// Indicates if the first page should be displayed in its own spread. + /// Method for fitting the pages within the viewport. + public var fit: Fit? + + /// Indicates whether the first page should be displayed alone instead of + /// alongside the second page. + /// + /// This is only effective if spreads are enabled. public var offsetFirstPage: Bool? /// Spacing between pages in points. @@ -41,6 +47,7 @@ public struct PDFPreferences: ConfigurablePreferences { public init( backgroundColor: Color? = nil, + fit: Fit? = nil, offsetFirstPage: Bool? = nil, pageSpacing: Double? = nil, readingProgression: ReadingProgression? = nil, @@ -51,6 +58,7 @@ public struct PDFPreferences: ConfigurablePreferences { ) { precondition(pageSpacing == nil || pageSpacing! >= 0) self.backgroundColor = backgroundColor + self.fit = fit self.offsetFirstPage = offsetFirstPage self.pageSpacing = pageSpacing self.readingProgression = readingProgression @@ -63,6 +71,7 @@ public struct PDFPreferences: ConfigurablePreferences { public func merging(_ other: PDFPreferences) -> PDFPreferences { PDFPreferences( backgroundColor: other.backgroundColor ?? backgroundColor, + fit: other.fit ?? fit, offsetFirstPage: other.offsetFirstPage ?? offsetFirstPage, pageSpacing: other.pageSpacing ?? pageSpacing, readingProgression: other.readingProgression ?? readingProgression, diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift index 28d1f9e256..9124041194 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -32,7 +32,20 @@ public final class PDFPreferencesEditor: StatefulPreferencesEditor = + enumPreference( + preference: \.fit, + setting: \.fit, + defaultEffectiveValue: defaults.fit ?? .auto, + isEffective: { $0.settings.scroll }, + supportedValues: [.auto, .page, .width] + ) + + /// Indicates whether the first page should be displayed alone instead of + /// alongside the second page. /// /// Only effective when `spread` is not off. public lazy var offsetFirstPage: AnyPreference = diff --git a/Sources/Navigator/PDF/Preferences/PDFSettings.swift b/Sources/Navigator/PDF/Preferences/PDFSettings.swift index 82ac91463a..936daee8ab 100644 --- a/Sources/Navigator/PDF/Preferences/PDFSettings.swift +++ b/Sources/Navigator/PDF/Preferences/PDFSettings.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -13,6 +13,7 @@ import ReadiumShared /// See `PDFPreferences` public struct PDFSettings: ConfigurableSettings { public let backgroundColor: Color? + public let fit: Fit public let offsetFirstPage: Bool public let pageSpacing: Double public let readingProgression: ReadingProgression @@ -25,6 +26,10 @@ public struct PDFSettings: ConfigurableSettings { backgroundColor = preferences.backgroundColor ?? defaults.backgroundColor + fit = preferences.fit + ?? defaults.fit + ?? .auto + offsetFirstPage = preferences.offsetFirstPage ?? defaults.offsetFirstPage ?? false @@ -64,6 +69,7 @@ public struct PDFSettings: ConfigurableSettings { /// See `PDFPreferences`. public struct PDFDefaults { public var backgroundColor: Color? + public var fit: Fit? public var offsetFirstPage: Bool? public var pageSpacing: Double? public var readingProgression: ReadingProgression? @@ -74,6 +80,7 @@ public struct PDFDefaults { public init( backgroundColor: Color? = nil, + fit: Fit? = nil, offsetFirstPage: Bool? = nil, pageSpacing: Double? = nil, readingProgression: ReadingProgression? = nil, @@ -83,6 +90,7 @@ public struct PDFDefaults { visibleScrollbar: Bool? = nil ) { self.backgroundColor = backgroundColor + self.fit = fit self.offsetFirstPage = offsetFirstPage self.pageSpacing = pageSpacing self.readingProgression = readingProgression diff --git a/Sources/Navigator/Preferences/Configurable.swift b/Sources/Navigator/Preferences/Configurable.swift index a07b169198..7eee624d9c 100644 --- a/Sources/Navigator/Preferences/Configurable.swift +++ b/Sources/Navigator/Preferences/Configurable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -67,7 +67,9 @@ public class AnyConfigurable< _editor = configurable.editor(of:) } - public var settings: Settings { _settings() } + public var settings: Settings { + _settings() + } public func submitPreferences(_ preferences: Preferences) { _submitPreferences(preferences) diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index 3659b0f7d6..c66514db3a 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -149,9 +149,17 @@ public class MappedPreference: Preference { self.to = to } - public var value: NewValue? { original.value.map(from) } - public var effectiveValue: NewValue { from(original.effectiveValue) } - public var isEffective: Bool { original.isEffective } + public var value: NewValue? { + original.value.map(from) + } + + public var effectiveValue: NewValue { + from(original.effectiveValue) + } + + public var isEffective: Bool { + original.isEffective + } public func set(_ value: NewValue?) { original.set(value.map(to)) diff --git a/Sources/Navigator/Preferences/Preference.swift b/Sources/Navigator/Preferences/Preference.swift index 2240648519..85f11f3637 100644 --- a/Sources/Navigator/Preferences/Preference.swift +++ b/Sources/Navigator/Preferences/Preference.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -82,9 +82,17 @@ public extension Preference { /// A type-erasing `Preference` object. public class AnyPreference: Preference { - public var value: Value? { _value() } - public var effectiveValue: Value { _effectiveValue() } - public var isEffective: Bool { _isEffective() } + public var value: Value? { + _value() + } + + public var effectiveValue: Value { + _effectiveValue() + } + + public var isEffective: Bool { + _isEffective() + } private let _value: () -> Value? private let _effectiveValue: () -> Value @@ -112,7 +120,9 @@ public extension EnumPreference { /// A type-erasing `EnumPreference` object. public class AnyEnumPreference: AnyPreference, EnumPreference { - public var supportedValues: [Value] { _supportedValues() } + public var supportedValues: [Value] { + _supportedValues() + } private let _supportedValues: () -> [Value] @@ -131,7 +141,9 @@ public extension RangePreference { /// A type-erasing `Preference` object. public class AnyRangePreference: AnyPreference, RangePreference { - public var supportedRange: ClosedRange { _supportedRange() } + public var supportedRange: ClosedRange { + _supportedRange() + } private let _supportedRange: () -> ClosedRange private let _increment: () -> Void diff --git a/Sources/Navigator/Preferences/PreferencesEditor.swift b/Sources/Navigator/Preferences/PreferencesEditor.swift index 4e3c488a80..407f7b890b 100644 --- a/Sources/Navigator/Preferences/PreferencesEditor.swift +++ b/Sources/Navigator/Preferences/PreferencesEditor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -38,7 +38,9 @@ public class StatefulPreferencesEditor Bool { true } - func navigator(_ navigator: SelectableNavigator, canPerformAction action: EditingAction, for selection: Selection) -> Bool { true } + func navigator(_ navigator: SelectableNavigator, shouldShowMenuForSelection selection: Selection) -> Bool { + true + } + + func navigator(_ navigator: SelectableNavigator, canPerformAction action: EditingAction, for selection: Selection) -> Bool { + true + } } diff --git a/Sources/Navigator/TTS/AVTTSEngine.swift b/Sources/Navigator/TTS/AVTTSEngine.swift index d3177c46b3..75afef5139 100644 --- a/Sources/Navigator/TTS/AVTTSEngine.swift +++ b/Sources/Navigator/TTS/AVTTSEngine.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index efbc73ac1b..51c20f59b2 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -156,7 +156,7 @@ public class PublicationSpeechSynthesizer: Loggable { ) } - private var currentTask: Task? = nil + private var currentTask: Task? private lazy var engine: TTSEngine = engineFactory() @@ -177,7 +177,7 @@ public class PublicationSpeechSynthesizer: Loggable { } /// Cache for the last requested voice, for performance. - private var lastUsedVoice: TTSVoice? = nil + private var lastUsedVoice: TTSVoice? /// (Re)starts the synthesizer from the given locator or the beginning of the publication. public func start(from startLocator: Locator? = nil) { @@ -245,7 +245,7 @@ public class PublicationSpeechSynthesizer: Loggable { } /// `Content.Iterator` used to iterate through the `publication`. - private var publicationIterator: ContentIterator? = nil { + private var publicationIterator: ContentIterator? { didSet { utterances = CursorList() } @@ -320,8 +320,7 @@ public class PublicationSpeechSynthesizer: Loggable { return .right(utterance.language ?? config.defaultLanguage ?? publication.metadata.language - ?? Language.current - ) + ?? Language.current) } } diff --git a/Sources/Navigator/TTS/TTSEngine.swift b/Sources/Navigator/TTS/TTSEngine.swift index 3d513bd3d5..66ea8e5eab 100644 --- a/Sources/Navigator/TTS/TTSEngine.swift +++ b/Sources/Navigator/TTS/TTSEngine.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/TTS/TTSVoice.swift b/Sources/Navigator/TTS/TTSVoice.swift index d84c060914..d482ef0f4e 100644 --- a/Sources/Navigator/TTS/TTSVoice.swift +++ b/Sources/Navigator/TTS/TTSVoice.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -106,15 +106,13 @@ public extension [TTSVoice] { // 3. Add remaining regions ordered by localized name. ordered.append(contentsOf: regions.sorted { ($0.localizedName(in: displayLocale) ?? $0.code) < ($1.localizedName(in: displayLocale) ?? $1.code) - } - ) + }) ordered = ordered.removingDuplicates() // Assign priorities: lower Int = higher priority let priorities = Dictionary(uniqueKeysWithValues: - ordered.enumerated().map { idx, region in (region, idx) } - ) + ordered.enumerated().map { idx, region in (region, idx) }) return (language, priorities) }) @@ -132,11 +130,11 @@ public extension [TTSVoice] { if let region = voice.language.region, let regionPriorities = regionPrioritiesByLanguage[language] - { - regionPriorities[region] ?? .max - } else { - .max - } + { + regionPriorities[region] ?? .max + } else { + .max + } return ( language: language.localizedLanguage(in: displayLocale) ?? voice.language.code.bcp47, @@ -230,7 +228,7 @@ private let defaultRegionByLanguage: [Language.Code: Language.Region] = [ .bcp47("yue"): "HK", ] -// Quality order priority: higher to lower +/// Quality order priority: higher to lower private let qualityPriorities: [TTSVoice.Quality: Int] = [ .higher: 0, .high: 1, @@ -239,7 +237,7 @@ private let qualityPriorities: [TTSVoice.Quality: Int] = [ .lower: 4, ] -// Gender order priority: female > male > unspecified +/// Gender order priority: female > male > unspecified private let genderPriorities: [TTSVoice.Gender: Int] = [ .female: 0, .male: 1, diff --git a/Sources/Navigator/Toolkit/CompletionList.swift b/Sources/Navigator/Toolkit/CompletionList.swift index c9b2b2df71..a6a2bba587 100644 --- a/Sources/Navigator/Toolkit/CompletionList.swift +++ b/Sources/Navigator/Toolkit/CompletionList.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/CursorList.swift b/Sources/Navigator/Toolkit/CursorList.swift index 40c5cd4a95..1b2e27522b 100644 --- a/Sources/Navigator/Toolkit/CursorList.swift +++ b/Sources/Navigator/Toolkit/CursorList.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/Extensions/Bundle.swift b/Sources/Navigator/Toolkit/Extensions/Bundle.swift index 285f422278..6d7a1c8589 100644 --- a/Sources/Navigator/Toolkit/Extensions/Bundle.swift +++ b/Sources/Navigator/Toolkit/Extensions/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/Extensions/CGRect.swift b/Sources/Navigator/Toolkit/Extensions/CGRect.swift index da3e6d6c82..7918813320 100644 --- a/Sources/Navigator/Toolkit/Extensions/CGRect.swift +++ b/Sources/Navigator/Toolkit/Extensions/CGRect.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/Extensions/Language.swift b/Sources/Navigator/Toolkit/Extensions/Language.swift index db6224296c..f83734b840 100644 --- a/Sources/Navigator/Toolkit/Extensions/Language.swift +++ b/Sources/Navigator/Toolkit/Extensions/Language.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/Extensions/Range.swift b/Sources/Navigator/Toolkit/Extensions/Range.swift index 82035f5063..5e7b9a2c48 100644 --- a/Sources/Navigator/Toolkit/Extensions/Range.swift +++ b/Sources/Navigator/Toolkit/Extensions/Range.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/Extensions/UIColor.swift b/Sources/Navigator/Toolkit/Extensions/UIColor.swift index be90574a70..6520aeff6d 100644 --- a/Sources/Navigator/Toolkit/Extensions/UIColor.swift +++ b/Sources/Navigator/Toolkit/Extensions/UIColor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/Extensions/UIView.swift b/Sources/Navigator/Toolkit/Extensions/UIView.swift index de65e35d02..72e6ba4d3e 100644 --- a/Sources/Navigator/Toolkit/Extensions/UIView.swift +++ b/Sources/Navigator/Toolkit/Extensions/UIView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,9 +8,9 @@ import Foundation import UIKit extension UIView { - // Finds the first `UIScrollView` in the view hierarchy. - // - // https://medium.com/@wailord/the-particulars-of-the-safe-area-and-contentinsetadjustmentbehavior-in-ios-11-9b842018eeaa#077b + /// Finds the first `UIScrollView` in the view hierarchy. + /// + /// https://medium.com/@wailord/the-particulars-of-the-safe-area-and-contentinsetadjustmentbehavior-in-ios-11-9b842018eeaa#077b var firstScrollView: UIScrollView? { sequence(first: self) { $0.subviews.first } .first { $0 is UIScrollView } diff --git a/Sources/Navigator/Toolkit/Extensions/WKWebView.swift b/Sources/Navigator/Toolkit/Extensions/WKWebView.swift index 2c8aada663..d23a7a4f60 100644 --- a/Sources/Navigator/Toolkit/Extensions/WKWebView.swift +++ b/Sources/Navigator/Toolkit/Extensions/WKWebView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/HTMLInjection.swift b/Sources/Navigator/Toolkit/HTMLInjection.swift index b56d8970c8..110ebc5166 100644 --- a/Sources/Navigator/Toolkit/HTMLInjection.swift +++ b/Sources/Navigator/Toolkit/HTMLInjection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -17,7 +17,9 @@ protocol HTMLInjectable { } extension HTMLInjectable { - func willInject(in html: String) -> String { html } + func willInject(in html: String) -> String { + html + } /// Injects the receiver in the given `html` document. func inject(in html: String) throws -> String { diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index 5035417cee..7890a66344 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -130,11 +130,11 @@ final class PaginationView: UIView, Loggable { } @available(*, unavailable) - public required init?(coder aDecoder: NSCoder) { + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override public func layoutSubviews() { + override func layoutSubviews() { guard !loadedViews.isEmpty else { scrollView.contentSize = bounds.size return @@ -150,6 +150,18 @@ final class PaginationView: UIView, Loggable { scrollView.contentOffset.x = xOffsetForIndex(currentIndex) } + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + // Remove all spread views to break retain cycles + for (_, view) in loadedViews { + view.removeFromSuperview() + } + loadedViews.removeAll() + } + } + override func didMoveToWindow() { super.didMoveToWindow() @@ -360,11 +372,11 @@ final class PaginationView: UIView, Loggable { } extension PaginationView: UIScrollViewDelegate { - /// We disable the scroll once the user releases the drag to prevent scrolling through more than 1 resource at a - /// time. Otherwise, because the pagination view's scroll view would have the focus during the scroll gesture, the - /// scrollable content of the resources would be skipped. - /// Note: using this approach might provide a better experience: - /// https://oleb.net/blog/2014/05/scrollviews-inside-scrollviews/ + // We disable the scroll once the user releases the drag to prevent scrolling through more than 1 resource at a + // time. Otherwise, because the pagination view's scroll view would have the focus during the scroll gesture, the + // scrollable content of the resources would be skipped. + // Note: using this approach might provide a better experience: + // https://oleb.net/blog/2014/05/scrollviews-inside-scrollviews/ func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { scrollView.isScrollEnabled = false @@ -380,7 +392,7 @@ extension PaginationView: UIScrollViewDelegate { } } - public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollView.isScrollEnabled = isScrollEnabled let currentOffset = (readingProgression == .rtl) diff --git a/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift b/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift index 7f3267023e..619a1d5e1e 100644 --- a/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift +++ b/Sources/Navigator/Toolkit/ReadiumNavigatorLocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/TargetAction.swift b/Sources/Navigator/Toolkit/TargetAction.swift index 84ab698732..8195efc7d5 100644 --- a/Sources/Navigator/Toolkit/TargetAction.swift +++ b/Sources/Navigator/Toolkit/TargetAction.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Navigator/Toolkit/WebView.swift b/Sources/Navigator/Toolkit/WebView.swift index 7d843722b1..fe561b87e6 100644 --- a/Sources/Navigator/Toolkit/WebView.swift +++ b/Sources/Navigator/Toolkit/WebView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -12,21 +12,14 @@ import WebKit final class WebView: WKWebView { private let editingActions: EditingActionsController - init(editingActions: EditingActionsController) { - self.editingActions = editingActions - - let config = WKWebViewConfiguration() - config.mediaTypesRequiringUserActionForPlayback = .all + convenience init(editingActions: EditingActionsController) { + self.init(editingActions: editingActions, configuration: WKWebViewConfiguration()) + } - // Disable the Apple Intelligence Writing tools in the web views. - // See https://github.com/readium/swift-toolkit/issues/509#issuecomment-2577780749 - #if compiler(>=6.0) - if #available(iOS 18.0, *) { - config.writingToolsBehavior = .none - } - #endif + init(editingActions: EditingActionsController, configuration: WKWebViewConfiguration) { + self.editingActions = editingActions - super.init(frame: .zero, configuration: config) + super.init(frame: .zero, configuration: configuration) #if DEBUG && swift(>=5.8) if #available(macOS 13.3, iOS 16.4, *) { diff --git a/Sources/Navigator/VisualNavigator.swift b/Sources/Navigator/VisualNavigator.swift index 5edc793f52..c9bf1ccca5 100644 --- a/Sources/Navigator/VisualNavigator.swift +++ b/Sources/Navigator/VisualNavigator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -19,20 +19,18 @@ public protocol VisualNavigator: Navigator, InputObservable { /// Moves to the left content portion (eg. page) relative to the reading /// progression direction. /// - /// - Parameter completion: Called when the transition is completed. - /// - Returns: Whether the navigator is able to move to the previous - /// content portion. The completion block is only called if true was - /// returned. + /// - Parameter options: Options for moving the content to the left. + /// - Returns: Whether the navigator was able to move to the left content + /// portion. @discardableResult func goLeft(options: NavigatorGoOptions) async -> Bool /// Moves to the right content portion (eg. page) relative to the reading /// progression direction. /// - /// - Parameter completion: Called when the transition is completed. - /// - Returns: Whether the navigator is able to move to the previous - /// content portion. The completion block is only called if true was - /// returned. + /// - Parameter options: Options for moving the content to the right. + /// - Returns: Whether the navigator was able to move to the right content + /// portion. @discardableResult func goRight(options: NavigatorGoOptions) async -> Bool diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 535926b15e..012966f250 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,16 +9,16 @@ import ReadiumFuzi import ReadiumShared public enum OPDS1ParserError: Error { - // The title is missing from the feed. + /// The title is missing from the feed. case missingTitle - // Root is not found + /// Root is not found case rootNotFound } public enum OPDSParserOpenSearchHelperError: Error { - // Search link not found in feed + /// Search link not found in feed case searchLinkNotFound - // OpenSearch document is invalid + /// OpenSearch document is invalid case searchDocumentIsInvalid } @@ -30,7 +30,10 @@ struct MimeTypeParameters { public class OPDS1Parser: Loggable { /// Parse an OPDS feed or publication. /// Feed can only be v1 (XML). - /// - parameter url: The feed URL + /// - Parameters: + /// - url: The feed URL. + /// - completion: A closure called when the parsing is complete, returning the parsed data + /// or an error if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let response = response else { @@ -219,8 +222,11 @@ public class OPDS1Parser: Loggable { /// Parse an OPDS publication. /// Publication can only be v1 (XML). - /// - parameter document: The XMLDocument data - /// - Returns: The resulting Publication + /// - Parameters: + /// - document: The XMLDocument data. + /// - feedURL: The base URL of the feed, used to resolve relative links. + /// - Returns: The resulting `Publication`, or `nil` if the entry couldn't be parsed. + /// - Throws: An error if the XML parsing or validation fails. public static func parseEntry(document: ReadiumFuzi.XMLDocument, feedURL: URL) throws -> Publication? { guard let root = document.root else { throw OPDS1ParserError.rootNotFound @@ -229,7 +235,10 @@ public class OPDS1Parser: Loggable { } /// Fetch an Open Search template from an OPDS feed. - /// - parameter feed: The OPDS feed + /// - Parameters: + /// - feed: The OPDS feed to search for the template. + /// - completion: A closure called with the OpenSearch template as a `String` if found, + /// or an `Error` if the fetch or parsing failed. public static func fetchOpenSearchTemplate(feed: Feed, completion: @escaping (String?, Error?) -> Void) { guard let openSearchHref = feed.links.firstWithRel(.search)?.href, let openSearchURL = URL(string: openSearchHref) @@ -301,7 +310,7 @@ public class OPDS1Parser: Loggable { } static func parseEntry(entry: ReadiumFuzi.XMLElement, feedURL: URL) -> Publication? { - // Shortcuts to get tag(s)' string value. + /// Shortcuts to get tag(s)' string value. func tag(_ name: String) -> String? { entry.firstChild(tag: name)?.stringValue } diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 98deabb553..b3f706b4c8 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -1,11 +1,10 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // import Foundation - import ReadiumShared public enum OPDS2ParserError: Error { @@ -22,7 +21,10 @@ public enum OPDS2ParserError: Error { public class OPDS2Parser: Loggable { /// Parse an OPDS feed or publication. /// Feed can only be v2 (JSON). - /// - parameter url: The feed URL + /// - Parameters: + /// - url: The feed URL. + /// - completion: A closure called when the parsing is complete, returning the + /// parsed `ParseData` on success, or an `Error` if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let response = response else { @@ -78,8 +80,11 @@ public class OPDS2Parser: Loggable { /// Parse an OPDS feed. /// Feed can only be v2 (JSON). - /// - parameter jsonDict: The json top level dictionary - /// - Returns: The resulting Feed + /// - Parameters: + /// - feedURL: The URL of the feed being parsed, used to resolve relative links. + /// - jsonDict: The JSON top-level dictionary. + /// - Returns: The resulting `Feed` object. + /// - Throws: An error if the JSON structure is invalid or missing required OPDS fields. public static func parse(feedURL: URL, jsonDict: [String: Any]) throws -> Feed { guard let metadataDict = jsonDict["metadata"] as? [String: Any] else { throw OPDS2ParserError.metadataNotFound diff --git a/Sources/OPDS/OPDSParser.swift b/Sources/OPDS/OPDSParser.swift index 4b1125ed89..80031b9397 100644 --- a/Sources/OPDS/OPDSParser.swift +++ b/Sources/OPDS/OPDSParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -17,7 +17,10 @@ public enum OPDSParser { /// Parse an OPDS feed or publication. /// Feed can be v1 (XML) or v2 (JSON). - /// - parameter url: The feed URL + /// - Parameters: + /// - url: The feed URL. + /// - completion: A closure called when the parsing is complete, returning the + /// parsed `ParseData` on success, or an `Error` if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { feedURL = url diff --git a/Sources/OPDS/ParseData.swift b/Sources/OPDS/ParseData.swift index 2ae11131a9..116c57ef0d 100644 --- a/Sources/OPDS/ParseData.swift +++ b/Sources/OPDS/ParseData.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/OPDS/URLHelper.swift b/Sources/OPDS/URLHelper.swift index 0fe7c4555e..5157fc5dff 100644 --- a/Sources/OPDS/URLHelper.swift +++ b/Sources/OPDS/URLHelper.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/OPDS/XMLNamespace.swift b/Sources/OPDS/XMLNamespace.swift index b379820a38..940929c4cf 100644 --- a/Sources/OPDS/XMLNamespace.swift +++ b/Sources/OPDS/XMLNamespace.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Logger/Loggable.swift b/Sources/Shared/Logger/Loggable.swift index 2c6ee4b7a8..b15a9e7e19 100644 --- a/Sources/Shared/Logger/Loggable.swift +++ b/Sources/Shared/Logger/Loggable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Logger/Logger.swift b/Sources/Shared/Logger/Logger.swift index d8ed23b876..c92a9bc24f 100644 --- a/Sources/Shared/Logger/Logger.swift +++ b/Sources/Shared/Logger/Logger.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,7 +9,10 @@ import Foundation /// Initialize the Logger. /// Default logger is the `LoggerStub` class /// -/// - Parameter customLogger: The Logger that will be used for printing logs. +/// - Parameters: +/// - level: The minimum severity level for logs to be processed. +/// - customLogger: The Logger that will be used for printing logs. +/// Defaults to a `LoggerStub` which may perform no-op logging. public func ReadiumEnableLog(withMinimumSeverityLevel level: SeverityLevel, customLogger: LoggerType = LoggerStub()) { Logger.sharedInstance.setupLogger(logger: customLogger) Logger.sharedInstance.setMinimumSeverityLevel(at: level) @@ -28,10 +31,10 @@ public final class Logger { /// throughout the framework. There is a default implementation `StubLogger` /// available. You can define your own implementation by applying the /// `Loggable` protocol to your xLogger class. - internal var activeLogger: LoggerType? + var activeLogger: LoggerType? /// The minimum severity level for logs to be displayed. - internal var minimumSeverityLevel: SeverityLevel? + var minimumSeverityLevel: SeverityLevel? private(set) static var sharedInstance = Logger() @@ -63,7 +66,7 @@ public final class Logger { // MARK: - Internal methods. - internal func log(_ value: Any?, at level: SeverityLevel, file: String, line: Int) { + func log(_ value: Any?, at level: SeverityLevel, file: String, line: Int) { if let minimumSeverityLevel = minimumSeverityLevel { guard level.numericValue >= minimumSeverityLevel.numericValue else { return diff --git a/Sources/Shared/Logger/LoggerStub.swift b/Sources/Shared/Logger/LoggerStub.swift index 3a5d5f2c55..c4f55e5554 100644 --- a/Sources/Shared/Logger/LoggerStub.swift +++ b/Sources/Shared/Logger/LoggerStub.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/OPDS/Facet.swift b/Sources/Shared/OPDS/Facet.swift index fc38245855..8eb52e1c28 100644 --- a/Sources/Shared/OPDS/Facet.swift +++ b/Sources/Shared/OPDS/Facet.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/OPDS/Feed.swift b/Sources/Shared/OPDS/Feed.swift index 9c77f8b816..1f2f0a97cb 100644 --- a/Sources/Shared/OPDS/Feed.swift +++ b/Sources/Shared/OPDS/Feed.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,7 +21,7 @@ public class Feed { /// Return a String representing the URL of the searchLink of the feed. /// /// - Returns: The HREF value of the search link - internal func getSearchLinkHref() -> String? { + func getSearchLinkHref() -> String? { links.firstWithRel(.search)?.href } } diff --git a/Sources/Shared/OPDS/Group.swift b/Sources/Shared/OPDS/Group.swift index 960fed4da1..992abf7bd1 100644 --- a/Sources/Shared/OPDS/Group.swift +++ b/Sources/Shared/OPDS/Group.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/OPDS/OPDSAcquisition.swift b/Sources/Shared/OPDS/OPDSAcquisition.swift index 902fa779f6..4152ab1760 100644 --- a/Sources/Shared/OPDS/OPDSAcquisition.swift +++ b/Sources/Shared/OPDS/OPDSAcquisition.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -13,7 +13,9 @@ public struct OPDSAcquisition: Equatable { public var type: String public var children: [OPDSAcquisition] = [] - public var mediaType: MediaType? { MediaType(type) } + public var mediaType: MediaType? { + MediaType(type) + } public init(type: String, children: [OPDSAcquisition] = []) { self.type = type diff --git a/Sources/Shared/OPDS/OPDSAvailability.swift b/Sources/Shared/OPDS/OPDSAvailability.swift index d013952745..de92f7cff5 100644 --- a/Sources/Shared/OPDS/OPDSAvailability.swift +++ b/Sources/Shared/OPDS/OPDSAvailability.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/OPDS/OPDSCopies.swift b/Sources/Shared/OPDS/OPDSCopies.swift index 0cece14339..2bb38c910c 100644 --- a/Sources/Shared/OPDS/OPDSCopies.swift +++ b/Sources/Shared/OPDS/OPDSCopies.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/OPDS/OPDSHolds.swift b/Sources/Shared/OPDS/OPDSHolds.swift index 4804418f3b..a47d7ba2ae 100644 --- a/Sources/Shared/OPDS/OPDSHolds.swift +++ b/Sources/Shared/OPDS/OPDSHolds.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/OPDS/OPDSPrice.swift b/Sources/Shared/OPDS/OPDSPrice.swift index 9da287c135..63f8a3256a 100644 --- a/Sources/Shared/OPDS/OPDSPrice.swift +++ b/Sources/Shared/OPDS/OPDSPrice.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -12,7 +12,7 @@ import ReadiumInternal public struct OPDSPrice: Equatable { public var currency: String // eg. EUR - // Should only be used for display purposes, because of precision issues inherent with Double and the JSON parsing. + /// Should only be used for display purposes, because of precision issues inherent with Double and the JSON parsing. public var value: Double public init(currency: String, value: Double) { diff --git a/Sources/Shared/OPDS/OpdsMetadata.swift b/Sources/Shared/OPDS/OpdsMetadata.swift index 7661313574..26b9d0aa62 100644 --- a/Sources/Shared/OPDS/OpdsMetadata.swift +++ b/Sources/Shared/OPDS/OpdsMetadata.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Accessibility/Accessibility.swift b/Sources/Shared/Publication/Accessibility/Accessibility.swift index fbe8d01985..dbdb40ae1c 100644 --- a/Sources/Shared/Publication/Accessibility/Accessibility.swift +++ b/Sources/Shared/Publication/Accessibility/Accessibility.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift index e4738f066c..b5970ad504 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityDisplayString+Generated.swift @@ -1,27 +1,33 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // -// DO NOT EDIT. File generated automatically from v2.0.c of the en-US JSON strings. - +/// DO NOT EDIT. File generated automatically from https://github.com/edrlab/thorium-locales/. public extension AccessibilityDisplayString { - static let waysOfReadingTitle: Self = "readium.a11y.ways-of-reading-title" - static let waysOfReadingNonvisualReadingAltText: Self = "readium.a11y.ways-of-reading-nonvisual-reading-alt-text" - static let waysOfReadingNonvisualReadingNoMetadata: Self = "readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" - static let waysOfReadingNonvisualReadingNone: Self = "readium.a11y.ways-of-reading-nonvisual-reading-none" - static let waysOfReadingNonvisualReadingNotFully: Self = "readium.a11y.ways-of-reading-nonvisual-reading-not-fully" - static let waysOfReadingNonvisualReadingReadable: Self = "readium.a11y.ways-of-reading-nonvisual-reading-readable" - static let waysOfReadingPrerecordedAudioComplementary: Self = "readium.a11y.ways-of-reading-prerecorded-audio-complementary" - static let waysOfReadingPrerecordedAudioNoMetadata: Self = "readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" - static let waysOfReadingPrerecordedAudioOnly: Self = "readium.a11y.ways-of-reading-prerecorded-audio-only" - static let waysOfReadingPrerecordedAudioSynchronized: Self = "readium.a11y.ways-of-reading-prerecorded-audio-synchronized" - static let waysOfReadingVisualAdjustmentsModifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-modifiable" - static let waysOfReadingVisualAdjustmentsUnknown: Self = "readium.a11y.ways-of-reading-visual-adjustments-unknown" - static let waysOfReadingVisualAdjustmentsUnmodifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-unmodifiable" - static let conformanceTitle: Self = "readium.a11y.conformance-title" - static let conformanceDetailsTitle: Self = "readium.a11y.conformance-details-title" + static let accessibilitySummaryNoMetadata: Self = "readium.a11y.accessibility-summary-no-metadata" + static let accessibilitySummaryPublisherContact: Self = "readium.a11y.accessibility-summary-publisher-contact" + static let accessibilitySummaryTitle: Self = "readium.a11y.accessibility-summary-title" + static let additionalAccessibilityInformationAria: Self = "readium.a11y.additional-accessibility-information-aria" + static let additionalAccessibilityInformationAudioDescriptions: Self = "readium.a11y.additional-accessibility-information-audio-descriptions" + static let additionalAccessibilityInformationBraille: Self = "readium.a11y.additional-accessibility-information-braille" + static let additionalAccessibilityInformationColorNotSoleMeansOfConveyingInformation: Self = "readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" + static let additionalAccessibilityInformationDyslexiaReadability: Self = "readium.a11y.additional-accessibility-information-dyslexia-readability" + static let additionalAccessibilityInformationFullRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-full-ruby-annotations" + static let additionalAccessibilityInformationHighContrastBetweenForegroundAndBackgroundAudio: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" + static let additionalAccessibilityInformationHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" + static let additionalAccessibilityInformationLargePrint: Self = "readium.a11y.additional-accessibility-information-large-print" + static let additionalAccessibilityInformationPageBreaks: Self = "readium.a11y.additional-accessibility-information-page-breaks" + static let additionalAccessibilityInformationRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-ruby-annotations" + static let additionalAccessibilityInformationSignLanguage: Self = "readium.a11y.additional-accessibility-information-sign-language" + static let additionalAccessibilityInformationTactileGraphics: Self = "readium.a11y.additional-accessibility-information-tactile-graphics" + static let additionalAccessibilityInformationTactileObjects: Self = "readium.a11y.additional-accessibility-information-tactile-objects" + static let additionalAccessibilityInformationTextToSpeechHinting: Self = "readium.a11y.additional-accessibility-information-text-to-speech-hinting" + static let additionalAccessibilityInformationTitle: Self = "readium.a11y.additional-accessibility-information-title" + static let additionalAccessibilityInformationUltraHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" + static let additionalAccessibilityInformationVisiblePageNumbering: Self = "readium.a11y.additional-accessibility-information-visible-page-numbering" + static let additionalAccessibilityInformationWithoutBackgroundSounds: Self = "readium.a11y.additional-accessibility-information-without-background-sounds" static let conformanceA: Self = "readium.a11y.conformance-a" static let conformanceAa: Self = "readium.a11y.conformance-aa" static let conformanceAaa: Self = "readium.a11y.conformance-aaa" @@ -38,26 +44,10 @@ public extension AccessibilityDisplayString { static let conformanceDetailsWcag20: Self = "readium.a11y.conformance-details-wcag-2-0" static let conformanceDetailsWcag21: Self = "readium.a11y.conformance-details-wcag-2-1" static let conformanceDetailsWcag22: Self = "readium.a11y.conformance-details-wcag-2-2" + static let conformanceDetailsTitle: Self = "readium.a11y.conformance-details-title" static let conformanceNo: Self = "readium.a11y.conformance-no" + static let conformanceTitle: Self = "readium.a11y.conformance-title" static let conformanceUnknownStandard: Self = "readium.a11y.conformance-unknown-standard" - static let navigationTitle: Self = "readium.a11y.navigation-title" - static let navigationIndex: Self = "readium.a11y.navigation-index" - static let navigationNoMetadata: Self = "readium.a11y.navigation-no-metadata" - static let navigationPageNavigation: Self = "readium.a11y.navigation-page-navigation" - static let navigationStructural: Self = "readium.a11y.navigation-structural" - static let navigationToc: Self = "readium.a11y.navigation-toc" - static let richContentTitle: Self = "readium.a11y.rich-content-title" - static let richContentAccessibleChemistryAsLatex: Self = "readium.a11y.rich-content-accessible-chemistry-as-latex" - static let richContentAccessibleChemistryAsMathml: Self = "readium.a11y.rich-content-accessible-chemistry-as-mathml" - static let richContentAccessibleMathAsLatex: Self = "readium.a11y.rich-content-accessible-math-as-latex" - static let richContentAccessibleMathAsMathml: Self = "readium.a11y.rich-content-accessible-math-as-mathml" - static let richContentAccessibleMathDescribed: Self = "readium.a11y.rich-content-accessible-math-described" - static let richContentClosedCaptions: Self = "readium.a11y.rich-content-closed-captions" - static let richContentExtended: Self = "readium.a11y.rich-content-extended" - static let richContentOpenCaptions: Self = "readium.a11y.rich-content-open-captions" - static let richContentTranscript: Self = "readium.a11y.rich-content-transcript" - static let richContentUnknown: Self = "readium.a11y.rich-content-unknown" - static let hazardsTitle: Self = "readium.a11y.hazards-title" static let hazardsFlashing: Self = "readium.a11y.hazards-flashing" static let hazardsFlashingNone: Self = "readium.a11y.hazards-flashing-none" static let hazardsFlashingUnknown: Self = "readium.a11y.hazards-flashing-unknown" @@ -69,30 +59,39 @@ public extension AccessibilityDisplayString { static let hazardsSound: Self = "readium.a11y.hazards-sound" static let hazardsSoundNone: Self = "readium.a11y.hazards-sound-none" static let hazardsSoundUnknown: Self = "readium.a11y.hazards-sound-unknown" + static let hazardsTitle: Self = "readium.a11y.hazards-title" static let hazardsUnknown: Self = "readium.a11y.hazards-unknown" - static let accessibilitySummaryTitle: Self = "readium.a11y.accessibility-summary-title" - static let accessibilitySummaryNoMetadata: Self = "readium.a11y.accessibility-summary-no-metadata" - static let accessibilitySummaryPublisherContact: Self = "readium.a11y.accessibility-summary-publisher-contact" - static let legalConsiderationsTitle: Self = "readium.a11y.legal-considerations-title" static let legalConsiderationsExempt: Self = "readium.a11y.legal-considerations-exempt" static let legalConsiderationsNoMetadata: Self = "readium.a11y.legal-considerations-no-metadata" - static let additionalAccessibilityInformationTitle: Self = "readium.a11y.additional-accessibility-information-title" - static let additionalAccessibilityInformationAria: Self = "readium.a11y.additional-accessibility-information-aria" - static let additionalAccessibilityInformationAudioDescriptions: Self = "readium.a11y.additional-accessibility-information-audio-descriptions" - static let additionalAccessibilityInformationBraille: Self = "readium.a11y.additional-accessibility-information-braille" - static let additionalAccessibilityInformationColorNotSoleMeansOfConveyingInformation: Self = "readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" - static let additionalAccessibilityInformationDyslexiaReadability: Self = "readium.a11y.additional-accessibility-information-dyslexia-readability" - static let additionalAccessibilityInformationFullRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-full-ruby-annotations" - static let additionalAccessibilityInformationHighContrastBetweenForegroundAndBackgroundAudio: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" - static let additionalAccessibilityInformationHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" - static let additionalAccessibilityInformationLargePrint: Self = "readium.a11y.additional-accessibility-information-large-print" - static let additionalAccessibilityInformationPageBreaks: Self = "readium.a11y.additional-accessibility-information-page-breaks" - static let additionalAccessibilityInformationRubyAnnotations: Self = "readium.a11y.additional-accessibility-information-ruby-annotations" - static let additionalAccessibilityInformationSignLanguage: Self = "readium.a11y.additional-accessibility-information-sign-language" - static let additionalAccessibilityInformationTactileGraphics: Self = "readium.a11y.additional-accessibility-information-tactile-graphics" - static let additionalAccessibilityInformationTactileObjects: Self = "readium.a11y.additional-accessibility-information-tactile-objects" - static let additionalAccessibilityInformationTextToSpeechHinting: Self = "readium.a11y.additional-accessibility-information-text-to-speech-hinting" - static let additionalAccessibilityInformationUltraHighContrastBetweenTextAndBackground: Self = "readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" - static let additionalAccessibilityInformationVisiblePageNumbering: Self = "readium.a11y.additional-accessibility-information-visible-page-numbering" - static let additionalAccessibilityInformationWithoutBackgroundSounds: Self = "readium.a11y.additional-accessibility-information-without-background-sounds" + static let legalConsiderationsTitle: Self = "readium.a11y.legal-considerations-title" + static let navigationIndex: Self = "readium.a11y.navigation-index" + static let navigationNoMetadata: Self = "readium.a11y.navigation-no-metadata" + static let navigationPageNavigation: Self = "readium.a11y.navigation-page-navigation" + static let navigationStructural: Self = "readium.a11y.navigation-structural" + static let navigationTitle: Self = "readium.a11y.navigation-title" + static let navigationToc: Self = "readium.a11y.navigation-toc" + static let richContentAccessibleChemistryAsLatex: Self = "readium.a11y.rich-content-accessible-chemistry-as-latex" + static let richContentAccessibleChemistryAsMathml: Self = "readium.a11y.rich-content-accessible-chemistry-as-mathml" + static let richContentAccessibleMathAsLatex: Self = "readium.a11y.rich-content-accessible-math-as-latex" + static let richContentAccessibleMathDescribed: Self = "readium.a11y.rich-content-accessible-math-described" + static let richContentClosedCaptions: Self = "readium.a11y.rich-content-closed-captions" + static let richContentExtendedDescriptions: Self = "readium.a11y.rich-content-extended-descriptions" + static let richContentMathAsMathml: Self = "readium.a11y.rich-content-math-as-mathml" + static let richContentOpenCaptions: Self = "readium.a11y.rich-content-open-captions" + static let richContentTitle: Self = "readium.a11y.rich-content-title" + static let richContentTranscript: Self = "readium.a11y.rich-content-transcript" + static let richContentUnknown: Self = "readium.a11y.rich-content-unknown" + static let waysOfReadingNonvisualReadingAltText: Self = "readium.a11y.ways-of-reading-nonvisual-reading-alt-text" + static let waysOfReadingNonvisualReadingNoMetadata: Self = "readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" + static let waysOfReadingNonvisualReadingNone: Self = "readium.a11y.ways-of-reading-nonvisual-reading-none" + static let waysOfReadingNonvisualReadingNotFully: Self = "readium.a11y.ways-of-reading-nonvisual-reading-not-fully" + static let waysOfReadingNonvisualReadingReadable: Self = "readium.a11y.ways-of-reading-nonvisual-reading-readable" + static let waysOfReadingPrerecordedAudioComplementary: Self = "readium.a11y.ways-of-reading-prerecorded-audio-complementary" + static let waysOfReadingPrerecordedAudioNoMetadata: Self = "readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" + static let waysOfReadingPrerecordedAudioOnly: Self = "readium.a11y.ways-of-reading-prerecorded-audio-only" + static let waysOfReadingPrerecordedAudioSynchronized: Self = "readium.a11y.ways-of-reading-prerecorded-audio-synchronized" + static let waysOfReadingTitle: Self = "readium.a11y.ways-of-reading-title" + static let waysOfReadingVisualAdjustmentsModifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-modifiable" + static let waysOfReadingVisualAdjustmentsUnknown: Self = "readium.a11y.ways-of-reading-visual-adjustments-unknown" + static let waysOfReadingVisualAdjustmentsUnmodifiable: Self = "readium.a11y.ways-of-reading-visual-adjustments-unmodifiable" } diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index aeb73d3036..44708469be 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -110,7 +110,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .waysOfReadingTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } /// "Ways of reading" should be rendered even if there is no metadata. public let shouldDisplay: Bool = true @@ -267,7 +269,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public struct Navigation: AccessibilityDisplayField { /// Indicates whether no information about navigation features is /// available. - public var noMetadata: Bool { !tableOfContents && !index && !headings && !page } + public var noMetadata: Bool { + !tableOfContents && !index && !headings && !page + } /// Table of contents to all chapters of the text via links. public var tableOfContents: Bool @@ -283,9 +287,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .navigationTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -377,20 +385,24 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .richContentTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { if extendedAltTextDescriptions { - $0.append(.richContentExtended) + $0.append(.richContentExtendedDescriptions) } if mathFormula { $0.append(.richContentAccessibleMathDescribed) } if mathFormulaAsMathML { - $0.append(.richContentAccessibleMathAsMathml) + $0.append(.richContentMathAsMathml) } if mathFormulaAsLaTeX { $0.append(.richContentAccessibleMathAsLatex) @@ -507,9 +519,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .additionalAccessibilityInformationTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -649,9 +665,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .hazardsTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -766,7 +786,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .conformanceTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } /// "Conformance" should be rendered even if there is no metadata. public let shouldDisplay: Bool = true @@ -818,7 +840,9 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#legal-considerations public struct Legal: AccessibilityDisplayField { /// No information is available. - public var noMetadata: Bool { !exemption } + public var noMetadata: Bool { + !exemption + } /// This publication claims an accessibility exemption in some /// jurisdictions. @@ -826,9 +850,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .legalConsiderationsTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { !noMetadata } + public var shouldDisplay: Bool { + !noMetadata + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -865,9 +893,13 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { public let id: AccessibilityDisplayString = .accessibilitySummaryTitle - public var localizedTitle: String { id.localized } + public var localizedTitle: String { + id.localized + } - public var shouldDisplay: Bool { summary != nil } + public var shouldDisplay: Bool { + summary != nil + } public var statements: [AccessibilityDisplayStatement] { Array { @@ -930,7 +962,7 @@ public struct AccessibilityDisplayStatement: Sendable, Equatable, Identifiable { /// family and font size, spaces between paragraphs, sentences, words, and /// letters, as well as color of background and text) /// - /// Some statements contain HTTP links; so we use an ``NSAttributedString``. + /// Some statements contain HTTP links; so we use an `NSAttributedString`. /// /// - Parameter descriptive: When true, will return the long descriptive /// statement. @@ -966,7 +998,7 @@ public struct AccessibilityDisplayStatement: Sendable, Equatable, Identifiable { /// /// See https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/draft/localizations/ public struct AccessibilityDisplayString: RawRepresentable, ExpressibleByStringLiteral, Sendable, Hashable { - // Special key for the provided summary, which is not localized. + /// Special key for the provided summary, which is not localized. static let accessibilitySummary: Self = "readium.a11y.accessibility-summary" public let rawValue: String @@ -997,30 +1029,38 @@ public struct AccessibilityDisplayString: RawRepresentable, ExpressibleByStringL /// - Parameter descriptive: When true, will return the long descriptive /// statement. public func localized(descriptive: Bool) -> NSAttributedString { - NSAttributedString(string: bundleString("\(rawValue)-\(descriptive ? "descriptive" : "compact")") - .trimmingCharacters(in: .whitespacesAndNewlines) - ) + // Try the suffixed key first, then fall back to the unsuffixed key + // for strings where compact and descriptive variants are identical. + let suffixedKey = "\(rawValue)-\(descriptive ? "descriptive" : "compact")" + var string = bundleString(suffixedKey) + if string == suffixedKey { + string = bundleString(rawValue) + } + return NSAttributedString(string: string.trimmingCharacters(in: .whitespacesAndNewlines)) } private func bundleString(_ key: String, _ values: CVarArg...) -> String { - bundleString(key, in: Bundle.module, table: "W3CAccessibilityMetadataDisplayGuide", values) - } - - /// Returns the localized string in the main bundle, or fallback on the given - /// bundle if not found. - private func bundleString(_ key: String, in bundle: Bundle, table: String? = nil, _ values: [CVarArg]) -> String { - let defaultValue = bundle.localizedString(forKey: key, value: nil, table: table) - var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: nil) - if !values.isEmpty { - string = String(format: string, locale: .current, arguments: values) - } - return string + ReadiumLocalizedString(key, in: Bundle.module, table: "W3CAccessibilityMetadataDisplayGuide", values) } } -// Syntactic sugar +/// Syntactic sugar private extension Array where Element == AccessibilityDisplayStatement { mutating func append(_ string: AccessibilityDisplayString) { append(AccessibilityDisplayStatement(string: string)) } } + +// MARK: - Deprecated Aliases + +public extension AccessibilityDisplayString { + @available(*, deprecated, renamed: "richContentExtendedDescriptions") + static var richContentExtended: Self { + richContentExtendedDescriptions + } + + @available(*, deprecated, renamed: "richContentMathAsMathml") + static var richContentAccessibleMathAsMathml: Self { + richContentMathAsMathml + } +} diff --git a/Sources/Shared/Publication/Contributor.swift b/Sources/Shared/Publication/Contributor.swift index 46f52c7de7..ac8b94c2d4 100644 --- a/Sources/Shared/Publication/Contributor.swift +++ b/Sources/Shared/Publication/Contributor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,7 +11,9 @@ import ReadiumInternal public struct Contributor: Hashable, Sendable { /// The name of the contributor. public var localizedName: LocalizedString - public var name: String { localizedName.string } + public var name: String { + localizedName.string + } /// An unambiguous reference to this contributor. public var identifier: String? diff --git a/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift b/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift index b530040f73..752a824788 100644 --- a/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift +++ b/Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift b/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift index 4aafa53a6a..4d9fb60002 100644 --- a/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift +++ b/Sources/Shared/Publication/Extensions/Audio/Locator+Audio.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift index 62963d391e..3b9a8b9940 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift new file mode 100644 index 0000000000..93751878a3 --- /dev/null +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift @@ -0,0 +1,39 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal + +/// EPUB Media Overlay metadata. +/// https://readium.org/webpub-manifest/profiles/epub#5-metadata +public struct EPUBMediaOverlay: Equatable, Sendable { + /// Author-defined CSS class name to apply to the currently-playing EPUB + /// Content Document element. + public var activeClass: String? + + /// Author-defined CSS class name to apply to the EPUB Content Document's + /// document element when playback is active. + public var playbackActiveClass: String? + + public init(activeClass: String? = nil, playbackActiveClass: String? = nil) { + self.activeClass = activeClass + self.playbackActiveClass = playbackActiveClass + } + + public init?(json: Any?) { + guard let json = json as? [String: Any] else { return nil } + activeClass = json["activeClass"] as? String + playbackActiveClass = json["playbackActiveClass"] as? String + guard activeClass != nil || playbackActiveClass != nil else { return nil } + } + + public var json: [String: Any] { + makeJSON([ + "activeClass": encodeIfNotNil(activeClass), + "playbackActiveClass": encodeIfNotNil(playbackActiveClass), + ]) + } +} diff --git a/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift new file mode 100644 index 0000000000..1466764af7 --- /dev/null +++ b/Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift @@ -0,0 +1,16 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +private let mediaOverlayKey = "mediaOverlay" + +public extension Metadata { + /// Media overlay CSS class names for this publication. + var mediaOverlay: EPUBMediaOverlay? { + EPUBMediaOverlay(json: otherMetadata[mediaOverlayKey]) + } +} diff --git a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift index 501ce61225..09b540d214 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift index cf7e59abd5..5a0123970e 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift index e4c9815ebe..ff5539079f 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift index 7184f412df..8212ace922 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift index d82f309b58..859b736829 100644 --- a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift +++ b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift b/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift index 61be92254a..ef066045d3 100644 --- a/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift +++ b/Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift b/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift index f8f25e21e5..f8ca8389a7 100644 --- a/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift +++ b/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift b/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift index c3a8fdc60f..3018cf6966 100644 --- a/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift +++ b/Sources/Shared/Publication/Extensions/OPDS/Publication+OPDS.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift index 843c561d1c..247ad2a6b1 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,5 +9,7 @@ import Foundation /// Presentation extensions for `Metadata`. public extension Metadata { @available(*, unavailable, message: "This was removed from RWPM. You can still use the EPUB extensibility to access the original values.") - var presentation: Presentation { fatalError() } + var presentation: Presentation { + fatalError() + } } diff --git a/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift index c39e6f9bd7..553a088edc 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Presentation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift b/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift index 2c5d6dd507..1fa9251a8c 100644 --- a/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift +++ b/Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift new file mode 100644 index 0000000000..23e228bc66 --- /dev/null +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift @@ -0,0 +1,40 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal + +/// Represents a Guided Navigation Document, as defined in the +/// Readium Guided Navigation specification. +/// +/// https://readium.org/guided-navigation/ +public struct GuidedNavigationDocument: Hashable, Sendable { + /// A sequence of resources and/or media fragments into these resources, + /// meant to be presented sequentially to the user. + public var guided: [GuidedNavigationObject] + + public init(guided: [GuidedNavigationObject]) { + self.guided = guided + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Document", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let guided = [GuidedNavigationObject](json: json["guided"], warnings: warnings) + guard !guided.isEmpty else { + warnings?.log("Guided Navigation Document requires a non-empty guided array", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + self.init(guided: guided) + } +} diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift new file mode 100644 index 0000000000..b0ae6cebde --- /dev/null +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift @@ -0,0 +1,560 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumInternal + +/// Represents a single Guided Navigation Object, as defined in the +/// Readium Guided Navigation specification. +/// +/// https://readium.org/guided-navigation/ +public struct GuidedNavigationObject: Hashable, Sendable { + public typealias ID = String + + /// Unique identifier for this object, in the scope of the containing Guided + /// Navigation Document. + public let id: ID? + + /// References to resources referenced by the current Guided Navigation + /// Object. + public let refs: Refs? + + /// Textual equivalent of the resources or fragment of the resources + /// referenced by the current Guided Navigation Object. + public let text: Text? + + /// Convey the structural semantics of a publication. + public let roles: [Role] + + /// Text, audio or image description for the current Guided Navigation + /// Object. + public let description: Description? + + /// Items that are children of the containing Guided Navigation Object. + public let children: [GuidedNavigationObject] + + public init?( + id: ID? = nil, + refs: Refs? = nil, + text: Text? = nil, + roles: [Role] = [], + description: Description? = nil, + children: [GuidedNavigationObject] = [] + ) { + guard refs != nil || text != nil || !children.isEmpty else { + return nil + } + self.id = id + self.refs = refs + self.text = text + self.roles = roles + self.description = description + self.children = children + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Object", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let refs = try Refs(json: json, warnings: warnings) + let text = try Text(json: json["text"], warnings: warnings) + let children = [GuidedNavigationObject](json: json["children"], warnings: warnings) + + guard refs != nil || text != nil || !children.isEmpty else { + warnings?.log("Guided Navigation Object requires at least one of audioref, imgref, textref, videoref, text, or children", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let description = try Description(json: json["description"], warnings: warnings) + + self.init( + id: json["id"] as? String, + refs: refs, + text: text, + roles: (json["role"] as? [String])?.map(Role.init) ?? [], + description: description, + children: children + ) + } + + /// Represents a collection of Guided Navigation References declared in a + /// Readium Guided Navigation Object. + public struct Refs: Hashable, Sendable { + /// References a textual resource or a fragment of it. + public let text: AnyURL? + + /// References an image or a fragment of it. + public let img: AnyURL? + + /// References an audio resource or a fragment of it. + public let audio: AnyURL? + + /// References a video clip or a fragment of it. + public let video: AnyURL? + + public init?( + text: AnyURL? = nil, + img: AnyURL? = nil, + audio: AnyURL? = nil, + video: AnyURL? = nil + ) { + guard text != nil || img != nil || audio != nil || video != nil else { + return nil + } + + self.audio = audio + self.img = img + self.text = text + self.video = video + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Refs", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + let text = (json["textref"] as? String).flatMap(AnyURL.init(string:)) + let img = (json["imgref"] as? String).flatMap(AnyURL.init(string:)) + let audio = (json["audioref"] as? String).flatMap(AnyURL.init(string:)) + let video = (json["videoref"] as? String).flatMap(AnyURL.init(string:)) + + self.init(text: text, img: img, audio: audio, video: video) + } + } + + /// Represents the text content of a Guided Navigation Object. + /// + /// Can be either a bare string (normalized to `plain`) or an object with + /// `plain`, `ssml`, and `language` properties. + public struct Text: Hashable, Sendable { + public let plain: String? + public let ssml: String? + public let language: Language? + + public init?( + plain: String? = nil, + ssml: String? = nil, + language: Language? = nil + ) { + guard plain?.isEmpty == false || ssml?.isEmpty == false else { + return nil + } + self.plain = plain + self.ssml = ssml + self.language = language + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + if json == nil { + return nil + } + if let string = json as? String { + self.init(plain: string) + } else if let obj = json as? [String: Any] { + let plain = obj["plain"] as? String + let ssml = obj["ssml"] as? String + guard plain?.isEmpty == false || ssml?.isEmpty == false else { + warnings?.log("Guided Navigation String requires at least one of plain, or ssml", model: Self.self, source: json, severity: .moderate) + return nil + } + + self.init( + plain: plain, + ssml: ssml, + language: (obj["language"] as? String).map { Language(code: .bcp47($0)) } + ) + } else { + warnings?.log("Invalid Guided Navigation Text", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + } + } + + /// Represents the description for a Guided Navigation object. + public struct Description: Hashable, Sendable { + /// References to resources referenced by this description. + public let refs: Refs? + + /// Textual equivalent of the resources or fragment of the resources + /// referenced by this description. + public let text: Text? + + public init?( + refs: Refs? = nil, + text: Text? = nil + ) { + guard refs != nil || text != nil else { + return nil + } + self.refs = refs + self.text = text + } + + public init?(json: Any?, warnings: WarningLogger? = nil) throws { + guard let json = json as? [String: Any] else { + if json == nil { + return nil + } + warnings?.log("Invalid Guided Navigation Description", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + let refs = try Refs(json: json, warnings: warnings) + let text = try Text(json: json["text"], warnings: warnings) + + guard refs != nil || text != nil else { + warnings?.log("Guided Navigation Description requires at least one of audioref, imgref, textref, videoref, or text", model: Self.self, source: json, severity: .moderate) + throw JSONError.parsing(Self.self) + } + + self.init(refs: refs, text: text) + } + } + + /// Represents a role for a Guided Navigation Object. + /// + /// See https://readium.org/guided-navigation/roles + public struct Role: Hashable, Sendable { + public let id: String + + public init(_ id: String) { + self.id = id + } + + /// A sequential container for objects and/or child containers. + public static let sequence = Role("sequence") + + // MARK: Inherited from DPUB ARIA 1.0 + + /// A short summary of the principle ideas, concepts and conclusions of + /// the work, or of a section or excerpt within it. + public static let abstract = Role("abstract") + + /// A section or statement that acknowledges significant contributions + /// by persons, organizations, governments and other entities to the + /// realization of the work. + public static let acknowledgments = Role("acknowledgments") + + /// A closing statement from the author or a person of importance, + /// typically providing insight into how the content came to be written. + public static let afterword = Role("afterword") + + /// A section of supplemental information located after the primary + /// content that informs the content but is not central to it. + public static let appendix = Role("appendix") + + /// A link that allows the user to return to a related location in the + /// content (e.g., from a footnote to its reference or from a glossary + /// definition to where a term is used). + public static let backlink = Role("backlink") + + /// A list of external references cited in the work, which may be to + /// print or digital sources. + public static let bibliography = Role("bibliography") + + /// A reference to a bibliography entry. + public static let biblioref = Role("biblioref") + + /// A major thematic section of content in a work. + public static let chapter = Role("chapter") + + /// A short section of production notes particular to the edition + /// (e.g., describing the typeface used), often located at the end of a + /// work. + public static let colophon = Role("colophon") + + /// A concluding section or statement that summarizes the work or wraps + /// up the narrative. + public static let conclusion = Role("conclusion") + + /// An image that sets the mood or tone for the work and typically + /// includes the title and author. + public static let cover = Role("cover") + + /// An acknowledgment of the source of integrated content from + /// third-party sources, such as photos. + public static let credit = Role("credit") + + /// A collection of credits. + public static let credits = Role("credits") + + /// An inscription at the front of the work, typically addressed in + /// tribute to one or more persons close to the author. + public static let dedication = Role("dedication") + + /// A collection of notes at the end of a work or a section within it. + public static let endnotes = Role("endnotes") + + /// A quotation set at the start of the work or a section that + /// establishes the theme or sets the mood. + public static let epigraph = Role("epigraph") + + /// A concluding section of narrative that wraps up or comments on the + /// actions and events of the work, typically from a future perspective. + public static let epilogue = Role("epilogue") + + /// A set of corrections discovered after initial publication of the + /// work, sometimes referred to as corrigenda. + public static let errata = Role("errata") + + /// An illustration of the usage of a defined term or phrase. + public static let example = Role("example") + + /// Ancillary information, such as a citation or commentary, that + /// provides additional context to a referenced passage of text. + public static let footnote = Role("footnote") + + /// A brief dictionary of new, uncommon, or specialized terms used in + /// the content. + public static let glossary = Role("glossary") + + /// A reference to a glossary definition. + public static let glossref = Role("glossref") + + /// A navigational aid that provides a detailed list of links to key + /// subjects, names and other important topics covered in the work. + public static let index = Role("index") + + /// A preliminary section that typically introduces the scope or nature + /// of the work. + public static let introduction = Role("introduction") + + /// A reference to a footnote or endnote, typically appearing as a + /// superscripted number or symbol in the main body of text. + public static let noteref = Role("noteref") + + /// Notifies the user of consequences that might arise from an action + /// or event. Examples include warnings, cautions and dangers. + public static let notice = Role("notice") + + /// A separator denoting the position before which a break occurs + /// between two contiguous pages in a statically paginated version of + /// the content. + public static let pagebreak = Role("pagebreak") + + /// A navigational aid that provides a list of links to the pagebreaks + /// in the content. + public static let pagelist = Role("pagelist") + + /// A major structural division in a work that contains a set of + /// related sections dealing with a particular subject, narrative arc or + /// similar encapsulated theme. + public static let part = Role("part") + + /// An introductory section that precedes the work, typically written by + /// the author of the work. + public static let preface = Role("preface") + + /// An introductory section that sets the background to a work, + /// typically part of the narrative. + public static let prologue = Role("prologue") + + /// A distinctively placed or highlighted quotation from the current + /// content designed to draw attention to a topic or highlight a key + /// point. + public static let pullquote = Role("pullquote") + + /// A section of content structured as a series of questions and + /// answers, such as an interview or list of frequently asked questions. + public static let qna = Role("qna") + + /// An explanatory or alternate title for the work, or a section or + /// component within it. + public static let subtitle = Role("subtitle") + + /// Helpful information that clarifies some aspect of the content or + /// assists in its comprehension. + public static let tip = Role("tip") + + /// A navigational aid that provides an ordered list of links to the + /// major sectional headings in the content. + public static let toc = Role("toc") + + // MARK: Inherited from HTML and/or ARIA + + /// A self-contained composition in a document, page, application, or + /// site, which is intended to be independently distributable or + /// reusable. + public static let article = Role("article") + + /// Secondary or supplementary content. + public static let aside = Role("aside") + + /// Embedded sound content in a document. + public static let audio = Role("audio") + + /// A section that is quoted from another source. + public static let blockquote = Role("blockquote") + + /// Represents the content of an HTML document. + public static let body = Role("body") + + /// A caption for an image or a table. + public static let caption = Role("caption") + + /// A single cell of tabular data or content. + public static let cell = Role("cell") + + /// The header cell for a column, establishing a relationship between + /// it and the other cells in the same column. + public static let columnheader = Role("columnheader") + + /// A supporting section of the document, designed to be complementary + /// to the main content at a similar level in the DOM hierarchy. + public static let complementary = Role("complementary") + + /// A definition of a term or concept. + public static let definition = Role("definition") + + /// A disclosure widget that can be expanded. + public static let details = Role("details") + + /// An illustration, diagram, photo, code listing or similar, + /// referenced from the text of a work, and typically annotated with a + /// title, caption and/or credits. + public static let figure = Role("figure") + + /// Introductory content, typically a group of introductory or + /// navigational aids. + public static let header = Role("header") + + /// A heading for a section of the page. + public static let heading1 = Role("heading1") + + /// A heading for a section of the page. + public static let heading2 = Role("heading2") + + /// A heading for a section of the page. + public static let heading3 = Role("heading3") + + /// A heading for a section of the page. + public static let heading4 = Role("heading4") + + /// A heading for a section of the page. + public static let heading5 = Role("heading5") + + /// A heading for a section of the page. + public static let heading6 = Role("heading6") + + /// An image. + public static let image = Role("image") + + /// A structure that contains an enumeration of related content items. + public static let list = Role("list") + + /// A single item in an enumeration. + public static let listItem = Role("listItem") + + /// Content that is directly related to or expands upon the central + /// topic of the document. + public static let main = Role("main") + + /// Content that represents a mathematical expression. + public static let math = Role("math") + + /// A section of a page that links to other pages or to parts within + /// the page. + public static let navigation = Role("navigation") + + /// A paragraph. + public static let paragraph = Role("paragraph") + + /// Preformatted text which is to be presented exactly as written. + public static let preformatted = Role("preformatted") + + /// An element being used only for presentation and therefore that does + /// not have any accessibility semantics. + public static let presentation = Role("presentation") + + /// Content that is relevant to a specific, author-specified purpose + /// and sufficiently important that users will likely want to be able to + /// navigate to the section easily. + public static let region = Role("region") + + /// A row of data or content in a tabular structure. + public static let row = Role("row") + + /// The header cell for a row, establishing a relationship between it + /// and the other cells in the same row. + public static let rowheader = Role("rowheader") + + /// A generic standalone section of a document, which doesn't have a + /// more specific semantic element to represent it. + public static let section = Role("section") + + /// A divider that separates and distinguishes sections of content or + /// groups of menu items. + public static let separator = Role("separator") + + /// A summary of an element contained in details. + public static let summary = Role("summary") + + /// A structure containing data or content laid out in tabular form. + public static let table = Role("table") + + /// A word or phrase with a corresponding definition. + public static let term = Role("term") + + /// Embedded videos, movies, or audio files with captions in a + /// document. + public static let video = Role("video") + + // MARK: Inherited from EPUB SSV 1.1 + + /// An area in a comic panel that contains the words, spoken or thought, + /// of a character. + public static let bubble = Role("bubble") + + /// An introductory section that precedes the work, typically not + /// written by the author of the work. + public static let foreword = Role("foreword") + + /// A collection of references to audio clips. + public static let landmarks = Role("landmarks") + + /// A listing of audio clips included in the work. + public static let loa = Role("loa") + + /// A listing of illustrations included in the work. + public static let loi = Role("loi") + + /// A listing of tables included in the work. + public static let lot = Role("lot") + + /// A listing of video clips included in the work. + public static let lov = Role("lov") + + /// An individual frame, or drawing. + public static let panel = Role("panel") + + /// A group of panels (e.g., a strip). + public static let panelGroup = Role("panelGroup") + + /// An area of text in a comic panel that represents a sound. + public static let sound = Role("sound") + } +} + +// MARK: - Array Extension + +public extension Array where Element == GuidedNavigationObject { + init(json: Any?, warnings: WarningLogger? = nil) { + self.init() + guard let json = json as? [Any] else { + return + } + let objects = json.compactMap { try? GuidedNavigationObject(json: $0, warnings: warnings) } + append(contentsOf: objects) + } +} diff --git a/Sources/Shared/Publication/HREFNormalizer.swift b/Sources/Shared/Publication/HREFNormalizer.swift index 5d8ce6cec5..8ef9f7ae9c 100644 --- a/Sources/Shared/Publication/HREFNormalizer.swift +++ b/Sources/Shared/Publication/HREFNormalizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Layout.swift b/Sources/Shared/Publication/Layout.swift index 971e945f34..1e0651172f 100644 --- a/Sources/Shared/Publication/Layout.swift +++ b/Sources/Shared/Publication/Layout.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 4b6f745eea..fb29b09699 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -172,8 +172,8 @@ public struct Link: JSONEquatable, Hashable, Sendable { /// /// If the HREF is a template, the `parameters` are used to expand it /// according to RFC 6570. - public func url( - relativeTo baseURL: T?, + public func url( + relativeTo baseURL: URLConvertible?, parameters: [String: LosslessStringConvertible] = [:] ) -> AnyURL { let url = url(parameters: parameters) @@ -207,6 +207,12 @@ public struct Link: JSONEquatable, Hashable, Sendable { } } +extension Link: URLConvertible { + public var anyURL: AnyURL { + url() + } +} + public extension Array where Element == Link { /// Parses multiple JSON links into an array of Link. /// eg. let links = [Link](json: [["href", "http://link1"], ["href", "http://link2"]]) @@ -288,6 +294,11 @@ public extension Array where Element == Link { allSatisfy { $0.mediaType?.isHTML == true } } + /// Returns whether any resource in the collection matches the given media type. + func anyMatchingMediaType(_ mediaType: MediaType) -> Bool { + contains { mediaType.matches($0.mediaType) } + } + /// Returns whether all the resources in the collection are matching the given media type. func allMatchingMediaType(_ mediaType: MediaType) -> Bool { allSatisfy { mediaType.matches($0.mediaType) } diff --git a/Sources/Shared/Publication/LinkRelation.swift b/Sources/Shared/Publication/LinkRelation.swift index a6f70b86af..d18aeb8a1e 100644 --- a/Sources/Shared/Publication/LinkRelation.swift +++ b/Sources/Shared/Publication/LinkRelation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -45,6 +45,8 @@ public struct LinkRelation: Sendable { public static let cover = LinkRelation("cover") /// Links to a manifest. public static let manifest = LinkRelation("manifest") + /// Identifies a related resource. + public static let related = LinkRelation("related") /// Refers to a URI or templated URI that will perform a search. public static let search = LinkRelation("search") /// Conveys an identifier for the link's context. @@ -124,16 +126,16 @@ public struct LinkRelation: Sendable { // Authentication for OPDS – https://drafts.opds.io/authentication-for-opds-1.0.html - // Location where a client can authenticate the user with OAuth. + /// Location where a client can authenticate the user with OAuth. public static let opdsAuthenticate = LinkRelation("authenticate") - // Location where a client can refresh the Access Token by sending a Refresh Token. + /// Location where a client can refresh the Access Token by sending a Refresh Token. public static let opdsRefresh = LinkRelation("refresh") - // Logo associated to the Catalog provider. + /// Logo associated to the Catalog provider. public static let opdsLogo = LinkRelation("logo") - // Location where a user can register. + /// Location where a user can register. public static let opdsRegister = LinkRelation("register") - // Support resources for the user (either a website, an email or a telephone number). + /// Support resources for the user (either a website, an email or a telephone number). public static let opdsHelp = LinkRelation("help") } diff --git a/Sources/Shared/Publication/LocalizedString.swift b/Sources/Shared/Publication/LocalizedString.swift index d3de980d0e..ab257db16c 100644 --- a/Sources/Shared/Publication/LocalizedString.swift +++ b/Sources/Shared/Publication/LocalizedString.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -86,7 +86,9 @@ public enum LocalizedString: Hashable, Sendable { } extension LocalizedString: CustomStringConvertible { - public var description: String { string } + public var description: String { + string + } } /// Provides syntactic sugar when initializing a LocalizedString from a regular String (nonlocalized) or a [String: String] (localized). @@ -107,5 +109,7 @@ extension LocalizedString: LocalizedStringConvertible { } extension Dictionary: LocalizedStringConvertible where Key == String, Value == String { - public var localizedString: LocalizedString { .localized(self) } + public var localizedString: LocalizedString { + .localized(self) + } } diff --git a/Sources/Shared/Publication/Locator.swift b/Sources/Shared/Publication/Locator.swift index 92bacac493..353bd4faab 100644 --- a/Sources/Shared/Publication/Locator.swift +++ b/Sources/Shared/Publication/Locator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -169,7 +169,7 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { set { otherLocationsJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherLocationsJSON: JSONDictionary public init(fragments: [String] = [], progression: Double? = nil, totalProgression: Double? = nil, position: Int? = nil, otherLocations: JSONDictionary.Wrapped = [:]) { @@ -212,7 +212,9 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { } } - public var isEmpty: Bool { json.isEmpty } + public var isEmpty: Bool { + json.isEmpty + } public var json: JSONDictionary.Wrapped { makeJSON([ @@ -223,11 +225,15 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { ], additional: otherLocations) } - public var jsonString: String? { serializeJSONString(json) } + public var jsonString: String? { + serializeJSONString(json) + } /// Syntactic sugar to access the `otherLocations` values by subscripting `Locations` directly. /// locations["cssSelector"] == locations.otherLocations["cssSelector"] - public subscript(key: String) -> Any? { otherLocations[key] } + public subscript(key: String) -> Any? { + otherLocations[key] + } } public struct Text: Hashable, Loggable, Sendable { @@ -275,7 +281,9 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable { ]) } - public var jsonString: String? { serializeJSONString(json) } + public var jsonString: String? { + serializeJSONString(json) + } /// Returns a copy of this text after sanitizing its content for user display. public func sanitized() -> Locator.Text { @@ -376,7 +384,9 @@ public struct LocatorCollection: Hashable { /// Holds the metadata of a `LocatorCollection`. public struct Metadata: Hashable { public var localizedTitle: LocalizedString? - public var title: String? { localizedTitle?.string } + public var title: String? { + localizedTitle?.string + } /// Indicates the total number of locators in the collection. public var numberOfItems: Int? @@ -387,7 +397,7 @@ public struct LocatorCollection: Hashable { set { otherMetadataJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherMetadataJSON: JSONDictionary public init( diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index 72c867c3d5..ad8ee2c6c5 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -120,7 +120,7 @@ public struct Manifest: JSONEquatable, Hashable, Sendable { case .epub: // EPUB needs to be explicitly indicated in `conformsTo`, otherwise // it could be a regular Web Publication. - return readingOrder.allAreHTML && metadata.conformsTo.contains(.epub) + return metadata.conformsTo.contains(.epub) case .pdf: return readingOrder.allMatchingMediaType(.pdf) default: diff --git a/Sources/Shared/Publication/ManifestTransformer.swift b/Sources/Shared/Publication/ManifestTransformer.swift index 5cb4a54609..3056198829 100644 --- a/Sources/Shared/Publication/ManifestTransformer.swift +++ b/Sources/Shared/Publication/ManifestTransformer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift deleted file mode 100644 index 6467683a78..0000000000 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation - -/// The publicly accessible struct. - -/// Clip is the representation of a MediaOverlay file fragment. A clip represent -/// the synchronized audio for a piece of text, it has a file where its data -/// belong, start/end times relatives to this file's data and a duration -/// calculated from the aforementioned values. -public struct Clip { - /// The relative URL. - public var relativeUrl: URL! - /// The relative fragmentId. - public var fragmentId: String? - /// Start time in seconds. - public var start: Double! - /// End time in seconds. - public var end: Double! - /// Total clip duration in seconds (end - start). -// @available(iOS, deprecated: 9.0, message: "Don't use it when the value is negative, because some information is missing in the original SMIL file. Try to get the duration from file system or APIs in Fetcher, then minus the start value.") - public var duration: Double! - - public init() {} -} - -/// The Error enumeration of the MediaOverlayNode class. -/// -/// - audio: Couldn't generate a proper clip due to erroneous audio property. -/// - timersParsing: Couldn't generate a proper clip due to timersParsing failure. -public enum MediaOverlayNodeError: Error { - case audio - case timersParsing -} - -/// Represents a MediaOverlay XML node. -public class MediaOverlayNode { - public var text: String? - public var clip: Clip? - - public var role = [String]() - public var children = [MediaOverlayNode]() - - public init(_ text: String? = nil, clip: Clip? = nil) { - self.text = text - self.clip = clip - self.clip?.fragmentId = fragmentId() - } - - // MARK: - Internal Methods. - - /// Return the MO node's fragmentId. - /// - /// - Returns: Node's fragment id. - public func fragmentId() -> String? { - guard let text = text else { - return nil - } - return text.components(separatedBy: "#").last - } -} diff --git a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift b/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift deleted file mode 100644 index cd0c43f93a..0000000000 --- a/Sources/Shared/Publication/Media Overlays/MediaOverlays.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation - -/// Errors related to MediaOverlays. -/// -/// - nodeNotFound: Couldn't find any node for the given `forFragmentId`. -public enum MediaOverlaysError: Error { - case nodeNotFound(forFragmentId: String?) -} - -// The functionnal wrapper around mediaOverlayNodes. - -/// The object representing the MediaOverlays for a Link. -/// Two ways of using it, using the `Clip`s or `MediaOverlayNode`s. -/// Clips or a functionnal representation of a `MediaOverlayNode` (while the -/// MediaOverlayNode is more of an XML->Object representation. -public class MediaOverlays { - public var nodes: [MediaOverlayNode]! - - public init(withNodes nodes: [MediaOverlayNode] = [MediaOverlayNode]()) { - self.nodes = nodes - } - - public func append(_ newNode: MediaOverlayNode) { - nodes.append(newNode) - } - - /// Get the audio `Clip` associated to an audio Fragment id. - /// The fragment id can be found in the HTML document in

& tags, - /// it refer to a element of one of the SMIL files, providing informations - /// about the synchronized audio. - /// This function returns the clip representing this element from SMIL. - /// - /// - Parameter id: The audio fragment id. - /// - Returns: The `Clip`, representation of the associated SMIL element. - /// - Throws: `MediaOverlayNodeError.audio`, - /// `MediaOverlayNodeError.timersParsing`. - public func clip(forFragmentId id: String) throws -> Clip? { - let clip: Clip? - - do { - let fragmentNode = try node(forFragmentId: id) - - clip = fragmentNode.clip - } - return clip - } - - /// Get the audio `Clip` for the node right after the one designated by - /// `id`. - /// The fragment id can be found in the HTML document in

& tags, - /// it refer to a element of one of the SMIL files, providing informations - /// about the synchronized audio. - /// This function returns the `Clip representing the element following this - /// element from SMIL. - /// - /// - Parameter id: The audio fragment id. - /// - Returns: The `Clip` for the node element positioned right after the - /// one designated by `id`. - /// - Throws: `MediaOverlayNodeError.audio`, - /// `MediaOverlayNodeError.timersParsing`. - public func clip(nextAfterFragmentId id: String) throws -> Clip? { - let clip: Clip? - - do { - let fragmentNextNode = try node(nextAfterFragmentId: id) - clip = fragmentNextNode.clip - } - return clip - } - - /// Return the `MediaOverlayNode` found for the given 'fragment id'. - /// - /// - Parameter forFragment: The SMIL fragment identifier. - /// - Returns: The node associated to the fragment. - public func node(forFragmentId id: String?) throws -> MediaOverlayNode { - guard let node = _findNode(forFragment: id, inNodes: nodes) else { - throw MediaOverlaysError.nodeNotFound(forFragmentId: id) - } - return node - } - - /// Return the `MediaOverlayNode` right after the node found for the given - /// 'fragment id'. - /// - /// - Parameter forFragment: The SMIL fragment identifier. - /// - Returns: The node right after the node associated to the fragment. - public func node(nextAfterFragmentId id: String?) throws -> MediaOverlayNode { - let ret = _findNextNode(forFragment: id, inNodes: nodes) - - guard let node = ret.found else { - throw MediaOverlaysError.nodeNotFound(forFragmentId: id) - } - return node - } - - // MARK: - Fileprivate Methods. - - /// [RECURISVE] - /// Find the node () corresponding to "fragment" ?? nil. - /// - /// - Parameters: - /// - fragment: The current fragment name for which we are looking the - /// associated media overlay node. - /// - nodes: The set of MediaOverlayNodes where to search. Default to - /// self children. - /// - Returns: The node we found ?? nil. - private func _findNode(forFragment fragment: String?, - inNodes nodes: [MediaOverlayNode]) -> MediaOverlayNode? - { - // For each node of the current scope.. - for node in nodes { - // If the node is a "section" ( sequence element).. - // TODO: ask if really useful? - if node.role.contains("section") { - // Try to find par nodes inside. - if let found = _findNode(forFragment: fragment, inNodes: node.children) { - return found - } - } - // If the node text refer to filename or that filename is nil, - // return node. - if fragment == nil || node.text?.contains(fragment!) ?? false { - return node - } - } - // If nothing found, return nil. - return nil - } - - /// [RECURISVE] - /// Find the node () corresponding to the next one after the given - /// "fragment" ?? nil. - /// - /// - Parameters: - /// - fragment: The fragment name corresponding to the node previous to - /// the one we want. - /// - nodes: The set of MediaOverlayNodes where to search. Default to - /// self children. - /// - Returns: The node we found ?? nil. - private func _findNextNode(forFragment fragment: String?, - inNodes nodes: [MediaOverlayNode]) -> (found: MediaOverlayNode?, prevFound: Bool) - { - var previousNodeFoundFlag = false - - // For each node of the current scope.. - for node in nodes { - guard !previousNodeFoundFlag else { - /// If the node is a section, we get the first non section child. - if node.role.contains("section") { - if let validChild = getFirstNonSectionChild(of: node) { - return (validChild, false) - } else { - // Try next nodes. - continue - } - } - /// Else we just return it. - return (node, false) - } - // If the node is a "section" ( sequence element).. - if node.role.contains("section") { - let ret = _findNextNode(forFragment: fragment, inNodes: node.children) - if let foundNode = ret.found { - return (foundNode, false) - } - previousNodeFoundFlag = ret.prevFound - } - // If the node text refer to filename or that filename is nil, - // return node. - if fragment == nil || node.text?.contains(fragment!) ?? false { - previousNodeFoundFlag = true - } - } - // If nothing found, return nil. - return (nil, previousNodeFoundFlag) - } - - /// Returns the closest non section children node found. - /// - /// - Parameter node: The section node - /// - Returns: The closest non section node or nil. - private func getFirstNonSectionChild(of node: MediaOverlayNode) -> MediaOverlayNode? { - for node in node.children { - if node.role.contains("section") { - if let found = getFirstNonSectionChild(of: node) { - return found - } - } else { - return node - } - } - return nil - } -} diff --git a/Sources/Shared/Publication/Metadata.swift b/Sources/Shared/Publication/Metadata.swift index 15f14a76ee..27edf4d3b1 100644 --- a/Sources/Shared/Publication/Metadata.swift +++ b/Sources/Shared/Publication/Metadata.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -18,10 +18,14 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable { public var conformsTo: [Publication.Profile] public var localizedTitle: LocalizedString? - public var title: String? { localizedTitle?.string } + public var title: String? { + localizedTitle?.string + } public var localizedSubtitle: LocalizedString? - public var subtitle: String? { localizedSubtitle?.string } + public var subtitle: String? { + localizedSubtitle?.string + } public var accessibility: Accessibility? public var modified: Date? @@ -67,7 +71,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable { set { otherMetadataJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite [String: Any] + /// Trick to keep the struct equatable despite [String: Any] private var otherMetadataJSON: JSONDictionary public init( diff --git a/Sources/Shared/Publication/Properties.swift b/Sources/Shared/Publication/Properties.swift index 9a909602bf..a44b7f6df0 100644 --- a/Sources/Shared/Publication/Properties.swift +++ b/Sources/Shared/Publication/Properties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -16,7 +16,7 @@ public struct Properties: Hashable, Loggable, WarningLogger, Sendable { set { otherPropertiesJSON = JSONDictionary(newValue) ?? JSONDictionary() } } - // Trick to keep the struct equatable despite JSONDictionary.Wrapped + /// Trick to keep the struct equatable despite JSONDictionary.Wrapped private var otherPropertiesJSON: JSONDictionary public init(_ otherProperties: JSONDictionary.Wrapped = [:]) { @@ -54,10 +54,21 @@ public struct Properties: Hashable, Loggable, WarningLogger, Sendable { /// /// https://github.com/readium/webpub-manifest/blob/master/properties.md#core-properties public extension Properties { + private static var pageKey: String { + "page" + } + /// Indicates how the linked resource should be displayed in a reading /// environment that displays synthetic spreads. var page: Page? { - parseRaw(otherProperties["page"]) + get { parseRaw(otherProperties[Self.pageKey]) } + set { + if let newValue = newValue { + otherProperties[Self.pageKey] = newValue.rawValue + } else { + otherProperties.removeValue(forKey: Self.pageKey) + } + } } /// Indicates how the linked resource should be displayed in a reading diff --git a/Sources/Shared/Publication/Protection/ContentProtection.swift b/Sources/Shared/Publication/Protection/ContentProtection.swift index db3c9e779c..83ea59659d 100644 --- a/Sources/Shared/Publication/Protection/ContentProtection.swift +++ b/Sources/Shared/Publication/Protection/ContentProtection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift index 8bda96f119..1909fe6b79 100644 --- a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift +++ b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index daf5c44e98..bc8e844596 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,14 +14,31 @@ public class Publication: Closeable, Loggable { private let container: Container private let services: [PublicationService] - public var context: [String] { manifest.context } - public var metadata: Metadata { manifest.metadata } - public var links: [Link] { manifest.links } + public var context: [String] { + manifest.context + } + + public var metadata: Metadata { + manifest.metadata + } + + public var links: [Link] { + manifest.links + } + /// Identifies a list of resources in reading order for the publication. - public var readingOrder: [Link] { manifest.readingOrder } + public var readingOrder: [Link] { + manifest.readingOrder + } + /// Identifies resources that are necessary for rendering the publication. - public var resources: [Link] { manifest.resources } - public var subcollections: [String: [PublicationCollection]] { manifest.subcollections } + public var resources: [Link] { + manifest.resources + } + + public var subcollections: [String: [PublicationCollection]] { + manifest.subcollections + } public init( manifest: Manifest, @@ -66,7 +83,9 @@ public class Publication: Closeable, Loggable { /// The URL where this publication is served, computed from the `Link` with `self` relation. /// /// e.g. https://provider.com/pub1293/manifest.json gives https://provider.com/pub1293/ - public var baseURL: HTTPURL? { manifest.baseURL } + public var baseURL: HTTPURL? { + manifest.baseURL + } /// Finds the first Link having the given `href` in the publication's links. public func linkWithHREF(_ href: T) -> Link? { @@ -169,7 +188,7 @@ public class Publication: Closeable, Loggable { _ manifest: inout Manifest, _ container: inout Container, _ services: inout PublicationServicesBuilder - ) -> Void + ) async -> Void private var manifest: Manifest private var container: Container @@ -185,12 +204,12 @@ public class Publication: Closeable, Loggable { self.servicesBuilder = servicesBuilder } - public mutating func apply(_ transform: Transform?) { + public mutating func apply(_ transform: Transform?) async { guard let transform = transform else { return } - transform(&manifest, &container, &servicesBuilder) + await transform(&manifest, &container, &servicesBuilder) } /// Builds the `Publication` from its parts. diff --git a/Sources/Shared/Publication/PublicationCollection.swift b/Sources/Shared/Publication/PublicationCollection.swift index 522709a11e..0de2406f76 100644 --- a/Sources/Shared/Publication/PublicationCollection.swift +++ b/Sources/Shared/Publication/PublicationCollection.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,7 +21,7 @@ public struct PublicationCollection: JSONEquatable, Hashable, Sendable { /// Subcollections indexed by their role in this collection. public var subcollections: [String: [PublicationCollection]] - // Trick to keep the struct hashable despite [String: Any] + /// Trick to keep the struct hashable despite [String: Any] private var metadataJSON: JSONDictionary public init(metadata: [String: Any] = [:], links: [Link], subcollections: [String: [PublicationCollection]] = [:]) { diff --git a/Sources/Shared/Publication/ReadingProgression.swift b/Sources/Shared/Publication/ReadingProgression.swift index bf6ea51290..2942876a86 100644 --- a/Sources/Shared/Publication/ReadingProgression.swift +++ b/Sources/Shared/Publication/ReadingProgression.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift index 4ae9398585..2993eb2401 100644 --- a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift +++ b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -37,8 +37,13 @@ public protocol ContentProtectionService: PublicationService { } public extension ContentProtectionService { - var credentials: String? { nil } - var rights: UserRights { UnrestrictedUserRights() } + var credentials: String? { + nil + } + + var rights: UserRights { + UnrestrictedUserRights() + } } // MARK: Publication Helpers diff --git a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift index 50c53d1bbc..1a247a0536 100644 --- a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift +++ b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -38,20 +38,40 @@ public protocol UserRights { public class UnrestrictedUserRights: UserRights { public init() {} - public func canCopy(text: String) async -> Bool { true } - public func copy(text: String) async -> Bool { true } + public func canCopy(text: String) async -> Bool { + true + } - public func canPrint(pageCount: Int) async -> Bool { true } - public func print(pageCount: Int) async -> Bool { true } + public func copy(text: String) async -> Bool { + true + } + + public func canPrint(pageCount: Int) async -> Bool { + true + } + + public func print(pageCount: Int) async -> Bool { + true + } } /// A `UserRights` which forbids all rights. public class AllRestrictedUserRights: UserRights { public init() {} - public func canCopy(text: String) async -> Bool { false } - public func copy(text: String) async -> Bool { false } + public func canCopy(text: String) async -> Bool { + false + } + + public func copy(text: String) async -> Bool { + false + } + + public func canPrint(pageCount: Int) async -> Bool { + false + } - public func canPrint(pageCount: Int) async -> Bool { false } - public func print(pageCount: Int) async -> Bool { false } + public func print(pageCount: Int) async -> Bool { + false + } } diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index ef543343cf..ff1e497991 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -63,9 +63,13 @@ public struct AnyEquatableContentElement: Equatable, ContentElement { self.element = element } - public var locator: Locator { element.locator } + public var locator: Locator { + element.locator + } - public var attributes: [ContentAttribute] { element.attributes } + public var attributes: [ContentAttribute] { + element.attributes + } public func isEqualTo(_ other: ContentElement) -> Bool { element.isEqualTo(other) @@ -85,7 +89,9 @@ public protocol TextualContentElement: ContentElement { } public extension TextualContentElement { - var text: String? { accessibilityLabel } + var text: String? { + accessibilityLabel + } } /// An element referencing an embedded external resource. @@ -200,8 +206,13 @@ public struct TextContentElement: Hashable, TextualContentElement { /// /// The `V` phantom type is there to perform static type checking when requesting an attribute. public struct ContentAttributeKey: Hashable { - public static var accessibilityLabel: ContentAttributeKey { .init("accessibilityLabel") } - public static var language: ContentAttributeKey { .init("language") } + public static var accessibilityLabel: ContentAttributeKey { + .init("accessibilityLabel") + } + + public static var language: ContentAttributeKey { + .init("language") + } public let key: String public init(_ key: String) { @@ -231,8 +242,13 @@ public protocol ContentAttributesHolder { } public extension ContentAttributesHolder { - var language: Language? { self[.language] } - var accessibilityLabel: String? { self[.accessibilityLabel] } + var language: Language? { + self[.language] + } + + var accessibilityLabel: String? { + self[.accessibilityLabel] + } /// Gets the first attribute with the given `key`. subscript(_ key: ContentAttributeKey) -> T? { diff --git a/Sources/Shared/Publication/Services/Content/ContentService.swift b/Sources/Shared/Publication/Services/Content/ContentService.swift index a4c15a7751..eab94e7bc8 100644 --- a/Sources/Shared/Publication/Services/Content/ContentService.swift +++ b/Sources/Shared/Publication/Services/Content/ContentService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift index b97aa5a0bd..7445039461 100644 --- a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift +++ b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,7 +11,11 @@ public typealias ContentTokenizer = Tokenizer /// A `ContentTokenizer` using a `TextTokenizer` to split the text of the `Content`. /// -/// - Parameter contextSnippetLength: Length of `before` and `after` snippets in the produced `Locator`s. +/// - Parameters: +/// - defaultLanguage: The language used by the tokenizer if the content doesn't specify one. +/// - contextSnippetLength: Length of `before` and `after` snippets in the produced `Locator`s. +/// - textTokenizerFactory: A closure providing the underlying `TextTokenizer` to use +/// for a given language. public func makeTextContentTokenizer( defaultLanguage: Language?, contextSnippetLength: Int = 50, diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index ab7ba00bd4..34bc82eafe 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -99,7 +99,8 @@ public class HTMLResourceContentIterator: ContentIterator { private lazy var elementsTask = Task { await resource - .readAsString() + .read() + .asString() .eraseToAnyError() .tryMap { try SwiftSoup.parse($0) } .tryMap { try parse(document: $0, locator: locator, beforeMaxLength: beforeMaxLength) } @@ -227,7 +228,7 @@ public class HTMLResourceContentIterator: ContentIterator { } } - public func head(_ node: Node, _ depth: Int) throws { + func head(_ node: Node, _ depth: Int) throws { if let node = node as? Element { let parent = ParentElement(element: node) if node.isBlock() { diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index 2ace7f311c..f2b3c15612 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Cover/CoverService.swift b/Sources/Shared/Publication/Services/Cover/CoverService.swift index 6f16fbc7ae..dd1d1a9a6f 100644 --- a/Sources/Shared/Publication/Services/Cover/CoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/CoverService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -49,35 +49,18 @@ public extension CoverService { public extension Publication { /// Returns the publication cover as a bitmap at its maximum size. func cover() async -> ReadResult { - if let service = findService(CoverService.self) { - return await service.cover() - } else { - return await coverFromManifest() + guard let service = findService(CoverService.self) else { + return .success(nil) } + return await service.cover() } /// Returns the publication cover as a bitmap, scaled down to fit the given `maxSize`. func coverFitting(maxSize: CGSize) async -> ReadResult { - if let service = findService(CoverService.self) { - return await service.coverFitting(maxSize: maxSize) - } else { - return await coverFromManifest() - .map { $0?.scaleToFit(maxSize: maxSize) } - } - } - - /// Extracts the first valid cover from the manifest links with `cover` relation. - private func coverFromManifest() async -> ReadResult { - for link in linksWithRel(.cover) { - guard let image = await get(link)? - .read().getOrNil() - .flatMap({ UIImage(data: $0) }) - else { - continue - } - return .success(image) + guard let service = findService(CoverService.self) else { + return .success(nil) } - return .success(nil) + return await service.coverFitting(maxSize: maxSize) } } diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index 786ddb482a..cca40733a3 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -41,9 +41,11 @@ public final class GeneratedCoverService: CoverService { await cachedCover().map { $0 as UIImage? } } - public var links: [Link] { [coverLink] } + public var links: [Link] { + [coverLink] + } - public func get(_ href: T) -> (any Resource)? where T: URLConvertible { + public func get(_ href: T) -> (any Resource)? { guard href.anyURL.isEquivalentTo(coverLink.url()) else { return nil } @@ -62,7 +64,7 @@ public final class GeneratedCoverService: CoverService { private class CoverResource: Resource { private let cover: () async -> ReadResult - public init(cover: @escaping () async -> ReadResult) { + init(cover: @escaping () async -> ReadResult) { self.cover = cover } diff --git a/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift b/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift new file mode 100644 index 0000000000..0f79f0bced --- /dev/null +++ b/Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift @@ -0,0 +1,63 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import UIKit + +/// A `CoverService` which retrieves the cover from the publication container. +/// +/// It will look for: +/// 1. Links with explicit `cover` relation in the resources. +/// 2. First `readingOrder` resource if it's a bitmap, or if it has a bitmap +/// `alternates`. +public final class ResourceCoverService: CoverService { + private let context: PublicationServiceContext + + public init(context: PublicationServiceContext) { + self.context = context + } + + public func cover() async -> ReadResult { + // Try resources with explicit `cover` relation + for link in context.manifest.linksWithRel(.cover) { + if let image = await loadImage(from: link) { + return .success(image) + } + } + + // Fallback: first reading order bitmap or alternate + if let firstLink = context.manifest.readingOrder.first { + if firstLink.mediaType?.isBitmap == true { + if let image = await loadImage(from: firstLink) { + return .success(image) + } + } + for alternate in firstLink.alternates { + if alternate.mediaType?.isBitmap == true { + if let image = await loadImage(from: alternate) { + return .success(image) + } + } + } + } + + return .success(nil) + } + + private func loadImage(from link: Link) async -> UIImage? { + guard + let resource = context.container[link.url()], + let data = try? await resource.read().get() + else { + return nil + } + return UIImage(data: data) + } + + public static func makeFactory() -> (PublicationServiceContext) -> ResourceCoverService { + { ResourceCoverService(context: $0) } + } +} diff --git a/Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift b/Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift new file mode 100644 index 0000000000..78bbfb13fa --- /dev/null +++ b/Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift @@ -0,0 +1,62 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +public typealias GuidedNavigationServiceFactory = (PublicationServiceContext) -> GuidedNavigationService? + +/// Provides pre-authored ``GuidedNavigationDocument`` objects for individual +/// reading order resources. +public protocol GuidedNavigationService: PublicationService { + /// Whether this publication has any pre-authored guided navigation + /// documents at all. + var hasGuidedNavigation: Bool { get } + + /// Returns whether a pre-authored ``GuidedNavigationDocument`` exists for + /// the given reading order resource, without fetching or parsing it. + func hasGuidedNavigation(for href: any URLConvertible) -> Bool + + /// Returns the pre-authored ``GuidedNavigationDocument`` for the given + /// reading order resource, or `nil` if none exists for this resource. + func guidedNavigationDocument(for href: any URLConvertible) async -> ReadResult +} + +// MARK: Publication Helpers + +public extension Publication { + /// Whether this publication has any pre-authored guided navigation + /// documents. + var hasGuidedNavigation: Bool { + findService(GuidedNavigationService.self)?.hasGuidedNavigation ?? false + } + + /// Returns whether a pre-authored guided navigation document exists for + /// the given reading order resource. + func hasGuidedNavigation(for href: any URLConvertible) -> Bool { + findService(GuidedNavigationService.self)?.hasGuidedNavigation(for: href) ?? false + } + + /// Returns the pre-authored guided navigation document for the given + /// reading order resource, or `nil` if none exists. + func guidedNavigationDocument(for href: any URLConvertible) async -> ReadResult { + guard let service = findService(GuidedNavigationService.self) else { + return .success(nil) + } + return await service.guidedNavigationDocument(for: href) + } +} + +// MARK: PublicationServicesBuilder Helpers + +public extension PublicationServicesBuilder { + mutating func setGuidedNavigationServiceFactory(_ factory: GuidedNavigationServiceFactory?) { + if let factory { + set(GuidedNavigationService.self, factory) + } else { + remove(GuidedNavigationService.self) + } + } +} diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index 60b009c03b..a4ca37b17a 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Locator/LocatorService.swift b/Sources/Shared/Publication/Services/Locator/LocatorService.swift index 9c89e211c7..aad19b0362 100644 --- a/Sources/Shared/Publication/Services/Locator/LocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/LocatorService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -27,9 +27,17 @@ public protocol LocatorService: PublicationService { } public extension LocatorService { - func locate(_ locator: Locator) async -> Locator? { nil } - func locate(_ link: Link) async -> Locator? { nil } - func locate(progression: Double) async -> Locator? { nil } + func locate(_ locator: Locator) async -> Locator? { + nil + } + + func locate(_ link: Link) async -> Locator? { + nil + } + + func locate(progression: Double) async -> Locator? { + nil + } } // MARK: Publication Helpers diff --git a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift index 1cb3a05f17..e34c7343d6 100644 --- a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift index 0dbbd0071e..1bdc239308 100644 --- a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Positions/PositionsService.swift b/Sources/Shared/Publication/Services/Positions/PositionsService.swift index 514881fe96..0c7ace733c 100644 --- a/Sources/Shared/Publication/Services/Positions/PositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -31,9 +31,11 @@ private let positionsLink = Link( ) public extension PositionsService { - var links: [Link] { [positionsLink] } + var links: [Link] { + [positionsLink] + } - func get(_ href: T) -> (any Resource)? where T: URLConvertible { + func get(_ href: T) -> (any Resource)? { guard href.anyURL.isEquivalentTo(positionsLink.url()) else { return nil } @@ -103,7 +105,8 @@ public extension Publication { private func positionsFromManifest() async -> ReadResult<[Locator]> { await links.firstWithMediaType(.readiumPositions) .flatMap { get($0) }? - .readAsJSONObject() + .read() + .asJSONObject() .map { [Locator](json: $0["positions"]) } ?? .success([]) } diff --git a/Sources/Shared/Publication/Services/PublicationService.swift b/Sources/Shared/Publication/Services/PublicationService.swift index 79fd86f8a4..ac90a932b2 100644 --- a/Sources/Shared/Publication/Services/PublicationService.swift +++ b/Sources/Shared/Publication/Services/PublicationService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -37,9 +37,13 @@ public protocol PublicationService: Closeable { } public extension PublicationService { - var links: [Link] { [] } + var links: [Link] { + [] + } - func get(_ href: T) -> Resource? { nil } + func get(_ href: T) -> Resource? { + nil + } } /// Factory used to create a `PublicationService`. diff --git a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift index 730c88e4bf..56027899dc 100644 --- a/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift +++ b/Sources/Shared/Publication/Services/PublicationServicesBuilder.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -15,7 +15,8 @@ public struct PublicationServicesBuilder { public init( content: ContentServiceFactory? = nil, contentProtection: ContentProtectionServiceFactory? = nil, - cover: CoverServiceFactory? = nil, + cover: CoverServiceFactory? = ResourceCoverService.makeFactory(), + guidedNavigation: GuidedNavigationServiceFactory? = nil, locator: LocatorServiceFactory? = { DefaultLocatorService(publication: $0.publication) }, positions: PositionsServiceFactory? = nil, search: SearchServiceFactory? = nil, @@ -24,6 +25,7 @@ public struct PublicationServicesBuilder { setContentServiceFactory(content) setContentProtectionServiceFactory(contentProtection) setCoverServiceFactory(cover) + setGuidedNavigationServiceFactory(guidedNavigation) setLocatorServiceFactory(locator) setPositionsServiceFactory(positions) setSearchServiceFactory(search) diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index a6d8c49b27..d16689af34 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -124,7 +124,9 @@ public enum SearchError: Error { // MARK: Publication Helpers public extension Publication { - private var searchService: SearchService? { findService(SearchService.self) } + private var searchService: SearchService? { + findService(SearchService.self) + } /// Indicates whether the content of this publication can be searched. var isSearchable: Bool { diff --git a/Sources/Shared/Publication/Services/Search/StringSearchService.swift b/Sources/Shared/Publication/Services/Search/StringSearchService.swift index fe74f017da..851c6d4372 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift b/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift index 45e2ae9865..b177c08137 100644 --- a/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift +++ b/Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Publication/Subject.swift b/Sources/Shared/Publication/Subject.swift index c7c8682e92..1f8733ad15 100644 --- a/Sources/Shared/Publication/Subject.swift +++ b/Sources/Shared/Publication/Subject.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -7,10 +7,13 @@ import Foundation import ReadiumInternal -// https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects +/// https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects public struct Subject: Hashable, Sendable { public var localizedName: LocalizedString - public var name: String { localizedName.string } + public var name: String { + localizedName.string + } + public var sortAs: String? public var scheme: String? // URI public var code: String? diff --git a/Sources/Shared/Publication/TDM.swift b/Sources/Shared/Publication/TDM.swift index f8760f64ca..bf4cc30148 100644 --- a/Sources/Shared/Publication/TDM.swift +++ b/Sources/Shared/Publication/TDM.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings similarity index 52% rename from Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings rename to Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings index 1afeade2f1..b5b399fad2 100644 --- a/Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings +++ b/Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -1,101 +1,56 @@ -// DO NOT EDIT. File generated automatically from v2.0.c of the en-US JSON strings. +// DO NOT EDIT. File generated automatically from the en JSON strings of https://github.com/edrlab/thorium-locales/. -"readium.a11y.ways-of-reading-title" = "Ways of reading"; -"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Has alternative text"; -"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Has alternative text descriptions for images"; -"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata-compact" = "No information about nonvisual reading is available"; -"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata-descriptive" = "No information about nonvisual reading is available"; -"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Not readable in read aloud or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "The content is not readable as read aloud speech or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Not fully readable in read aloud or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Not all of the content will be readable as read aloud speech or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Readable in read aloud or dynamic braille"; -"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "All content can be read as read aloud speech or dynamic braille"; -"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Prerecorded audio clips"; -"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Prerecorded audio clips are embedded in the content"; -"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata-compact" = "No information about prerecorded audio is available"; -"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata-descriptive" = "No information about prerecorded audio is available"; -"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Prerecorded audio only"; -"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Audiobook with no text alternative"; -"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Prerecorded audio synchronized with text"; -"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "All the content is available as prerecorded audio synchronized with text"; -"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "Appearance can be modified"; -"readium.a11y.ways-of-reading-visual-adjustments-modifiable-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)"; -"readium.a11y.ways-of-reading-visual-adjustments-unknown-compact" = "No information about appearance modifiability is available"; -"readium.a11y.ways-of-reading-visual-adjustments-unknown-descriptive" = "No information about appearance modifiability is available"; -"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "Appearance cannot be modified"; -"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-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"; -"readium.a11y.conformance-title" = "Conformance"; -"readium.a11y.conformance-details-title" = "Detailed conformance information"; +"readium.a11y.accessibility-summary-no-metadata" = "No information is available"; +"readium.a11y.accessibility-summary-publisher-contact" = "For more information about the accessibility of this product, please contact the publisher: "; +"readium.a11y.accessibility-summary-title" = "Accessibility summary"; +"readium.a11y.additional-accessibility-information-aria-compact" = "ARIA roles included"; +"readium.a11y.additional-accessibility-information-aria-descriptive" = "Content is enhanced with ARIA roles to optimize organization and facilitate navigation"; +"readium.a11y.additional-accessibility-information-audio-descriptions" = "Audio descriptions"; +"readium.a11y.additional-accessibility-information-braille" = "Braille"; +"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" = "Color is not the sole means of conveying information"; +"readium.a11y.additional-accessibility-information-dyslexia-readability" = "Dyslexia readability"; +"readium.a11y.additional-accessibility-information-full-ruby-annotations" = "Full ruby annotations"; +"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" = "High contrast between foreground and background audio"; +"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" = "High contrast between foreground text and background"; +"readium.a11y.additional-accessibility-information-large-print" = "Large print"; +"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Page breaks included"; +"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Page breaks included from the original print source"; +"readium.a11y.additional-accessibility-information-ruby-annotations" = "Some Ruby annotations"; +"readium.a11y.additional-accessibility-information-sign-language" = "Sign language"; +"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Tactile graphics included"; +"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "Tactile graphics have been integrated to facilitate access to visual elements for blind people"; +"readium.a11y.additional-accessibility-information-tactile-objects" = "Tactile 3D objects"; +"readium.a11y.additional-accessibility-information-text-to-speech-hinting" = "Text-to-speech hinting provided"; +"readium.a11y.additional-accessibility-information-title" = "Additional accessibility information"; +"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" = "Ultra high contrast between text and background"; +"readium.a11y.additional-accessibility-information-visible-page-numbering" = "Visible page numbering"; +"readium.a11y.additional-accessibility-information-without-background-sounds" = "Without background sounds"; "readium.a11y.conformance-a-compact" = "This publication meets minimum accessibility standards"; "readium.a11y.conformance-a-descriptive" = "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level A standard"; "readium.a11y.conformance-aa-compact" = "This publication meets accepted accessibility standards"; "readium.a11y.conformance-aa-descriptive" = "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level AA standard"; "readium.a11y.conformance-aaa-compact" = "This publication exceeds accepted accessibility standards"; "readium.a11y.conformance-aaa-descriptive" = "The publication contains a conformance statement that it meets the EPUB Accessibility and WCAG 2 Level AAA standard"; -"readium.a11y.conformance-certifier-compact" = "The publication was certified by "; -"readium.a11y.conformance-certifier-descriptive" = "The publication was certified by "; -"readium.a11y.conformance-certifier-credentials-compact" = "The certifier's credential is "; -"readium.a11y.conformance-certifier-credentials-descriptive" = "The certifier's credential is "; -"readium.a11y.conformance-details-certification-info-compact" = "The publication was certified on "; -"readium.a11y.conformance-details-certification-info-descriptive" = "The publication was certified on "; -"readium.a11y.conformance-details-certifier-report-compact" = "For more information refer to the certifier's report"; -"readium.a11y.conformance-details-certifier-report-descriptive" = "For more information refer to the certifier's report"; -"readium.a11y.conformance-details-claim-compact" = "This publication claims to meet"; -"readium.a11y.conformance-details-claim-descriptive" = "This publication claims to meet"; -"readium.a11y.conformance-details-epub-accessibility-1-0-compact" = " EPUB Accessibility 1.0"; -"readium.a11y.conformance-details-epub-accessibility-1-0-descriptive" = " EPUB Accessibility 1.0"; -"readium.a11y.conformance-details-epub-accessibility-1-1-compact" = " EPUB Accessibility 1.1"; -"readium.a11y.conformance-details-epub-accessibility-1-1-descriptive" = " EPUB Accessibility 1.1"; -"readium.a11y.conformance-details-level-a-compact" = " Level A"; -"readium.a11y.conformance-details-level-a-descriptive" = " Level A"; -"readium.a11y.conformance-details-level-aa-compact" = " Level AA"; -"readium.a11y.conformance-details-level-aa-descriptive" = " Level AA"; -"readium.a11y.conformance-details-level-aaa-compact" = " Level AAA"; -"readium.a11y.conformance-details-level-aaa-descriptive" = " Level AAA"; -"readium.a11y.conformance-details-wcag-2-0-compact" = " WCAG 2.0"; -"readium.a11y.conformance-details-wcag-2-0-descriptive" = " Web Content Accessibility Guidelines (WCAG) 2.0"; -"readium.a11y.conformance-details-wcag-2-1-compact" = " WCAG 2.1"; -"readium.a11y.conformance-details-wcag-2-1-descriptive" = " Web Content Accessibility Guidelines (WCAG) 2.1"; -"readium.a11y.conformance-details-wcag-2-2-compact" = " WCAG 2.2"; -"readium.a11y.conformance-details-wcag-2-2-descriptive" = " Web Content Accessibility Guidelines (WCAG) 2.2"; -"readium.a11y.conformance-no-compact" = "No information is available"; -"readium.a11y.conformance-no-descriptive" = "No information is available"; -"readium.a11y.conformance-unknown-standard-compact" = "Conformance to accepted standards for accessibility of this publication cannot be determined"; -"readium.a11y.conformance-unknown-standard-descriptive" = "Conformance to accepted standards for accessibility of this publication cannot be determined"; -"readium.a11y.navigation-title" = "Navigation"; -"readium.a11y.navigation-index-compact" = "Index"; -"readium.a11y.navigation-index-descriptive" = "Index with links to referenced entries"; -"readium.a11y.navigation-no-metadata-compact" = "No information is available"; -"readium.a11y.navigation-no-metadata-descriptive" = "No information is available"; -"readium.a11y.navigation-page-navigation-compact" = "Go to page"; -"readium.a11y.navigation-page-navigation-descriptive" = "Page list to go to pages from the print source version"; -"readium.a11y.navigation-structural-compact" = "Headings"; -"readium.a11y.navigation-structural-descriptive" = "Elements such as headings, tables, etc for structured navigation"; -"readium.a11y.navigation-toc-compact" = "Table of contents"; -"readium.a11y.navigation-toc-descriptive" = "Table of contents to all chapters of the text via links"; -"readium.a11y.rich-content-title" = "Rich content"; -"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Chemical formulas in LaTeX"; -"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Chemical formulas in accessible format (LaTeX)"; -"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Chemical formulas in MathML"; -"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Chemical formulas in accessible format (MathML)"; -"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Math as LaTeX"; -"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Math formulas in accessible format (LaTeX)"; -"readium.a11y.rich-content-accessible-math-as-mathml-compact" = "Math as MathML"; -"readium.a11y.rich-content-accessible-math-as-mathml-descriptive" = "Math formulas in accessible format (MathML)"; -"readium.a11y.rich-content-accessible-math-described-compact" = "Text descriptions of math are provided"; -"readium.a11y.rich-content-accessible-math-described-descriptive" = "Text descriptions of math are provided"; -"readium.a11y.rich-content-closed-captions-compact" = "Videos have closed captions"; -"readium.a11y.rich-content-closed-captions-descriptive" = "Videos included in publications have closed captions"; -"readium.a11y.rich-content-extended-compact" = "Information-rich images are described by extended descriptions"; -"readium.a11y.rich-content-extended-descriptive" = "Information-rich images are described by extended descriptions"; -"readium.a11y.rich-content-open-captions-compact" = "Videos have open captions"; -"readium.a11y.rich-content-open-captions-descriptive" = "Videos included in publications have open captions"; -"readium.a11y.rich-content-transcript-compact" = "Transcript(s) provided"; -"readium.a11y.rich-content-transcript-descriptive" = "Transcript(s) provided"; -"readium.a11y.rich-content-unknown-compact" = "No information is available"; -"readium.a11y.rich-content-unknown-descriptive" = "No information is available"; -"readium.a11y.hazards-title" = "Hazards"; +"readium.a11y.conformance-certifier" = "The publication was certified by "; +"readium.a11y.conformance-certifier-credentials" = "The certifier's credential is "; +"readium.a11y.conformance-details-certification-info" = "The publication was certified on "; +"readium.a11y.conformance-details-certifier-report" = "For more information refer to the certifier's report"; +"readium.a11y.conformance-details-claim" = "This publication claims to meet"; +"readium.a11y.conformance-details-epub-accessibility-1-0" = "EPUB Accessibility 1.0"; +"readium.a11y.conformance-details-epub-accessibility-1-1" = "EPUB Accessibility 1.1"; +"readium.a11y.conformance-details-level-a" = "Level A"; +"readium.a11y.conformance-details-level-aa" = "Level AA"; +"readium.a11y.conformance-details-level-aaa" = "Level AAA"; +"readium.a11y.conformance-details-wcag-2-0-compact" = "WCAG 2.0"; +"readium.a11y.conformance-details-wcag-2-0-descriptive" = "Web Content Accessibility Guidelines (WCAG) 2.0"; +"readium.a11y.conformance-details-wcag-2-1-compact" = "WCAG 2.1"; +"readium.a11y.conformance-details-wcag-2-1-descriptive" = "Web Content Accessibility Guidelines (WCAG) 2.1"; +"readium.a11y.conformance-details-wcag-2-2-compact" = "WCAG 2.2"; +"readium.a11y.conformance-details-wcag-2-2-descriptive" = "Web Content Accessibility Guidelines (WCAG) 2.2"; +"readium.a11y.conformance-details-title" = "Detailed conformance information"; +"readium.a11y.conformance-no" = "No information is available"; +"readium.a11y.conformance-title" = "Conformance"; +"readium.a11y.conformance-unknown-standard" = "Conformance to accepted standards for accessibility of this publication cannot be determined"; "readium.a11y.hazards-flashing-compact" = "Flashing content"; "readium.a11y.hazards-flashing-descriptive" = "The publication contains flashing content that can cause photosensitive seizures"; "readium.a11y.hazards-flashing-none-compact" = "No flashing hazards"; @@ -108,8 +63,7 @@ "readium.a11y.hazards-motion-none-descriptive" = "The publication does not contain motion simulations that can cause motion sickness"; "readium.a11y.hazards-motion-unknown-compact" = "Motion simulation hazards not known"; "readium.a11y.hazards-motion-unknown-descriptive" = "The presence of motion simulations that can cause motion sickness could not be determined"; -"readium.a11y.hazards-no-metadata-compact" = "No information is available"; -"readium.a11y.hazards-no-metadata-descriptive" = "No information is available"; +"readium.a11y.hazards-no-metadata" = "No information is available"; "readium.a11y.hazards-none-compact" = "No hazards"; "readium.a11y.hazards-none-descriptive" = "The publication contains no hazards"; "readium.a11y.hazards-sound-compact" = "Sounds"; @@ -118,52 +72,58 @@ "readium.a11y.hazards-sound-none-descriptive" = "The publication does not contain sounds that can cause sensitivity issues"; "readium.a11y.hazards-sound-unknown-compact" = "Sound hazards not known"; "readium.a11y.hazards-sound-unknown-descriptive" = "The presence of sounds that can cause sensitivity issues could not be determined"; -"readium.a11y.hazards-unknown-compact" = "The presence of hazards is unknown"; -"readium.a11y.hazards-unknown-descriptive" = "The presence of hazards is unknown"; -"readium.a11y.accessibility-summary-title" = "Accessibility summary"; -"readium.a11y.accessibility-summary-no-metadata-compact" = "No information is available"; -"readium.a11y.accessibility-summary-no-metadata-descriptive" = "No information is available"; -"readium.a11y.accessibility-summary-publisher-contact-compact" = "For more information about the accessibility of this product, please contact the publisher: "; -"readium.a11y.accessibility-summary-publisher-contact-descriptive" = "For more information about the accessibility of this product, please contact the publisher: "; -"readium.a11y.legal-considerations-title" = "Legal considerations"; +"readium.a11y.hazards-title" = "Hazards"; +"readium.a11y.hazards-unknown" = "The presence of hazards is unknown"; "readium.a11y.legal-considerations-exempt-compact" = "Claims an accessibility exemption in some jurisdictions"; "readium.a11y.legal-considerations-exempt-descriptive" = "This publication claims an accessibility exemption in some jurisdictions"; -"readium.a11y.legal-considerations-no-metadata-compact" = "No information is available"; -"readium.a11y.legal-considerations-no-metadata-descriptive" = "No information is available"; -"readium.a11y.additional-accessibility-information-title" = "Additional accessibility information"; -"readium.a11y.additional-accessibility-information-aria-compact" = "ARIA roles included"; -"readium.a11y.additional-accessibility-information-aria-descriptive" = "Content is enhanced with ARIA roles to optimize organization and facilitate navigation"; -"readium.a11y.additional-accessibility-information-audio-descriptions-compact" = "Audio descriptions"; -"readium.a11y.additional-accessibility-information-audio-descriptions-descriptive" = "Audio descriptions"; -"readium.a11y.additional-accessibility-information-braille-compact" = "Braille"; -"readium.a11y.additional-accessibility-information-braille-descriptive" = "Braille"; -"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information-compact" = "Color is not the sole means of conveying information"; -"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information-descriptive" = "Color is not the sole means of conveying information"; -"readium.a11y.additional-accessibility-information-dyslexia-readability-compact" = "Dyslexia readability"; -"readium.a11y.additional-accessibility-information-dyslexia-readability-descriptive" = "Dyslexia readability"; -"readium.a11y.additional-accessibility-information-full-ruby-annotations-compact" = "Full ruby annotations"; -"readium.a11y.additional-accessibility-information-full-ruby-annotations-descriptive" = "Full ruby annotations"; -"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio-compact" = "High contrast between foreground and background audio"; -"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio-descriptive" = "High contrast between foreground and background audio"; -"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background-compact" = "High contrast between foreground text and background"; -"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background-descriptive" = "High contrast between foreground text and background"; -"readium.a11y.additional-accessibility-information-large-print-compact" = "Large print"; -"readium.a11y.additional-accessibility-information-large-print-descriptive" = "Large print"; -"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Page breaks included"; -"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Page breaks included from the original print source"; -"readium.a11y.additional-accessibility-information-ruby-annotations-compact" = "Some Ruby annotations"; -"readium.a11y.additional-accessibility-information-ruby-annotations-descriptive" = "Some Ruby annotations"; -"readium.a11y.additional-accessibility-information-sign-language-compact" = "Sign language"; -"readium.a11y.additional-accessibility-information-sign-language-descriptive" = "Sign language"; -"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Tactile graphics included"; -"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "Tactile graphics have been integrated to facilitate access to visual elements for blind people"; -"readium.a11y.additional-accessibility-information-tactile-objects-compact" = "Tactile 3D objects"; -"readium.a11y.additional-accessibility-information-tactile-objects-descriptive" = "Tactile 3D objects"; -"readium.a11y.additional-accessibility-information-text-to-speech-hinting-compact" = "Text-to-speech hinting provided"; -"readium.a11y.additional-accessibility-information-text-to-speech-hinting-descriptive" = "Text-to-speech hinting provided"; -"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background-compact" = "Ultra high contrast between text and background"; -"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background-descriptive" = "Ultra high contrast between text and background"; -"readium.a11y.additional-accessibility-information-visible-page-numbering-compact" = "Visible page numbering"; -"readium.a11y.additional-accessibility-information-visible-page-numbering-descriptive" = "Visible page numbering"; -"readium.a11y.additional-accessibility-information-without-background-sounds-compact" = "Without background sounds"; -"readium.a11y.additional-accessibility-information-without-background-sounds-descriptive" = "Without background sounds"; +"readium.a11y.legal-considerations-no-metadata" = "No information is available"; +"readium.a11y.legal-considerations-title" = "Legal considerations"; +"readium.a11y.navigation-index-compact" = "Index"; +"readium.a11y.navigation-index-descriptive" = "Index with links to referenced entries"; +"readium.a11y.navigation-no-metadata" = "No information is available"; +"readium.a11y.navigation-page-navigation-compact" = "Go to page"; +"readium.a11y.navigation-page-navigation-descriptive" = "Page list to go to pages from the print source version"; +"readium.a11y.navigation-structural-compact" = "Headings"; +"readium.a11y.navigation-structural-descriptive" = "Elements such as headings, tables, etc for structured navigation"; +"readium.a11y.navigation-title" = "Navigation"; +"readium.a11y.navigation-toc-compact" = "Table of contents"; +"readium.a11y.navigation-toc-descriptive" = "Table of contents to all chapters of the text via links"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Chemical formulas in LaTeX"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Chemical formulas in accessible format (LaTeX)"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Chemical formulas in MathML"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Chemical formulas in accessible format (MathML)"; +"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Math as LaTeX"; +"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Math formulas in accessible format (LaTeX)"; +"readium.a11y.rich-content-accessible-math-described" = "Text descriptions of math are provided"; +"readium.a11y.rich-content-closed-captions-compact" = "Videos have closed captions"; +"readium.a11y.rich-content-closed-captions-descriptive" = "Videos included in publications have closed captions"; +"readium.a11y.rich-content-extended-descriptions" = "Information-rich images are described by extended descriptions"; +"readium.a11y.rich-content-math-as-mathml-compact" = "Math as MathML"; +"readium.a11y.rich-content-math-as-mathml-descriptive" = "Math formulas in accessible format (MathML)"; +"readium.a11y.rich-content-open-captions-compact" = "Videos have open captions"; +"readium.a11y.rich-content-open-captions-descriptive" = "Videos included in publications have open captions"; +"readium.a11y.rich-content-title" = "Rich content"; +"readium.a11y.rich-content-transcript" = "Transcript(s) provided"; +"readium.a11y.rich-content-unknown" = "No information is available"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Has alternative text"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Has alternative text descriptions for images"; +"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" = "No information about nonvisual reading is available"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Not readable in read aloud or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "The content is not readable as read aloud speech or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Not fully readable in read aloud or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Not all of the content will be readable as read aloud speech or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Readable in read aloud or dynamic braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "All content can be read as read aloud speech or dynamic braille"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Prerecorded audio clips"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Prerecorded audio clips are embedded in the content"; +"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" = "No information about prerecorded audio is available"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Prerecorded audio only"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Audiobook with no text alternative"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Prerecorded audio synchronized with text"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "All the content is available as prerecorded audio synchronized with text"; +"readium.a11y.ways-of-reading-title" = "Ways of reading"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "Appearance can be modified"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-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)"; +"readium.a11y.ways-of-reading-visual-adjustments-unknown" = "No information about appearance modifiability is available"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "Appearance cannot be modified"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-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"; diff --git a/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings new file mode 100644 index 0000000000..0d2105ce0c --- /dev/null +++ b/Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -0,0 +1,129 @@ +// DO NOT EDIT. File generated automatically from the fr JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.a11y.accessibility-summary-no-metadata" = "Aucune information disponible"; +"readium.a11y.accessibility-summary-publisher-contact" = "Pour plus d'information Γ  propos de l'accessibilitΓ© de cette publication, veuillez contacter l'Γ©diteurΒ : "; +"readium.a11y.accessibility-summary-title" = "Informations d'accessibilitΓ© supplΓ©mentaires fournies par l'Γ©diteur"; +"readium.a11y.additional-accessibility-information-aria-compact" = "Information enrichie pour les technologies d'assistances"; +"readium.a11y.additional-accessibility-information-aria-descriptive" = "La structure est enrichi de rΓ΄les ARIA afin d'optimiser l'organisation et de faciliter la navigation via les technologies d'assistances"; +"readium.a11y.additional-accessibility-information-audio-descriptions" = "Description audio"; +"readium.a11y.additional-accessibility-information-braille" = "Braille"; +"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" = "La couleur n'est pas la seule maniΓ¨re de communiquer de l'information"; +"readium.a11y.additional-accessibility-information-dyslexia-readability" = "LisibilitΓ© adaptΓ© aux publics dys"; +"readium.a11y.additional-accessibility-information-full-ruby-annotations" = "Annotations complΓ¨tes au format ruby (langues asiatiques)"; +"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" = "Contraste sonore amΓ©liorΓ© entre les diffΓ©rents plans"; +"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" = "Contraste Γ©levΓ© entre le texte et l'arriΓ¨re-plan"; +"readium.a11y.additional-accessibility-information-large-print" = "Grands caractΓ¨res"; +"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Pagination identique Γ  l'imprimΓ©"; +"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Contient une pagination identique Γ  la version imprimΓ©e"; +"readium.a11y.additional-accessibility-information-ruby-annotations" = "Annotations partielles au format ruby (langues asiatiques)"; +"readium.a11y.additional-accessibility-information-sign-language" = "Langue des signes"; +"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Graphiques tactiles"; +"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "Des graphiques tactiles ont Γ©tΓ© intΓ©grΓ©s pour faciliter l'accΓ¨s des personnes aveugles aux Γ©lΓ©ments visuels"; +"readium.a11y.additional-accessibility-information-tactile-objects" = "Objets 3D ou tactiles"; +"readium.a11y.additional-accessibility-information-text-to-speech-hinting" = "Prononciation amΓ©liorΓ©e pour la synthΓ¨se vocale"; +"readium.a11y.additional-accessibility-information-title" = "Informations complΓ©mentaires sur l'accessibilitΓ©"; +"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" = "Contraste trΓ¨s Γ©levΓ© entre le texte et l'arriΓ¨re-plan"; +"readium.a11y.additional-accessibility-information-visible-page-numbering" = "NumΓ©rotation de page visible"; +"readium.a11y.additional-accessibility-information-without-background-sounds" = "Aucun bruit de fond"; +"readium.a11y.conformance-a-compact" = "Cette publication rΓ©pond aux rΓ¨gles minimales d'accessibilitΓ©"; +"readium.a11y.conformance-a-descriptive" = "La publication indique qu'elle respecte les rΓ¨gles d'accessibilitΓ© EPUB et WCAG 2 niveau A"; +"readium.a11y.conformance-aa-compact" = "Cette publication rΓ©pond aux rΓ¨gles d'accessibilitΓ© reconnues"; +"readium.a11y.conformance-aa-descriptive" = "La publication indique qu'elle respecte les rΓ¨gles d'accessibilitΓ© EPUB et WCAG 2 niveau AA"; +"readium.a11y.conformance-aaa-compact" = "Cette publication dΓ©passe les rΓ¨gles d'accessibilitΓ© reconnues"; +"readium.a11y.conformance-aaa-descriptive" = "La publication indique qu'elle respecte les rΓ¨gles d'accessibilitΓ© EPUB et WCAG 2 niveau AAA"; +"readium.a11y.conformance-certifier" = "AccessibilitΓ© Γ©valuΓ©e par "; +"readium.a11y.conformance-certifier-credentials" = "L'Γ©valuateur est accrΓ©ditΓ© par "; +"readium.a11y.conformance-details-certification-info" = "Cette publication a Γ©tΓ© certifiΓ© le"; +"readium.a11y.conformance-details-certifier-report" = "Pour plus d'information, veuillez consulter le rapport de certification"; +"readium.a11y.conformance-details-claim" = "Cette publication indique respecter"; +"readium.a11y.conformance-details-epub-accessibility-1-0" = "EPUB AccessibilitΓ© 1.0"; +"readium.a11y.conformance-details-epub-accessibility-1-1" = "EPUB AccessibilitΓ© 1.1"; +"readium.a11y.conformance-details-level-a" = "Niveau A"; +"readium.a11y.conformance-details-level-aa" = "Niveau AA"; +"readium.a11y.conformance-details-level-aaa" = "Niveau AAA"; +"readium.a11y.conformance-details-wcag-2-0-compact" = "WCAG 2.0"; +"readium.a11y.conformance-details-wcag-2-0-descriptive" = "RΓ¨gles pour l’accessibilitΓ© des contenus Web (WCAG) 2.0"; +"readium.a11y.conformance-details-wcag-2-1-compact" = "WCAG 2.1"; +"readium.a11y.conformance-details-wcag-2-1-descriptive" = "RΓ¨gles pour l’accessibilitΓ© des contenus Web (WCAG) 2.1"; +"readium.a11y.conformance-details-wcag-2-2-compact" = "WCAG 2.2"; +"readium.a11y.conformance-details-wcag-2-2-descriptive" = "RΓ¨gles pour l’accessibilitΓ© des contenus Web (WCAG) 2.2"; +"readium.a11y.conformance-details-title" = "Information dΓ©taillΓ©e"; +"readium.a11y.conformance-no" = "Aucune information disponible"; +"readium.a11y.conformance-title" = "RΓ¨gles d'accessibilitΓ©"; +"readium.a11y.conformance-unknown-standard" = "Aucune indication concernant les normes d'accessibilitΓ©"; +"readium.a11y.hazards-flashing-compact" = "Flashs lumineux"; +"readium.a11y.hazards-flashing-descriptive" = "La publication contient des flashs lumineux qui peuvent provoquer des crises d’épilepsie"; +"readium.a11y.hazards-flashing-none-compact" = "Pas de flashs lumineux"; +"readium.a11y.hazards-flashing-none-descriptive" = "La publication ne contient pas de flashs lumineux susceptibles de provoquer des crises d’épilepsie"; +"readium.a11y.hazards-flashing-unknown-compact" = "Pas d'information concernant la prΓ©sence de flashs lumineux"; +"readium.a11y.hazards-flashing-unknown-descriptive" = "La prΓ©sence de flashs lumineux susceptibles de provoquer des crises d’épilepsie n'a pas pu Γͺtre dΓ©terminΓ©e"; +"readium.a11y.hazards-motion-compact" = "Sensations de mouvement"; +"readium.a11y.hazards-motion-descriptive" = "La publication contient des images en mouvement qui peuvent provoquer des nausΓ©es, des vertiges et des maux de tΓͺte"; +"readium.a11y.hazards-motion-none-compact" = "Pas de sensations de mouvement"; +"readium.a11y.hazards-motion-none-descriptive" = "La publication ne contient pas d'images en mouvement qui pourraient provoquer des nausΓ©es, des vertiges et des maux de tΓͺte"; +"readium.a11y.hazards-motion-unknown-compact" = "Pas d'information concernant la prΓ©sence d'images en mouvement"; +"readium.a11y.hazards-motion-unknown-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"; +"readium.a11y.hazards-no-metadata" = "Aucune information disponible"; +"readium.a11y.hazards-none-compact" = "Aucun points d'attention"; +"readium.a11y.hazards-none-descriptive" = "La publication ne prΓ©sente aucun risque liΓ© Γ  la prΓ©sence de flashs lumineux, de sensations de mouvement ou de sons"; +"readium.a11y.hazards-sound-compact" = "Sons"; +"readium.a11y.hazards-sound-descriptive" = "La publication contient des sons qui peuvent causer des troubles de la sensibilitΓ©"; +"readium.a11y.hazards-sound-none-compact" = "Pas de risques sonores"; +"readium.a11y.hazards-sound-none-descriptive" = "La publication ne contient pas de sons susceptibles de provoquer des troubles de la sensibilitΓ©"; +"readium.a11y.hazards-sound-unknown-compact" = "Pas d'information concernant la prΓ©sence de sons"; +"readium.a11y.hazards-sound-unknown-descriptive" = "La prΓ©sence de sons susceptibles de causer des troubles de sensibilitΓ© n'a pas pu Γͺtre dΓ©terminΓ©e"; +"readium.a11y.hazards-title" = "Points d'attention"; +"readium.a11y.hazards-unknown" = "La prΓ©sence de risques est inconnue"; +"readium.a11y.legal-considerations-exempt-compact" = "DΓ©clare Γͺtre sous le coup d'une exemption dans certaines juridictions"; +"readium.a11y.legal-considerations-exempt-descriptive" = "Cette publication dΓ©clare Γͺtre sous le coup d'une exemption dans certaines juridictions"; +"readium.a11y.legal-considerations-no-metadata" = "Aucune information disponible"; +"readium.a11y.legal-considerations-title" = "ConsidΓ©rations lΓ©gales"; +"readium.a11y.navigation-index-compact" = "Index"; +"readium.a11y.navigation-index-descriptive" = "Index comportant des liens vers les entrΓ©es rΓ©fΓ©rencΓ©es"; +"readium.a11y.navigation-no-metadata" = "Aucune information disponible"; +"readium.a11y.navigation-page-navigation-compact" = "Aller Γ  la page"; +"readium.a11y.navigation-page-navigation-descriptive" = "Permet d'accΓ©der aux pages de la version source imprimΓ©e"; +"readium.a11y.navigation-structural-compact" = "Titres"; +"readium.a11y.navigation-structural-descriptive" = "Contient des titres pour une navigation structurΓ©e"; +"readium.a11y.navigation-title" = "Points de repΓ¨re"; +"readium.a11y.navigation-toc-compact" = "Table des matiΓ¨res"; +"readium.a11y.navigation-toc-descriptive" = "Table des matiΓ¨res"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Formules chimiques en LaTeX"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Formules chimiques en format accessible (LaTeX)"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Formules chimiques en MathML"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Formules chimiques en format accessible (MathML)"; +"readium.a11y.rich-content-accessible-math-as-latex-compact" = "MathΓ©matiques en LaTeX"; +"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Formules mathΓ©matiques en format accessible (LaTeX)"; +"readium.a11y.rich-content-accessible-math-described" = "Des descriptions textuelles des formules mathΓ©matiques sont fournies"; +"readium.a11y.rich-content-closed-captions-compact" = "Sous-titres disponibles pour les vidΓ©os"; +"readium.a11y.rich-content-closed-captions-descriptive" = "Des sous titres sont disponibles pour les vidΓ©os"; +"readium.a11y.rich-content-extended-descriptions" = "Les images porteuses d'informations complexes sont dΓ©crites par des descriptions longues"; +"readium.a11y.rich-content-math-as-mathml-compact" = "MathΓ©matiques en MathML"; +"readium.a11y.rich-content-math-as-mathml-descriptive" = "Formules mathΓ©matiques en format accessible (MathML)"; +"readium.a11y.rich-content-open-captions-compact" = "Sous-titres incrustΓ©s"; +"readium.a11y.rich-content-open-captions-descriptive" = "Des sous titres sont incrustΓ©s pour les vidΓ©os"; +"readium.a11y.rich-content-title" = "Contenus spΓ©cifiques"; +"readium.a11y.rich-content-transcript" = "Transcriptions fournies"; +"readium.a11y.rich-content-unknown" = "Aucune information disponible"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Images dΓ©crites"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Les images sont dΓ©crites par un texte"; +"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" = "Aucune information pour la lecture en voix de synthΓ¨se ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Non lisible en voix de synthΓ¨se ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "Le contenu n'est pas lisible en voix de synthΓ¨se ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Pas entiΓ¨rement lisible en voix de synthΓ¨se ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Tous les contenus ne pourront pas Γͺtre lus Γ  haute voix ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "EntiΓ¨rement lisible en voix de synthΓ¨se ou en braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "Tous les contenus peuvent Γͺtre lus en voix de synthΓ¨se ou en braille"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Clips audio prΓ©enregistrΓ©s"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Des clips audio prΓ©enregistrΓ©s sont intΓ©grΓ©s au contenu"; +"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" = "Aucune information sur les enregistrements audio"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Audio prΓ©enregistrΓ© uniquement"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Livre audio sans texte alternatif"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Audio prΓ©enregistrΓ© synchronisΓ© avec du texte"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "Tous les contenus sont disponibles comme audio prΓ©enregistrΓ©s synchronisΓ©s avec le texte"; +"readium.a11y.ways-of-reading-title" = "LisibilitΓ©"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "L'affichage peut Γͺtre adaptΓ©"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-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)"; +"readium.a11y.ways-of-reading-visual-adjustments-unknown" = "Aucune information sur les possibilitΓ©s d'adaptation de l'affichage"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "L'affichage ne peut pas Γͺtre adaptΓ©"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-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"; diff --git a/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings b/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings new file mode 100644 index 0000000000..13aaa51e23 --- /dev/null +++ b/Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings @@ -0,0 +1,129 @@ +// DO NOT EDIT. File generated automatically from the it JSON strings of https://github.com/edrlab/thorium-locales/. + +"readium.a11y.accessibility-summary-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.accessibility-summary-publisher-contact" = "Per ulteriori informazioni sull'accessibilitΓ  di questa risorsa, contattare l'editore: "; +"readium.a11y.accessibility-summary-title" = "Informazioni aggiuntive sull'accessibilitΓ  fornite dall'editore"; +"readium.a11y.additional-accessibility-information-aria-compact" = "Ruoli ARIA inclusi"; +"readium.a11y.additional-accessibility-information-aria-descriptive" = "Il contenuto Γ¨ semanticamente arricchito con ruoli ARIA per ottimizzare l'organizzazione e facilitare la navigazione"; +"readium.a11y.additional-accessibility-information-audio-descriptions" = "Descrizioni audio"; +"readium.a11y.additional-accessibility-information-braille" = "Braille"; +"readium.a11y.additional-accessibility-information-color-not-sole-means-of-conveying-information" = "Il colore non Γ¨ l'unico mezzo per trasmettere informazioni"; +"readium.a11y.additional-accessibility-information-dyslexia-readability" = "LeggibilitΓ  adatta alla dislessia"; +"readium.a11y.additional-accessibility-information-full-ruby-annotations" = "Annotazioni complete in Ruby"; +"readium.a11y.additional-accessibility-information-high-contrast-between-foreground-and-background-audio" = "Elevato contrasto tra audio principale e sottofondo"; +"readium.a11y.additional-accessibility-information-high-contrast-between-text-and-background" = "Contrasto elevato tra testo in primo piano e sfondo"; +"readium.a11y.additional-accessibility-information-large-print" = "Stampa a caratteri ingranditi"; +"readium.a11y.additional-accessibility-information-page-breaks-compact" = "Interruzioni di pagina incluse"; +"readium.a11y.additional-accessibility-information-page-breaks-descriptive" = "Interruzioni di pagina identiche alla versione originale a stampa"; +"readium.a11y.additional-accessibility-information-ruby-annotations" = "Alcune annotazioni in Ruby"; +"readium.a11y.additional-accessibility-information-sign-language" = "Lingua dei segni"; +"readium.a11y.additional-accessibility-information-tactile-graphics-compact" = "Grafica tattile inclusa"; +"readium.a11y.additional-accessibility-information-tactile-graphics-descriptive" = "La grafica tattile Γ¨ stata integrata per facilitare l'accesso agli elementi visivi alle persone non vedenti"; +"readium.a11y.additional-accessibility-information-tactile-objects" = "Oggetti 3D tattili"; +"readium.a11y.additional-accessibility-information-text-to-speech-hinting" = "Pronuncia migliorata per la sintesi vocale"; +"readium.a11y.additional-accessibility-information-title" = "Ulteriori informazioni sull'accessibilitΓ "; +"readium.a11y.additional-accessibility-information-ultra-high-contrast-between-text-and-background" = "Contrasto molto elevato tra testo e sfondo"; +"readium.a11y.additional-accessibility-information-visible-page-numbering" = "Numerazione delle pagine visibile"; +"readium.a11y.additional-accessibility-information-without-background-sounds" = "Nessun suono in sottofondo"; +"readium.a11y.conformance-a-compact" = "Questa pubblicazione soddisfa gli standard minimi di accessibilitΓ "; +"readium.a11y.conformance-a-descriptive" = "La pubblicazione contiene una dichiarazione di conformitΓ  che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello A"; +"readium.a11y.conformance-aa-compact" = "Questa pubblicazione soddisfa gli standard di accessibilitΓ  accettati"; +"readium.a11y.conformance-aa-descriptive" = "La pubblicazione contiene una dichiarazione di conformitΓ  che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AA"; +"readium.a11y.conformance-aaa-compact" = "Questa pubblicazione supera gli standard di accessibilitΓ "; +"readium.a11y.conformance-aaa-descriptive" = "La pubblicazione contiene una dichiarazione di conformitΓ  che attesta il rispetto degli standard EPUB Accessibility e WCAG 2 Livello AAA"; +"readium.a11y.conformance-certifier" = "La pubblicazione Γ¨ stata certificata da "; +"readium.a11y.conformance-certifier-credentials" = "Le credenziali del certificatore sono "; +"readium.a11y.conformance-details-certification-info" = "La pubblicazione Γ¨ stata certificata il "; +"readium.a11y.conformance-details-certifier-report" = "Per ulteriori informazioni, consultare il report di accessibilitΓ  del certificatore"; +"readium.a11y.conformance-details-claim" = "Questa pubblicazione Γ¨ conforme ai requisiti di"; +"readium.a11y.conformance-details-epub-accessibility-1-0" = "EPUB Accessibility 1.0"; +"readium.a11y.conformance-details-epub-accessibility-1-1" = "EPUB Accessibility 1.1"; +"readium.a11y.conformance-details-level-a" = "Livello A"; +"readium.a11y.conformance-details-level-aa" = "Livello AA"; +"readium.a11y.conformance-details-level-aaa" = "Livello AAA"; +"readium.a11y.conformance-details-wcag-2-0-compact" = "WCAG 2.0"; +"readium.a11y.conformance-details-wcag-2-0-descriptive" = "Linee guida per l'accessibilitΓ  dei contenuti web (WCAG) 2.0"; +"readium.a11y.conformance-details-wcag-2-1-compact" = "WCAG 2.1"; +"readium.a11y.conformance-details-wcag-2-1-descriptive" = "Linee guida per l'accessibilitΓ  dei contenuti web (WCAG) 2.1"; +"readium.a11y.conformance-details-wcag-2-2-compact" = "WCAG 2.2"; +"readium.a11y.conformance-details-wcag-2-2-descriptive" = "Linee guida per l'accessibilitΓ  dei contenuti web (WCAG) 2.2"; +"readium.a11y.conformance-details-title" = "Informazioni dettagliate sulla conformitΓ "; +"readium.a11y.conformance-no" = "Nessuna informazione disponibile"; +"readium.a11y.conformance-title" = "ConformitΓ "; +"readium.a11y.conformance-unknown-standard" = "Nessuna indicazione sugli standard d'accessibilitΓ "; +"readium.a11y.hazards-flashing-compact" = "Contenuto lampeggiante"; +"readium.a11y.hazards-flashing-descriptive" = "La pubblicazione contiene contenuti lampeggianti che possono causare crisi d'epilessia fotosensibile"; +"readium.a11y.hazards-flashing-none-compact" = "Nessun contenuto lampeggiante"; +"readium.a11y.hazards-flashing-none-descriptive" = "La pubblicazione non presenta contenuti lampeggianti che possono causare crisi d'epilessia fotosensibile"; +"readium.a11y.hazards-flashing-unknown-compact" = "Nessuna informazione sulla presenza di contenuti lampeggianti"; +"readium.a11y.hazards-flashing-unknown-descriptive" = "Non Γ¨ stato possibile determinare la presenza di contenuti lampeggianti che possono causare crisi d'epilessia fotosensibile"; +"readium.a11y.hazards-motion-compact" = "Simulazione del movimento"; +"readium.a11y.hazards-motion-descriptive" = "La pubblicazione contiene simulazioni di movimento che possono provocare cinetosi"; +"readium.a11y.hazards-motion-none-compact" = "Nessun rischio di simulazione del movimento"; +"readium.a11y.hazards-motion-none-descriptive" = "La pubblicazione non contiene simulazioni di movimento che possono causare la malattia di movimento"; +"readium.a11y.hazards-motion-unknown-compact" = "Nessuna informazione relativa alla presenza di simulazioni di movimento"; +"readium.a11y.hazards-motion-unknown-descriptive" = "Non Γ¨ stato possibile determinare la presenza di contenuti che possono provocare cinetosi"; +"readium.a11y.hazards-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.hazards-none-compact" = "Nessuna problematica"; +"readium.a11y.hazards-none-descriptive" = "La pubblicazione non presenta contenuti a rischio di simulazione di movimento, di suoni, o di contenuti lampeggianti"; +"readium.a11y.hazards-sound-compact" = "Suoni"; +"readium.a11y.hazards-sound-descriptive" = "La pubblicazione contiene suoni che possono causare problemi di sensibilitΓ "; +"readium.a11y.hazards-sound-none-compact" = "Nessun rischio acustico"; +"readium.a11y.hazards-sound-none-descriptive" = "La pubblicazione non contiene suoni che possono causare problemi di sensibilitΓ "; +"readium.a11y.hazards-sound-unknown-compact" = "Nessuna informazione sulla presenza di suoni"; +"readium.a11y.hazards-sound-unknown-descriptive" = "Non Γ¨ stato possibile determinare la presenza di suoni che potrebbero causare problemi di sensibilitΓ "; +"readium.a11y.hazards-title" = "Problematiche"; +"readium.a11y.hazards-unknown" = "La presenza di rischi Γ¨ sconosciuta"; +"readium.a11y.legal-considerations-exempt-compact" = "Dichiara di godere dell'esenzione d'accessibilitΓ  in alcune giurisdizioni"; +"readium.a11y.legal-considerations-exempt-descriptive" = "Questa risorsa gode dell'esenzione d'accessibilitΓ  in alcune giurisdizioni"; +"readium.a11y.legal-considerations-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.legal-considerations-title" = "Note legali"; +"readium.a11y.navigation-index-compact" = "Indice analitico interattivo"; +"readium.a11y.navigation-index-descriptive" = "Indice analitico con link alle voci di riferimento"; +"readium.a11y.navigation-no-metadata" = "Nessuna informazione disponibile"; +"readium.a11y.navigation-page-navigation-compact" = "Vai alla pagina"; +"readium.a11y.navigation-page-navigation-descriptive" = "Sono presenti i riferimenti ai numeri di pagina della versione a stampa corrispondente"; +"readium.a11y.navigation-structural-compact" = "Intestazioni"; +"readium.a11y.navigation-structural-descriptive" = "Contiene elementi come titoli, elenchi e tabelle per permettere una navigazione strutturata"; +"readium.a11y.navigation-title" = "Navigazione"; +"readium.a11y.navigation-toc-compact" = "Indice interattivo"; +"readium.a11y.navigation-toc-descriptive" = "L’indice permette l’accesso diretto a tutti i capitoli tramite link"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-compact" = "Formule chimiche in LaTeX"; +"readium.a11y.rich-content-accessible-chemistry-as-latex-descriptive" = "Formule chimiche in formato accessibile (LaTeX)"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-compact" = "Formule chimiche in MathML"; +"readium.a11y.rich-content-accessible-chemistry-as-mathml-descriptive" = "Formule chimiche in formato accessibile (MathML)"; +"readium.a11y.rich-content-accessible-math-as-latex-compact" = "Matematica in LaTeX"; +"readium.a11y.rich-content-accessible-math-as-latex-descriptive" = "Formule matematiche in formato accessibile (LaTeX)"; +"readium.a11y.rich-content-accessible-math-described" = "Sono disponibili descrizioni testuali per le formule matematiche"; +"readium.a11y.rich-content-closed-captions-compact" = "Sottotitoli disponibili per i video"; +"readium.a11y.rich-content-closed-captions-descriptive" = "Per i video sono disponibili dei sottotitoli"; +"readium.a11y.rich-content-extended-descriptions" = "Le immagini complesse presentano descrizioni estese"; +"readium.a11y.rich-content-math-as-mathml-compact" = "Matematica in MathML"; +"readium.a11y.rich-content-math-as-mathml-descriptive" = "Formule matematiche in formato accessibile (MathML)"; +"readium.a11y.rich-content-open-captions-compact" = "I video hanno i sottotitoli"; +"readium.a11y.rich-content-open-captions-descriptive" = "I video inclusi nella pubblicazione hanno i sottotitoli"; +"readium.a11y.rich-content-title" = "Contenuti arricchiti"; +"readium.a11y.rich-content-transcript" = "Trascrizioni fornite"; +"readium.a11y.rich-content-unknown" = "Nessuna informazione disponibile"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-compact" = "Immagini descritte"; +"readium.a11y.ways-of-reading-nonvisual-reading-alt-text-descriptive" = "Le immagini sono descritte da un testo"; +"readium.a11y.ways-of-reading-nonvisual-reading-no-metadata" = "Nessuna informazione sulla lettura non visiva"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-compact" = "Non leggibile con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-none-descriptive" = "Il contenuto non Γ¨ leggibile con la lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-compact" = "Non Γ¨ interamente leggibile con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-not-fully-descriptive" = "Non tutti i contenuti potranno essere letti con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-compact" = "Interamente leggibile con lettura ad alta voce o in braille"; +"readium.a11y.ways-of-reading-nonvisual-reading-readable-descriptive" = "Tutti i contenuti possono essere letti con la lettura ad alta voce o con il display braille"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-compact" = "Clip audio preregistrate"; +"readium.a11y.ways-of-reading-prerecorded-audio-complementary-descriptive" = "Le clip audio preregistrate sono integrate nel contenuto"; +"readium.a11y.ways-of-reading-prerecorded-audio-no-metadata" = "Non sono disponibili informazioni sull'audio preregistrato"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-compact" = "Solo audio preregistrato"; +"readium.a11y.ways-of-reading-prerecorded-audio-only-descriptive" = "Audiolibro senza testi alternativi"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-compact" = "Audio preregistrato sincronizzato con il testo"; +"readium.a11y.ways-of-reading-prerecorded-audio-synchronized-descriptive" = "Tutti i contenuti sono disponibili come audio preregistrato sincronizzato con il testo"; +"readium.a11y.ways-of-reading-title" = "LeggibilitΓ "; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-compact" = "La formattazione del testo e il layout della pagina possono essere modificati"; +"readium.a11y.ways-of-reading-visual-adjustments-modifiable-descriptive" = "La formattazione del testo e il layout della pagina possono essere modificati in base alle funzionalitΓ  presenti nella soluzione di lettura (ingrandimento dei caratteri del testo, modifica dei colori e dei contrasti per il testo e lo sfondo, modifica degli spazi tra lettere, parole, frasi e paragrafi)"; +"readium.a11y.ways-of-reading-visual-adjustments-unknown" = "Non sono disponibili informazioni sulla possibilitΓ  di formattare il testo"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-compact" = "La formattazione del testo e il display della pagina non possono essere modificati"; +"readium.a11y.ways-of-reading-visual-adjustments-unmodifiable-descriptive" = "Il layout di testo e pagina non puΓ² essere modificato poichΓ© l'esperienza di lettura Γ¨ vicina a una versione di stampa, ma i sistemi di lettura possono ancora fornire opzioni di zoom"; diff --git a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift index f47380a4b3..e22b0c463f 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift index 27d23fc22c..3e7e9502f2 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift index b1130519cb..79c57930c1 100644 --- a/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift index 75a75bef0b..73e1bdfa56 100644 --- a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Atomic.swift b/Sources/Shared/Toolkit/Atomic.swift index 100703a181..0a5bc21ba8 100644 --- a/Sources/Shared/Toolkit/Atomic.swift +++ b/Sources/Shared/Toolkit/Atomic.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Cancellable.swift b/Sources/Shared/Toolkit/Cancellable.swift index f7cab4c7c4..a59090fc14 100644 --- a/Sources/Shared/Toolkit/Cancellable.swift +++ b/Sources/Shared/Toolkit/Cancellable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Closeable.swift b/Sources/Shared/Toolkit/Closeable.swift index ef4e084550..0ae54d84c2 100644 --- a/Sources/Shared/Toolkit/Closeable.swift +++ b/Sources/Shared/Toolkit/Closeable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/ControlFlow.swift b/Sources/Shared/Toolkit/ControlFlow.swift index b7e2ee150f..3f951cb9e8 100644 --- a/Sources/Shared/Toolkit/ControlFlow.swift +++ b/Sources/Shared/Toolkit/ControlFlow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Asset/Asset.swift b/Sources/Shared/Toolkit/Data/Asset/Asset.swift index 7ee5a6277e..9091a34e68 100644 --- a/Sources/Shared/Toolkit/Data/Asset/Asset.swift +++ b/Sources/Shared/Toolkit/Data/Asset/Asset.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift index 3ad3055644..76137ec203 100644 --- a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift +++ b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index f62a2b2bcc..e6a18fd906 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -34,7 +34,9 @@ public struct EmptyContainer: Container { public let sourceURL: AbsoluteURL? = nil public let entries: Set = Set() - public subscript(url: any URLConvertible) -> Resource? { nil } + public subscript(url: any URLConvertible) -> Resource? { + nil + } } /// Concatenates several containers. diff --git a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift index 774de4c86e..4b37c828cd 100644 --- a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,8 +11,13 @@ public class SingleResourceContainer: Container { public let entry: AnyURL private let resource: Resource - public var sourceURL: AbsoluteURL? { resource.sourceURL } - public var entries: Set { [entry] } + public var sourceURL: AbsoluteURL? { + resource.sourceURL + } + + public var entries: Set { + [entry] + } public init(resource: Resource, at entry: AnyURL) { self.resource = resource diff --git a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift index 81f336c4c9..44c0e1c211 100644 --- a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -29,7 +29,9 @@ public final class TransformingContainer: Container { } public let sourceURL: AbsoluteURL? = nil - public var entries: Set { container.entries } + public var entries: Set { + container.entries + } public subscript(url: any URLConvertible) -> Resource? { let url = url.anyURL diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index 6248635ac4..da5f3d597a 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -1,13 +1,11 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // import Foundation -public typealias ReadResult = Result - /// Errors occurring while reading a resource. public enum ReadError: Error { /// An error occurred while trying to access the content. @@ -33,6 +31,17 @@ public enum ReadError: Error { public static func decoding(_ message: String, cause: Error? = nil) -> ReadError { .decoding(DebugError(message, cause: cause)) } + + /// Wraps a native error into a `ReadError`, if possible. + /// + /// Returns `nil` if the error cannot be mapped to a known `ReadError`. + public static func wrap(_ error: Error) -> ReadError? { + if let error = AccessError.wrap(error) { + return .access(error) + } else { + return nil + } + } } public enum AccessError: Error { @@ -44,4 +53,17 @@ public enum AccessError: Error { /// For extension purposes. This is not used in the Readium toolkit. case other(Error) + + /// Wraps a native error into an `AccessError`, if possible. + /// + /// Returns `nil` if the error cannot be mapped to a known `AccessError`. + public static func wrap(_ error: Error) -> AccessError? { + if let error = HTTPError.wrap(error) { + return .http(error) + } else if let error = FileSystemError.wrap(error) { + return .fileSystem(error) + } else { + return nil + } + } } diff --git a/Sources/Shared/Toolkit/Data/ReadResult.swift b/Sources/Shared/Toolkit/Data/ReadResult.swift new file mode 100644 index 0000000000..4ed0e3a3e8 --- /dev/null +++ b/Sources/Shared/Toolkit/Data/ReadResult.swift @@ -0,0 +1,149 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +public typealias ReadResult = Result + +public extension ReadResult { + /// Decodes the data as a `T` using the given `decoder`. + /// + /// - Returns: The decoded `T`, or a `ReadError.decoding` error. + func decode(_ decoder: (Data) throws -> T) -> ReadResult { + flatMap { data in + do { + return try .success(decoder(data)) + } catch { + return .failure(.decoding(error)) + } + } + } + + /// Decodes the data as a `String`. + func asString(encoding: String.Encoding = .utf8) -> ReadResult { + decode { try $0.asString(encoding: encoding) } + } + + /// Decodes the data as a JSON value. + func asJSON(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSON(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObject(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: Any]> { + asJSON(options: options) + } + + /// Decodes the data as an XML document. + func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) -> ReadResult { + decode { try $0.asXML(using: factory, namespaces: namespaces) } + } + + /// Decodes the data as a `JSONValue`. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSONValue(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: JSONValue]> { + decode { try $0.asJSONObjectValue(options: options) } + } +} + +public extension ReadResult { + /// Decodes the data as a `T` using the given `decoder`. + /// + /// - Returns: `nil` if the data is absent, the decoded `T` if data is + /// present, or a `ReadError.decoding` error if decoding fails. + func decode(_ decoder: (Data) throws -> T) -> ReadResult { + flatMap { data in + guard let data = data else { + return .success(nil) + } + do { + return try .success(decoder(data)) + } catch { + return .failure(.decoding(error)) + } + } + } + + /// Decodes the data as a `String`. + func asString(encoding: String.Encoding = .utf8) -> ReadResult { + decode { try $0.asString(encoding: encoding) } + } + + /// Decodes the data as a JSON value. + func asJSON(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSON(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObject(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: Any]?> { + asJSON(options: options) + } + + /// Decodes the data as an XML document. + func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) -> ReadResult { + decode { try $0.asXML(using: factory, namespaces: namespaces) } + } + + /// Decodes the data as a `JSONValue`. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + decode { try $0.asJSONValue(options: options) } + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: JSONValue]?> { + decode { try $0.asJSONObjectValue(options: options) } + } +} + +private extension Data { + /// Decodes the data as a `String`. + func asString(encoding: String.Encoding = .utf8) throws -> String { + guard let string = String(data: self, encoding: encoding) else { + throw DebugError("Not a valid \(encoding) string") + } + return string + } + + /// Decodes the data as a JSON value. + func asJSON(options: JSONSerialization.ReadingOptions = []) throws -> T { + guard let json = try JSONSerialization.jsonObject(with: self, options: options) as? T else { + throw JSONError.parsing(T.self) + } + return json + } + + /// Decodes the data as a JSON object. + func asJSONObject(options: JSONSerialization.ReadingOptions = []) throws -> [String: Any] { + try asJSON(options: options) + } + + /// Decodes the data as an XML document. + func asXML(using factory: XMLDocumentFactory, namespaces: [XMLNamespace] = []) throws -> XMLDocument { + try factory.open(data: self, namespaces: namespaces) + } + + /// Decodes the data as a `JSONValue`. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) throws -> JSONValue { + let json = try JSONSerialization.jsonObject(with: self, options: options) + guard let value = JSONValue(json) else { + throw JSONError.parsing(JSONValue.self) + } + return value + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) throws -> [String: JSONValue] { + let value = try asJSONValue(options: options) + guard let dict = value.object else { + throw JSONError.parsing([String: JSONValue].self) + } + return dict + } +} diff --git a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift index f2a4c07da0..0fb74c5daf 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index 4833dcf1c6..fa8fe234f7 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -26,8 +26,10 @@ public actor BufferingResource: Resource, Loggable { /// `Resource`, with the range it covers. private var buffer: Buffer - /// - Parameter bufferSize: Size of the buffer chunks to read. - public init(resource: Resource, bufferSize: Int = 8192) { + /// - Parameters: + /// - resource: The underlying `Resource` to be read. + /// - bufferSize: Size of the buffer chunks to read, in bytes. + public init(resource: Resource, bufferSize: Int = 256 * 1024) { precondition(bufferSize > 0) self.resource = resource buffer = Buffer(maxSize: bufferSize) @@ -38,7 +40,9 @@ public actor BufferingResource: Resource, Loggable { self.init(resource: resource, bufferSize: Int(bufferSize)) } - public nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } + public nonisolated var sourceURL: AbsoluteURL? { + resource.sourceURL + } public func properties() async -> ReadResult { await resource.properties() @@ -47,83 +51,70 @@ public actor BufferingResource: Resource, Loggable { private var cachedLength: ReadResult? public func estimatedLength() async -> ReadResult { - if cachedLength == nil { - cachedLength = await resource.estimatedLength() + if let cachedLength { + return cachedLength + } + let result = await resource.estimatedLength() + if case .success = result { + cachedLength = result } - return cachedLength! + return result } public func stream( range: Range?, consume: @escaping (Data) -> Void ) async -> ReadResult { - guard - // Reading the whole resource bypasses buffering to keep things simple. - var requestedRange = range, - let optionalLength = await estimatedLength().getOrNil(), - let length = optionalLength - else { + // Reading the whole resource bypasses buffering to keep things simple. + guard let requestedRange = range, !requestedRange.isEmpty else { return await resource.stream(range: range, consume: consume) } - requestedRange = requestedRange.clamped(to: 0 ..< length) - guard !requestedRange.isEmpty else { - consume(Data()) - return .success(()) - } + // Serve from the buffer if the request is fully covered. if let data = buffer.get(range: requestedRange) { - log(.trace, "Used buffer for \(requestedRange) (\(requestedRange.count) bytes)") consume(data) return .success(()) } - // Calculate the adjusted range to cover at least buffer.maxSize bytes. - // Adjust the start if near the end of the resource. - var adjustedStart = requestedRange.lowerBound - var adjustedEnd = requestedRange.upperBound - let missingBytesToMatchBufferSize = buffer.maxSize - requestedRange.count - if missingBytesToMatchBufferSize > 0 { - adjustedEnd = min(adjustedEnd + UInt64(missingBytesToMatchBufferSize), length) - } - if adjustedEnd - adjustedStart < buffer.maxSize { - adjustedStart = UInt64(max(0, Int(adjustedEnd) - buffer.maxSize)) - } - let adjustedRange = adjustedStart ..< adjustedEnd - log(.trace, "Requested \(requestedRange) (\(requestedRange.count) bytes), adjusted to \(adjustedRange) (\(adjustedRange.count) bytes) of resource with length \(length)") + // Read ahead from the request start to fill the buffer. + let readAheadEnd = requestedRange.lowerBound + UInt64(buffer.maxSize) + let readRange = requestedRange.lowerBound ..< max(requestedRange.upperBound, readAheadEnd) - var data = Data() - - // Range that will need to be read from the original resource. - var readRange = adjustedRange + // Range that will actually need to be read from the original resource, + // after reusing any overlap with the current buffer. + var fetchRange = readRange + var prefixData = Data() // Checks if the beginning of the range to read is already buffered. // This is an optimization particularly useful with LCP, where we need // to go backward for every read to get the previous block of data. if - readRange.lowerBound < buffer.range.upperBound, - readRange.upperBound > buffer.range.upperBound, - let dataPrefix = buffer.get(range: readRange.lowerBound ..< buffer.range.upperBound) + fetchRange.lowerBound < buffer.range.upperBound, + fetchRange.upperBound > buffer.range.upperBound, + let dataPrefix = buffer.get(range: fetchRange.lowerBound ..< buffer.range.upperBound) { - log(.trace, "Found \(dataPrefix.count) bytes to reuse at the end of the buffer") - data.append(dataPrefix) - readRange = buffer.range.upperBound ..< readRange.upperBound + prefixData = Data(dataPrefix) + fetchRange = buffer.range.upperBound ..< fetchRange.upperBound } - log(.trace, "Will read \(readRange) (\(readRange.count) bytes)") + // Read from the original resource using stream to avoid materializing + // more than needed. + var data = prefixData + let result = await resource.stream(range: fetchRange) { chunk in + data.append(chunk) + } - // Fallback on reading the requested range from the original resource. - return await resource.read(range: readRange) - .flatMap { readData in - data.append(readData) - buffer.set(data, at: adjustedRange.lowerBound) + guard case .success = result else { + return result + } - guard let data = data[requestedRange, offsetBy: adjustedRange.lowerBound] else { - return .failure(.decoding("Cannot extract the requested range from the read range")) - } + buffer.set(data, at: readRange.lowerBound) - consume(data) - return .success(()) - } + let end = min(Int(requestedRange.count), data.count) + if end > 0 { + consume(data[0 ..< end]) + } + return .success(()) } private struct Buffer { @@ -162,7 +153,13 @@ public actor BufferingResource: Resource, Loggable { public extension Resource { /// Wraps this resource in a `BufferingResource` to improve reading /// performances. - func buffered(size: Int = 8192) -> BufferingResource { + func buffered() -> BufferingResource { + BufferingResource(resource: self) + } + + /// Wraps this resource in a `BufferingResource` to improve reading + /// performances. + func buffered(size: Int) -> BufferingResource { BufferingResource(resource: self, bufferSize: size) } diff --git a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift index c7338990ec..f310bb078b 100644 --- a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -28,7 +28,9 @@ public actor CachingResource: Resource { return data! } - public nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } + public nonisolated var sourceURL: AbsoluteURL? { + resource.sourceURL + } public func properties() async -> ReadResult { await resource.properties() diff --git a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift index 5f8d1bf61f..d86afe802a 100644 --- a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift index 5056757dc1..7cc0f39341 100644 --- a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Resource/Resource.swift b/Sources/Shared/Toolkit/Data/Resource/Resource.swift index 1dc21d5625..fc6648130f 100644 --- a/Sources/Shared/Toolkit/Data/Resource/Resource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/Resource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index 46bf4a4e40..bf91db37cf 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -46,11 +46,12 @@ class _HTMLResourceContentExtractor: _ResourceContentExtractor { private let xmlFactory = DefaultXMLDocumentFactory() func extractText(of resource: Resource) async -> ReadResult { - await resource.readAsString() + await resource.read() + .asString() .asyncFlatMap { content in do { // First try to parse a valid XML document, then fallback on SwiftSoup, which is slower. - var text = await parse(xml: content) + var text = parse(xml: content) ?? parse(html: content) ?? "" @@ -65,21 +66,21 @@ class _HTMLResourceContentExtractor: _ResourceContentExtractor { } } - // Parse the HTML resource as a strict XML document. - // - // This is much more efficient than using SwiftSoup, but will fail when encountering - // invalid HTML documents. - private func parse(xml: String) async -> String? { - guard let document = try? await xmlFactory.open(string: xml, namespaces: [.xhtml]) else { + /// Parse the HTML resource as a strict XML document. + /// + /// This is much more efficient than using SwiftSoup, but will fail when encountering + /// invalid HTML documents. + private func parse(xml: String) -> String? { + guard let document = try? xmlFactory.open(string: xml, namespaces: [.xhtml]) else { return nil } return document.first("/xhtml:html/xhtml:body")?.textContent } - // Parse the HTML resource with SwiftSoup. - // - // This may be slow but will recover from broken HTML documents. + /// Parse the HTML resource with SwiftSoup. + /// + /// This may be slow but will recover from broken HTML documents. private func parse(html: String) -> String? { try? SwiftSoup.parse(html).body()?.text() } diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift index b1721f4e01..7745466786 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift index 0602e88c61..ff7134c809 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -55,7 +55,7 @@ public extension ResourceProperties { } set { if let mediaType = newValue { - properties[mediaTypeKey] = mediaType + properties[mediaTypeKey] = mediaType.string } else { properties.removeValue(forKey: mediaTypeKey) } diff --git a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift index 0f95f2c068..fb64c17420 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,7 +21,9 @@ actor TailCachingResource: Resource, Loggable { self.cacheFromOffset = cacheFromOffset } - nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } + nonisolated var sourceURL: AbsoluteURL? { + resource.sourceURL + } func properties() async -> ReadResult { await resource.properties() @@ -61,7 +63,7 @@ actor TailCachingResource: Resource, Loggable { } } - private var cache: ReadResult? = nil + private var cache: ReadResult? private func cachedTail() async -> ReadResult { if let cache = cache { diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index 672be9f497..71e9b9a4b5 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -29,8 +29,8 @@ open class TransformingResource: Resource { await _transform!(data) } - // As the resource is transformed, we can't use the original source URL - // as reference. + /// As the resource is transformed, we can't use the original source URL + /// as reference. public let sourceURL: AbsoluteURL? = nil open func estimatedLength() async -> ReadResult { @@ -70,11 +70,11 @@ public extension Resource { TransformingResource(self, transform: { await $0.asyncMap(transform) }) } - func mapAsString(encoding: String.Encoding = .utf8, transform: @escaping (String) -> String) -> Resource { + func mapAsString(encoding: String.Encoding = .utf8, transform: @escaping (String) async -> String) -> Resource { TransformingResource(self) { - $0.map { data in + await $0.asyncMap { data in let string = String(data: data, encoding: encoding) ?? "" - return transform(string).data(using: .utf8) ?? Data() + return await transform(string).data(using: .utf8) ?? Data() } } } diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 06d8538aa0..d21decb2ff 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -56,6 +56,7 @@ public extension Streamable { } /// Reads the whole content as a `String`. + @available(*, deprecated, message: "Use `read().asString()` instead") func readAsString(encoding: String.Encoding = .utf8) async -> ReadResult { await read().flatMap { guard let string = String(data: $0, encoding: encoding) else { @@ -66,6 +67,7 @@ public extension Streamable { } /// Reads the whole content as a JSON value. + @available(*, deprecated, message: "Use `read().asJSON()` instead") func readAsJSON(options: JSONSerialization.ReadingOptions = []) async -> ReadResult { await read().flatMap { do { @@ -80,6 +82,7 @@ public extension Streamable { } /// Reads the whole content as a JSON object. + @available(*, deprecated, message: "Use `read().asJSONObject()` instead") func readAsJSONObject(options: JSONSerialization.ReadingOptions = []) async -> ReadResult<[String: Any]> { await readAsJSON() } diff --git a/Sources/Shared/Toolkit/DebugError.swift b/Sources/Shared/Toolkit/DebugError.swift index 53b44609db..0268e1dfc3 100644 --- a/Sources/Shared/Toolkit/DebugError.swift +++ b/Sources/Shared/Toolkit/DebugError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index 3714970546..bf98d7de71 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -51,7 +51,7 @@ public struct DocumentTypes { .flatMap(\.utis) .removingDuplicates() - let utis = supportedUTIs.map(UTI.init(stringLiteral:)) + let utis = supportedUTIs.compactMap { UTI($0) } let utisMediaTypes = utis .flatMap { $0.tags(withClass: .mediaType) } @@ -91,18 +91,18 @@ public struct DocumentTypes { /// Metadata about a Document Type declared in `CFBundleDocumentTypes`. public struct DocumentType: Equatable, Loggable { - // Abstract name for the document type, used to refer to the type. + /// Abstract name for the document type, used to refer to the type. public let name: String - // Uniform Type Identifiers supported by this document type. + /// Uniform Type Identifiers supported by this document type. public let utis: [String] - // The preferred media type used to identify this document type. + /// The preferred media type used to identify this document type. public let preferredMediaType: MediaType? - // Media (MIME) types recognized by this document type. + /// Media (MIME) types recognized by this document type. public let mediaTypes: [MediaType] - // File extensions recognized by this document type. + /// File extensions recognized by this document type. public let fileExtensions: [String] init( @@ -127,7 +127,7 @@ public struct DocumentType: Equatable, Loggable { self.name = name self.utis = (dictionary["LSItemContentTypes"] as? [String] ?? []) - let utis = utis.map(UTI.init(stringLiteral:)) + let utis = utis.compactMap { UTI($0) } let fileExtensions = utis.flatMap { $0.tags(withClass: .fileExtension) } + diff --git a/Sources/Shared/Toolkit/Either.swift b/Sources/Shared/Toolkit/Either.swift index c5eb6e701b..122339ca17 100644 --- a/Sources/Shared/Toolkit/Either.swift +++ b/Sources/Shared/Toolkit/Either.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Extensions/Bundle.swift b/Sources/Shared/Toolkit/Extensions/Bundle.swift index fa13d46f5e..6ff7bb8abe 100644 --- a/Sources/Shared/Toolkit/Extensions/Bundle.swift +++ b/Sources/Shared/Toolkit/Extensions/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Extensions/Optional.swift b/Sources/Shared/Toolkit/Extensions/Optional.swift index a36492c741..20d12083e0 100644 --- a/Sources/Shared/Toolkit/Extensions/Optional.swift +++ b/Sources/Shared/Toolkit/Extensions/Optional.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Extensions/Range.swift b/Sources/Shared/Toolkit/Extensions/Range.swift index a5d9bbe245..df5f1ebaff 100644 --- a/Sources/Shared/Toolkit/Extensions/Range.swift +++ b/Sources/Shared/Toolkit/Extensions/Range.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Extensions/String.swift b/Sources/Shared/Toolkit/Extensions/String.swift index 0a957a4732..17e707625d 100644 --- a/Sources/Shared/Toolkit/Extensions/String.swift +++ b/Sources/Shared/Toolkit/Extensions/String.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Extensions/StringEncoding.swift b/Sources/Shared/Toolkit/Extensions/StringEncoding.swift index 540650555d..1600599a70 100644 --- a/Sources/Shared/Toolkit/Extensions/StringEncoding.swift +++ b/Sources/Shared/Toolkit/Extensions/StringEncoding.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Extensions/UIImage.swift b/Sources/Shared/Toolkit/Extensions/UIImage.swift index 317a4d2e7a..7dfe604d5b 100644 --- a/Sources/Shared/Toolkit/Extensions/UIImage.swift +++ b/Sources/Shared/Toolkit/Extensions/UIImage.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/File/DirectoryContainer.swift b/Sources/Shared/Toolkit/File/DirectoryContainer.swift index 700ff93580..478661bbfe 100644 --- a/Sources/Shared/Toolkit/File/DirectoryContainer.swift +++ b/Sources/Shared/Toolkit/File/DirectoryContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,7 +11,10 @@ public struct DirectoryContainer: Container, Loggable { public struct NotADirectoryError: Error {} private let directoryURL: FileURL - public var sourceURL: AbsoluteURL? { directoryURL } + public var sourceURL: AbsoluteURL? { + directoryURL + } + public let entries: Set /// Creates a ``DirectoryContainer`` at `directory` serving only the given @@ -42,7 +45,7 @@ public struct DirectoryContainer: Container, Loggable { includingPropertiesForKeys: [.isRegularFileKey], options: options ) { - for case let url as URL in enumerator { + while let url = enumerator.nextObject() as? URL { do { let fileAttributes = try url.resourceValues(forKeys: [.isRegularFileKey]) if fileAttributes.isRegularFile == true, let entry = directory.relativize(url.anyURL) { diff --git a/Sources/Shared/Toolkit/File/FileContainer.swift b/Sources/Shared/Toolkit/File/FileContainer.swift index 748a5b32dd..20990411d7 100644 --- a/Sources/Shared/Toolkit/File/FileContainer.swift +++ b/Sources/Shared/Toolkit/File/FileContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index d5d93d9819..df88578017 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,7 +14,9 @@ public actor FileResource: Resource, Loggable { fileURL = file } - public nonisolated var sourceURL: AbsoluteURL? { fileURL } + public nonisolated var sourceURL: AbsoluteURL? { + fileURL + } private var _length: ReadResult? @@ -28,7 +30,7 @@ public actor FileResource: Resource, Loggable { _length = .failure(.access(.fileSystem(.fileNotFound(nil)))) } } catch { - _length = .failure(.access(.fileSystem(.io(error)))) + _length = .failure(.access(.fileSystem(.wrap(error) ?? .io(error)))) } } return _length! diff --git a/Sources/Shared/Toolkit/File/FileResourceFactory.swift b/Sources/Shared/Toolkit/File/FileResourceFactory.swift index 87db6623b6..b71b282ba7 100644 --- a/Sources/Shared/Toolkit/File/FileResourceFactory.swift +++ b/Sources/Shared/Toolkit/File/FileResourceFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/File/FileSystemError.swift b/Sources/Shared/Toolkit/File/FileSystemError.swift index ed5b0ef265..4e70f9f262 100644 --- a/Sources/Shared/Toolkit/File/FileSystemError.swift +++ b/Sources/Shared/Toolkit/File/FileSystemError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,6 +14,76 @@ public enum FileSystemError: Error { /// You are not allowed to access this file. case forbidden(Error?) + /// The file storage is out of space. + case outOfSpace(Error?) + /// An unexpected IO error occurred on the file system. case io(Error?) + + /// Wraps a native error into a `FileSystemError`, if possible. + /// + /// Returns `nil` if the error is not related to the file system. + public static func wrap(_ error: Error) -> FileSystemError? { + if let error = error as? CocoaError { + return switch error.code { + case .fileNoSuchFile, .fileReadNoSuchFile: + .fileNotFound(error) + + case .fileReadNoPermission, .fileWriteNoPermission: + .forbidden(error) + + case .fileWriteOutOfSpace: + .outOfSpace(error) + + case + .fileLocking, + .fileReadCorruptFile, + .fileReadInvalidFileName, + .fileReadTooLarge, + .fileReadUnknown, + .fileReadUnsupportedScheme, + .fileWriteFileExists, + .fileWriteInapplicableStringEncoding, + .fileWriteInvalidFileName, + .fileWriteUnknown, + .fileWriteUnsupportedScheme, + .fileWriteVolumeReadOnly: + .io(error) + + default: + nil + } + } else if let error = error as? POSIXError { + return switch error.code { + case .ENOENT: + .fileNotFound(error) + case .EPERM, .EACCES, .EAUTH: + .forbidden(error) + case .ENOSPC, .EDQUOT: + .outOfSpace(error) + case + .EIO, + .ENXIO, + .EBADF, + .EBUSY, + .EEXIST, + .ENOTDIR, + .EISDIR, + .ENFILE, + .EMFILE, + .EFBIG, + .EROFS, + .EMLINK, + .ENAMETOOLONG, + .ELOOP, + .ENOTEMPTY, + .ESTALE, + .ENOLCK: + .io(error) + default: + nil + } + } + return nil + } } diff --git a/Sources/Shared/Toolkit/FileExtension.swift b/Sources/Shared/Toolkit/FileExtension.swift index 839c92bdad..86380c0c3c 100644 --- a/Sources/Shared/Toolkit/FileExtension.swift +++ b/Sources/Shared/Toolkit/FileExtension.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Format.swift b/Sources/Shared/Toolkit/Format/Format.swift index 896846d176..4f395e7035 100644 --- a/Sources/Shared/Toolkit/Format/Format.swift +++ b/Sources/Shared/Toolkit/Format/Format.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -164,6 +164,7 @@ public struct FormatSpecification: RawRepresentable, Hashable { public static let bmp = FormatSpecification(rawValue: "bmp") public static let gif = FormatSpecification(rawValue: "gif") public static let jpeg = FormatSpecification(rawValue: "jpeg") + public static let jxl = FormatSpecification(rawValue: "jxl") public static let png = FormatSpecification(rawValue: "png") public static let tiff = FormatSpecification(rawValue: "tiff") public static let webp = FormatSpecification(rawValue: "webp") diff --git a/Sources/Shared/Toolkit/Format/FormatSniffer.swift b/Sources/Shared/Toolkit/Format/FormatSniffer.swift index 5ed1a8fb58..a87674ab5b 100644 --- a/Sources/Shared/Toolkit/Format/FormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/FormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift index e7c7659fd9..bf8a474049 100644 --- a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift +++ b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -74,7 +74,7 @@ public actor FormatSnifferBlob { if xml == nil { xml = await read().asyncMap { await $0.asyncFlatMap { - try? await xmlDocumentFactory.open(data: $0, namespaces: []) + try? xmlDocumentFactory.open(data: $0, namespaces: []) } } } diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index 1f5291c6df..5baf9f4ffe 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -188,7 +188,7 @@ public struct MediaType: Hashable, Loggable, Sendable { /// Returns whether this media type is of a bitmap image, so excluding vectorial formats. public var isBitmap: Bool { - matchesAny(.bmp, .gif, .jpeg, .png, .tiff, .webp) + matchesAny(.avif, .bmp, .gif, .jpeg, .jxl, .png, .tiff, .webp) } /// Returns whether this media type is of an audio clip. @@ -228,6 +228,7 @@ public struct MediaType: Hashable, Loggable, Sendable { public static let javascript = MediaType("text/javascript")! public static let jpeg = MediaType("image/jpeg")! public static let json = MediaType("application/json")! + public static let jxl = MediaType("image/jxl")! public static let lcpLicenseDocument = MediaType("application/vnd.readium.lcp.license.v1.0+json")! public static let lcpProtectedAudiobook = MediaType("application/audiobook+lcp")! public static let lcpProtectedPDF = MediaType("application/pdf+lcp")! @@ -252,6 +253,7 @@ public struct MediaType: Hashable, Loggable, Sendable { public static let rar = MediaType("application/vnd.rar")! public static let readiumAudiobook = MediaType("application/audiobook+zip")! public static let readiumAudiobookManifest = MediaType("application/audiobook+json")! + public static let readiumGuidedNavigationDocument = MediaType("application/guided-navigation+json")! public static let readiumWebPub = MediaType("application/webpub+zip")! public static let readiumWebPubManifest = MediaType("application/webpub+json")! public static let smil = MediaType("application/smil+xml")! @@ -292,7 +294,9 @@ public struct MediaType: Hashable, Loggable, Sendable { } extension MediaType: RawRepresentable { - public var rawValue: String { string } + public var rawValue: String { + string + } public init?(rawValue: String) { self.init(rawValue) diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift index f6e076539d..50792893f2 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift index dd2a2abe23..dfd5bdac27 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -70,7 +70,7 @@ public struct ZABFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { let entries = container.entries .filter { $0.lastPathSegment?.hasPrefix(".") == false && diff --git a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift index 6e25c9a01f..165e44a59f 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -23,6 +23,9 @@ public class BitmapFormatSniffer: FormatSniffer { if hints.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || hints.hasMediaType("image/jpeg") { return Format(specifications: .jpeg, mediaType: .jpeg, fileExtension: "jpg") } + if hints.hasFileExtension("jxl") || hints.hasMediaType("image/jxl") { + return Format(specifications: .jxl, mediaType: .jxl, fileExtension: "jxl") + } if hints.hasFileExtension("png") || hints.hasMediaType("image/png") { return Format(specifications: .png, mediaType: .png, fileExtension: "png") } diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift index 63942e94ef..0cbe03383d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -25,6 +25,7 @@ public struct ComicFormatSniffer: FormatSniffer { "jfif", "jpg", "jpeg", + "jxl", "png", "tif", "tiff", @@ -68,7 +69,7 @@ public struct ComicFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { let entries = container.entries .filter { $0.lastPathSegment?.hasPrefix(".") == false && diff --git a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift index bdbbeaecca..1fdfdcd79b 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift index 801b60aa66..2dcc9212fb 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,8 +9,10 @@ import Foundation /// Default implementation of ``FormatSniffer`` guessing as well as possible all /// formats known by Readium. public final class DefaultFormatSniffer: CompositeFormatSniffer { - /// - Parameter additionalSniffers: Additional sniffers to be used to guess - /// content format. + /// - Parameters: + /// - xmlDocumentFactory: Used to parse XML content when sniffing formats that require + /// XML inspection. Defaults to `DefaultXMLDocumentFactory()`. + /// - additionalSniffers: Additional sniffers to be used to guess content format. public init( xmlDocumentFactory: XMLDocumentFactory = DefaultXMLDocumentFactory(), additionalSniffers: [FormatSniffer] = [] diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 7f286d021d..5aa775bace 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -31,12 +31,13 @@ public struct EPUBFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { guard let resource = container[AnyURL(path: "mimetype")!] else { return .success(nil) } - return await resource.readAsString() + return await resource.read() + .asString() .asyncFlatMap { mimetype in if MediaType.epub.matches(MediaType(mimetype.trimmingCharacters(in: .whitespacesAndNewlines))) { var format = format @@ -66,7 +67,7 @@ public struct EPUBFormatSniffer: FormatSniffer { } return await resource.read() - .asyncMap { try? await xmlDocumentFactory.open(data: $0, namespaces: []) } + .asyncMap { try? xmlDocumentFactory.open(data: $0, namespaces: []) } .map { document in guard let document = document else { return format diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index db622f859b..8c324b2358 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -29,7 +29,11 @@ public struct HTMLFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { - await blob.readAsXML() + guard !format.hasSpecification || format.conformsTo(.xml) else { + return .success(nil) + } + + return await blob.readAsXML() .asyncMap { document in if let format = sniffDocument(document) { return format diff --git a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift index 7505e7b0f5..1cce9c9f20 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -28,7 +28,11 @@ public struct JSONFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { - await blob.readAsJSON() + guard !format.hasSpecification else { + return .success(nil) + } + + return await blob.readAsJSON() .map { guard $0 != nil else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift index dd4173524b..3247d0fcd8 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift index b401d23ab0..84f952dd09 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift index e697a6b7f4..618cc5f5ea 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift index 7c35608119..cde825e181 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -24,8 +24,12 @@ public struct PDFFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + guard !format.hasSpecification else { + return .success(nil) + } + // https://en.wikipedia.org/wiki/List_of_file_signatures - await blob.read(range: 0 ..< 5) + return await blob.read(range: 0 ..< 5) .map { data in guard String(data: data, encoding: .utf8) == "%PDF-" else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift index a42498be02..11bbf15d27 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -22,8 +22,12 @@ public struct RARFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + guard !format.hasSpecification else { + return .success(nil) + } + // https://en.wikipedia.org/wiki/List_of_file_signatures - await blob.read(range: 0 ..< 8) + return await blob.read(range: 0 ..< 8) .map { data in guard data.count > 8, diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift index 6cb93addb1..64b280a1b6 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -26,12 +26,13 @@ public struct RPFFormatSniffer: FormatSniffer { return nil } - public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult where C: Container { + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { guard let resource = container[AnyURL(path: "manifest.json")!] else { return .success(nil) } - return await resource.readAsJSONObject() + return await resource.read() + .asJSONObject() .map { guard let manifest = try? Manifest(json: $0) else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift index dfb1db8e69..af5ed82b28 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift index 6717a551de..b2f8b00e99 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -22,7 +22,11 @@ public struct XMLFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { - await blob.readAsXML() + guard !format.hasSpecification else { + return .success(nil) + } + + return await blob.readAsXML() .map { guard $0 != nil else { return nil diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift index 708cf1c368..6b0f13ca97 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -22,8 +22,12 @@ public struct ZIPFormatSniffer: FormatSniffer { } public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + guard !format.hasSpecification else { + return .success(nil) + } + // https://en.wikipedia.org/wiki/List_of_file_signatures - await blob.read(range: 0 ..< 4) + return await blob.read(range: 0 ..< 4) .map { data in guard data == Data([0x50, 0x4B, 0x03, 0x04]) || diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index 08b2591661..462070a657 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -122,6 +122,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { /// - additionalHeaders: A dictionary of additional headers to send with requests. For example, `User-Agent`. /// - requestTimeout: The timeout interval to use when waiting for additional data. /// - resourceTimeout: The maximum amount of time that a resource request should be allowed to take. + /// - delegate: An optional delegate to handle common HTTP events. /// - configure: Callback used to configure further the `URLSessionConfiguration` object. public convenience init( userAgent: String? = nil, @@ -151,7 +152,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { self.init(configuration: config, userAgent: userAgent, delegate: delegate) } - public weak var delegate: DefaultHTTPClientDelegate? = nil + public weak var delegate: DefaultHTTPClientDelegate? private let tasks: HTTPTaskManager private let session: URLSession @@ -160,7 +161,9 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { /// Creates a `DefaultHTTPClient` with a custom configuration. /// /// - Parameters: + /// - configuration: The `URLSessionConfiguration` to use for all requests. /// - userAgent: Default user agent issued with requests. + /// - delegate: An optional delegate to handle common HTTP events. public init( configuration: URLSessionConfiguration, userAgent: String? = nil, @@ -290,7 +293,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { // MARK: - URLSessionDataDelegate - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { guard let task = findTask(for: dataTask) else { completionHandler(.cancel) return @@ -298,11 +301,11 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { task.urlSession(session, didReceive: response, completionHandler: completionHandler) } - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { findTask(for: dataTask)?.urlSession(session, didReceive: data) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { findTask(for: task)?.urlSession(session, didCompleteWithError: error) } @@ -486,7 +489,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { if case .failure = state { // No-op, we don't want to overwrite the failure state in this case. } else if let continuation = state.continuation { - state = .failure(continuation: continuation, error: HTTPError(error: error)) + state = .failure(continuation: continuation, error: .wrap(error) ?? .other(error)) } else { state = .finished } @@ -512,35 +515,6 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } } -private extension HTTPError { - /// Maps a native `URLError` to `HTTPError`. - init(error: Error) { - switch error { - case let error as URLError: - switch error.code { - case .httpTooManyRedirects, .redirectToNonExistentLocation: - self = .redirection(error) - case .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, .appTransportSecurityRequiresSecureConnection, .userAuthenticationRequired: - self = .security(error) - case .badServerResponse, .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum: - self = .malformedResponse(error) - case .notConnectedToInternet, .networkConnectionLost: - self = .offline(error) - case .cannotConnectToHost, .cannotFindHost: - self = .unreachable(error) - case .timedOut: - self = .timeout(error) - case .cancelled, .userCancelledAuthentication: - self = .cancelled - default: - self = .other(error) - } - default: - self = .other(error) - } - } -} - private extension HTTPRequest { var urlRequest: URLRequest { var request = URLRequest(url: url.url) diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index 6cbf662901..b43b27a3c3 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift b/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift index b2b2505f37..6146be9911 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPError.swift b/Sources/Shared/Toolkit/HTTP/HTTPError.swift index 983ae59483..dbbe8cd843 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPError.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -60,4 +60,31 @@ public enum HTTPError: Error, Loggable { return try HTTPProblemDetails(data: body) } + + /// Wraps a native error into an `HTTPError`, if possible. + /// + /// Returns `nil` if the error is not related to HTTP. + public static func wrap(_ error: Error) -> HTTPError? { + guard let error = error as? URLError else { + return nil + } + return switch error.code { + case .httpTooManyRedirects, .redirectToNonExistentLocation: + .redirection(error) + case .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, .appTransportSecurityRequiresSecureConnection, .userAuthenticationRequired: + .security(error) + case .badServerResponse, .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum: + .malformedResponse(error) + case .notConnectedToInternet, .networkConnectionLost: + .offline(error) + case .cannotConnectToHost, .cannotFindHost: + .unreachable(error) + case .timedOut: + .timeout(error) + case .cancelled, .userCancelledAuthentication: + .cancelled + default: + .other(error) + } + } } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift b/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift index 0658dbe1f8..2aa39d849c 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift index 3e04961e76..f6ca759d1e 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 57e66a0f3e..1422579289 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -17,7 +17,9 @@ public actor HTTPResource: Resource { self.client = client } - public nonisolated var sourceURL: AbsoluteURL? { url } + public nonisolated var sourceURL: AbsoluteURL? { + url + } public func properties() async -> ReadResult { await headResponse() diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift b/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift index fa5f938683..526dc7e85d 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index 50ec137b2c..24098445da 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/JSON.swift b/Sources/Shared/Toolkit/JSON.swift index d2cc38b955..9f2bbeb019 100644 --- a/Sources/Shared/Toolkit/JSON.swift +++ b/Sources/Shared/Toolkit/JSON.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/JSONValue.swift b/Sources/Shared/Toolkit/JSONValue.swift new file mode 100644 index 0000000000..d311cc3f4d --- /dev/null +++ b/Sources/Shared/Toolkit/JSONValue.swift @@ -0,0 +1,271 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import CoreFoundation +import Foundation + +/// A type-safe JSON value. +/// +/// This enum is used to represent JSON values in a type-safe way, avoiding the +/// use of `any Sendable` or `Any`. It guarantees that the value is Sendable and +/// Hashable. +public enum JSONValue: Sendable, Hashable, Loggable { + case null + case bool(Bool) + case string(String) + case integer(Int) + case double(Double) + case array([JSONValue]) + case object([String: JSONValue]) + + /// Initializes a `JSONValue` from an `Any` value. + /// + /// This initializer attempts to convert the given value to a `JSONValue`. + /// It handles nested arrays and dictionaries recursively. + public init?(_ value: Any?) { + guard let value = value else { + return nil + } + + if let value = value as? JSONValue { + self = value + return + } + + // Fast path for typed collections + if let object = value as? [String: JSONValue] { + self = .object(object) + return + } + if let array = value as? [JSONValue] { + self = .array(array) + return + } + + // Check for specific types + if let string = value as? String { + self = .string(string) + return + } + + // On platforms with CoreFoundation (Apple), NSNumber bridges from Bool, Int, Double. + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + self = .bool(number.boolValue) + return + } + if CFNumberIsFloatType(number) { + self = .double(number.doubleValue) + return + } + if number.compare(0) == .orderedAscending { + self = .integer(Int(clamping: number.int64Value)) + } else { + self = .integer(Int(clamping: number.uint64Value)) + } + return + } + + if let array = value as? [Any] { + self = .array(array.compactMap { + let element = JSONValue($0) + if element == nil { + Self.log(.warning, "JSONValue: unsupported element type \(type(of: $0))") + } + return element + }) + } else if let dict = value as? [String: Any] { + var object: [String: JSONValue] = [:] + for (key, val) in dict { + guard let jsonVal = JSONValue(val) else { + Self.log(.warning, "JSONValue: unsupported element type \(type(of: val))") + continue + } + object[key] = jsonVal + } + self = .object(object) + } else if value is NSNull { + self = .null + } else { + return nil + } + } + + /// Returns the raw value as `Any`. + /// + /// This property is useful for interoperability with APIs that expect + /// standard Swift types (e.g., `JSONSerialization`). + public var any: Any { + switch self { + case .null: + return NSNull() + case let .bool(value): + return value + case let .string(value): + return value + case let .integer(value): + return value + case let .double(value): + return value + case let .array(value): + return value.map(\.any) + case let .object(value): + return value.mapValues(\.any) + } + } + + public var bool: Bool? { + if case let .bool(v) = self { return v } + return nil + } + + public var string: String? { + if case let .string(v) = self { return v } + return nil + } + + public var integer: Int? { + if case let .integer(v) = self { return v } + return nil + } + + public var double: Double? { + if case let .double(v) = self { return v } + if case let .integer(v) = self { return Double(v) } + return nil + } + + public var array: [JSONValue]? { + if case let .array(v) = self { return v } + return nil + } + + public var object: [String: JSONValue]? { + if case let .object(v) = self { return v } + return nil + } +} + +// MARK: - ExpressibleByLiteral Conformance + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .integer(value) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} + +// MARK: - Codable Conformance + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Always try to decode Nil first + if container.decodeNil() { + self = .null + return + } + + // Attempt to decode boolean + if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + return + } + + // Attempt to decode Int + if let intValue = try? container.decode(Int.self) { + self = .integer(intValue) + return + } + + // Attempt to decode floating point numbers + if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + return + } + + // Attempt to decode string + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + + // Attempt to decode array + if let arrayValue = try? container.decode([JSONValue].self) { + self = .array(arrayValue) + return + } + + // Attempt to decode object + if let objectValue = try? container.decode([String: JSONValue].self) { + self = .object(objectValue) + return + } + + // If all attempts fail, throw an error + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Data cannot be decoded as a valid JSONValue." + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .integer(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } +} diff --git a/Sources/Shared/Toolkit/Language.swift b/Sources/Shared/Toolkit/Language.swift index b2ee31c807..402873befe 100644 --- a/Sources/Shared/Toolkit/Language.swift +++ b/Sources/Shared/Toolkit/Language.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -53,7 +53,9 @@ public struct Language: Hashable, Sendable { locale.regionCode.flatMap { Region(code: $0) } } - public var locale: Locale { Locale(identifier: code.bcp47) } + public var locale: Locale { + Locale(identifier: code.bcp47) + } public func localizedDescription(in locale: Locale = Locale.current) -> String { locale.localizedString(forIdentifier: code.bcp47) diff --git a/Sources/Shared/Toolkit/Logging/WarningLogger.swift b/Sources/Shared/Toolkit/Logging/WarningLogger.swift index b76a530b29..73e5837612 100644 --- a/Sources/Shared/Toolkit/Logging/WarningLogger.swift +++ b/Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -49,7 +49,9 @@ public struct JSONWarning: Warning { /// Source JSON object. public let source: Any? public let severity: WarningSeverityLevel - public var tag: String { "json" } + public var tag: String { + "json" + } public var message: String { "JSON \(modelType): \(reason)" diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index 8e3ae77ece..cd1f33bfd6 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -19,7 +19,9 @@ public protocol AudioSessionUser: AnyObject { } public extension AudioSessionUser { - var audioConfiguration: AudioSession.Configuration { .init() } + var audioConfiguration: AudioSession.Configuration { + .init() + } } /// Manages an activated `AVAudioSession`. diff --git a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift index acf9c67b48..5c2904f7e9 100644 --- a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift +++ b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Observable.swift b/Sources/Shared/Toolkit/Observable.swift index 910cc7d392..e2a7e2d631 100644 --- a/Sources/Shared/Toolkit/Observable.swift +++ b/Sources/Shared/Toolkit/Observable.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,6 +8,7 @@ import Foundation /// Holds an observable value. /// You can either get the value directly with `value`, or subscribe to its updates with `observe`. +@available(*, unavailable, message: "This is not used anymore in the toolkit") public class Observable { fileprivate var _value: Value { didSet { @@ -46,6 +47,7 @@ public class Observable { } } +@available(*, unavailable, message: "This is not used anymore in the toolkit") public class MutableObservable: Observable { override public var value: Value { get { diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index f484d101f2..8b4d0c3c31 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/PDF/PDFDocument.swift b/Sources/Shared/Toolkit/PDF/PDFDocument.swift index e35327bc58..06f67f1243 100644 --- a/Sources/Shared/Toolkit/PDF/PDFDocument.swift +++ b/Sources/Shared/Toolkit/PDF/PDFDocument.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/PDF/PDFKit.swift b/Sources/Shared/Toolkit/PDF/PDFKit.swift index aa17486bc4..156f4fe567 100644 --- a/Sources/Shared/Toolkit/PDF/PDFKit.swift +++ b/Sources/Shared/Toolkit/PDF/PDFKit.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,23 +14,41 @@ import PDFKit /// /// Use `PDFKitPDFDocumentFactory` to create a `PDFDocument` from a `Resource`. extension PDFKit.PDFDocument: PDFDocument { - public func pageCount() async throws -> Int { pageCount } + public func pageCount() async throws -> Int { + pageCount + } - public func identifier() async throws -> String? { try await documentRef?.identifier() } + public func identifier() async throws -> String? { + try await documentRef?.identifier() + } - public func cover() async throws -> UIImage? { try await documentRef?.cover() } + public func cover() async throws -> UIImage? { + try await documentRef?.cover() + } - public func readingProgression() async throws -> ReadingProgression? { try await documentRef?.readingProgression() } + public func readingProgression() async throws -> ReadingProgression? { + try await documentRef?.readingProgression() + } - public func title() async throws -> String? { try await documentRef?.title() } + public func title() async throws -> String? { + try await documentRef?.title() + } - public func author() async throws -> String? { try await documentRef?.author() } + public func author() async throws -> String? { + try await documentRef?.author() + } - public func subject() async throws -> String? { try await documentRef?.subject() } + public func subject() async throws -> String? { + try await documentRef?.subject() + } - public func keywords() async throws -> [String] { try await documentRef?.keywords() ?? [] } + public func keywords() async throws -> [String] { + try await documentRef?.keywords() ?? [] + } - public func tableOfContents() async throws -> [PDFOutlineNode] { try await documentRef?.tableOfContents() ?? [] } + public func tableOfContents() async throws -> [PDFOutlineNode] { + try await documentRef?.tableOfContents() ?? [] + } } /// Creates a `PDFDocument` using PDFKit. diff --git a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift index a4a653ade9..72fa1f658c 100644 --- a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift +++ b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/ReadiumLocalizedString.swift b/Sources/Shared/Toolkit/ReadiumLocalizedString.swift index ab0e323f82..8b11b587fd 100644 --- a/Sources/Shared/Toolkit/ReadiumLocalizedString.swift +++ b/Sources/Shared/Toolkit/ReadiumLocalizedString.swift @@ -1,31 +1,62 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // import Foundation -/// Returns the localized string in the main bundle, or fallback on the given bundle if not found. -/// Can be used to override framework localized strings in the host app. -public func ReadiumLocalizedString(_ key: String, in bundle: Bundle, _ values: [CVarArg]) -> String { - let defaultValue = bundle.localizedString(forKey: key, value: nil, table: nil) - var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: nil) - if !values.isEmpty { - string = String(format: string, locale: Locale.current, arguments: values) - } - return string +/// Returns the localized string for `key`. +public func ReadiumLocalizedString( + _ key: String, + in bundle: Bundle, + table: String? = nil, + _ values: CVarArg... +) -> String { + ReadiumLocalizedString(key, in: bundle, table: table, values) } -public func ReadiumLocalizedString(_ key: String, in bundleID: String, _ values: [CVarArg]) -> String { - let defaultValue = Bundle(identifier: bundleID)?.localizedString(forKey: key, value: nil, table: nil) - var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: nil) +/// Returns the localized string for `key` with the following lookup order: +/// +/// 1. The host app's main bundle (allows the app to override any Readium +/// string). +/// 2. The given module `bundle` in the user's preferred language. +/// 3. The English localization inside the module `bundle` as a last resort. +public func ReadiumLocalizedString( + _ key: String, + in bundle: Bundle, + table: String? = nil, + _ values: [CVarArg] +) -> String { + let defaultValue = localizedString(forKey: key, in: bundle, table: table) + var string = Bundle.main.localizedString(forKey: key, value: defaultValue, table: table) if !values.isEmpty { - string = String(format: string, locale: Locale.current, arguments: values) + let locale = bundle.preferredLocalizations.first.map(Locale.init(identifier:)) ?? .current + string = String(format: string, locale: locale, arguments: values) } return string } -public func ReadiumLocalizedString(_ key: String, in bundleID: String, _ values: CVarArg...) -> String { - ReadiumLocalizedString(key, in: bundleID, values) +/// Looks up `key` in `bundle` for the user's preferred language, falling +/// back to the English localization when no translation is found. +private func localizedString(forKey key: String, in bundle: Bundle, table: String?) -> String { + let value = bundle.localizedString(forKey: key, value: nil, table: table) + + // `localizedString` returns the key itself when no translation exists. + if value != key { + return value + } + + // Fall back to the English localization bundled with the module. + if + let enPath = bundle.path(forResource: "en", ofType: "lproj"), + let enBundle = Bundle(path: enPath) + { + let enValue = enBundle.localizedString(forKey: key, value: nil, table: table) + if enValue != key { + return enValue + } + } + + return key } diff --git a/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift b/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift index 208018dc9b..efd7cc9645 100644 --- a/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift +++ b/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift b/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift index bc2de1e181..c739402c39 100644 --- a/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift +++ b/Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift index c70043ed71..363e166ce8 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -57,7 +57,7 @@ public extension AbsoluteURL { /// self: http://example.com/foo /// other: http://example.com/foo/bar/baz /// returns bar/baz - func relativize(_ other: T) -> RelativeURL? { + func relativize(_ other: URLConvertible) -> RelativeURL? { guard let absoluteURL = other.anyURL.absoluteURL, scheme == absoluteURL.scheme, @@ -82,7 +82,9 @@ public extension AbsoluteURL { /// Implements ``URLConvertible``. public extension AbsoluteURL { - var anyURL: AnyURL { .absolute(self) } + var anyURL: AnyURL { + .absolute(self) + } } /// A URL scheme, e.g. http or file. @@ -100,5 +102,7 @@ public struct URLScheme: RawRepresentable, CustomStringConvertible, Hashable, Se self.rawValue = rawValue.lowercased() } - public var description: String { rawValue } + public var description: String { + rawValue + } } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index 596a234694..eb3f01b8d1 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift index 85452efb02..48aba28980 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift index 910709c222..5ca29a03e0 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -31,7 +31,7 @@ struct UnknownAbsoluteURL: AbsoluteURL, Hashable { /// To ignore this warning, compare `UnknownAbsoluteURL.string` instead of /// `UnknownAbsoluteURL` itself. @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") - public static func == (lhs: UnknownAbsoluteURL, rhs: UnknownAbsoluteURL) -> Bool { + static func == (lhs: UnknownAbsoluteURL, rhs: UnknownAbsoluteURL) -> Bool { lhs.string == rhs.string } } diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 8c46d63784..3f5ea72c48 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -17,7 +17,7 @@ public enum AnyURL: URLProtocol { /// A relative URL. case relative(RelativeURL) - /// Creates an ``AnyURL`` from a Foundation ``URL``. + /// Creates an ``AnyURL`` from a Foundation `URL`. public init(url: URL) { if let url = RelativeURL(url: url) { self = .relative(url) @@ -84,7 +84,9 @@ public enum AnyURL: URLProtocol { } /// Returns a foundation URL for this ``AnyURL``. - public var url: URL { wrapped.url } + public var url: URL { + wrapped.url + } /// Resolves the `other` URL to this URL, if possible. /// @@ -119,7 +121,9 @@ public enum AnyURL: URLProtocol { /// Implements `URLConvertible`. extension AnyURL: URLConvertible { - public var anyURL: AnyURL { self } + public var anyURL: AnyURL { + self + } } /// Implements `Hashable` and `Equatable`. diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index 05e7e364b8..e2fcc40248 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,7 +10,7 @@ import Foundation public struct RelativeURL: URLProtocol, Hashable { public let url: URL - /// Creates a ``RelativeURL`` from a standard Swift ``URL``. + /// Creates a ``RelativeURL`` from a standard Swift `URL`. public init?(url: URL) { guard url.scheme == nil else { return nil @@ -124,7 +124,9 @@ public struct RelativeURL: URLProtocol, Hashable { /// Implements `URLConvertible`. extension RelativeURL: URLConvertible { - public var anyURL: AnyURL { .relative(self) } + public var anyURL: AnyURL { + .relative(self) + } } public extension RelativeURL { diff --git a/Sources/Shared/Toolkit/URL/URITemplate.swift b/Sources/Shared/Toolkit/URL/URITemplate.swift index 8d17a2a3bf..625f65150b 100644 --- a/Sources/Shared/Toolkit/URL/URITemplate.swift +++ b/Sources/Shared/Toolkit/URL/URITemplate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -69,5 +69,7 @@ public struct URITemplate: CustomStringConvertible { // MARK: CustomStringConvertible - public var description: String { uri } + public var description: String { + uri + } } diff --git a/Sources/Shared/Toolkit/URL/URLConvertible.swift b/Sources/Shared/Toolkit/URL/URLConvertible.swift index ef2f405ed9..b3e4f4ae1a 100644 --- a/Sources/Shared/Toolkit/URL/URLConvertible.swift +++ b/Sources/Shared/Toolkit/URL/URLConvertible.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/URL/URLExtensions.swift b/Sources/Shared/Toolkit/URL/URLExtensions.swift index 418737dd2a..9252f7ab45 100644 --- a/Sources/Shared/Toolkit/URL/URLExtensions.swift +++ b/Sources/Shared/Toolkit/URL/URLExtensions.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index 32c62d5f0b..a7ca67afe8 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,10 +9,10 @@ import ReadiumInternal /// A type that can represent a URL. public protocol URLProtocol: URLConvertible, Sendable, CustomStringConvertible { - /// Creates a new instance of this type from a Foundation ``URL``. + /// Creates a new instance of this type from a Foundation `URL`. init?(url: URL) - /// Returns a foundation ``URL`` for this URL representation. + /// Returns a foundation `URL` for this URL representation. var url: URL { get } } @@ -25,7 +25,9 @@ public extension URLProtocol { } /// Returns the string representation for this URL. - var string: String { url.absoluteString } + var string: String { + url.absoluteString + } /// Normalizes the URL using a subset of the RFC-3986 rules. /// https://datatracker.ietf.org/doc/html/rfc3986#section-6 @@ -104,7 +106,9 @@ public extension URLProtocol { /// Returns the decoded query parameters present in this URL, in the order /// they appear. - var query: URLQuery? { URLQuery(url: url) } + var query: URLQuery? { + URLQuery(url: url) + } /// Creates a copy of this URL after removing its query portion. func removingQuery() -> Self { @@ -140,7 +144,9 @@ public extension URLProtocol { /// Implements `CustomStringConvertible` public extension URLProtocol { - var description: String { string } + var description: String { + string + } } private extension String { diff --git a/Sources/Shared/Toolkit/URL/URLQuery.swift b/Sources/Shared/Toolkit/URL/URLQuery.swift index 5d15b0777f..ef02bd5514 100644 --- a/Sources/Shared/Toolkit/URL/URLQuery.swift +++ b/Sources/Shared/Toolkit/URL/URLQuery.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/UncheckedSendable.swift b/Sources/Shared/Toolkit/UncheckedSendable.swift new file mode 100644 index 0000000000..34534a8bc6 --- /dev/null +++ b/Sources/Shared/Toolkit/UncheckedSendable.swift @@ -0,0 +1,18 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// A wrapper to force a value to be `Sendable`. +/// +/// **Warning**: Use this wrapper only if you are sure that the value is thread-safe. +package struct UncheckedSendable: @unchecked Sendable { + package let value: T + + package init(_ value: T) { + self.value = value + } +} diff --git a/Sources/Shared/Toolkit/Weak.swift b/Sources/Shared/Toolkit/Weak.swift index 58bbe95179..87d5970b9a 100644 --- a/Sources/Shared/Toolkit/Weak.swift +++ b/Sources/Shared/Toolkit/Weak.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -12,7 +12,7 @@ import Foundation /// Conveniently, the reference can be reset by setting the `ref` property. @dynamicCallable public class Weak { - // Weakly held reference. + /// Weakly held reference. public weak var ref: T? public init(_ ref: T? = nil) { diff --git a/Sources/Shared/Toolkit/XML/Fuzi.swift b/Sources/Shared/Toolkit/XML/Fuzi.swift index 056087c14b..7276cf180c 100644 --- a/Sources/Shared/Toolkit/XML/Fuzi.swift +++ b/Sources/Shared/Toolkit/XML/Fuzi.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/XML/XML.swift b/Sources/Shared/Toolkit/XML/XML.swift index be6c100b58..25dd4fe184 100644 --- a/Sources/Shared/Toolkit/XML/XML.swift +++ b/Sources/Shared/Toolkit/XML/XML.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -73,33 +73,44 @@ public protocol XMLElement: XMLNode { public protocol XMLDocumentFactory { /// Opens an XML document from a local file path. /// - /// - Parameter namespaces: List of namespace prefixes to declare in the document. + /// - Parameters: + /// - file: The local file URL of the XML document. + /// - namespaces: List of namespace prefixes to declare in the document. + /// - Throws: An error if the file cannot be read or the XML is malformed. func open(file: FileURL, namespaces: [XMLNamespace]) async throws -> XMLDocument /// Opens an XML document from its raw data content. /// - /// - Parameter namespaces: List of namespace prefixes to declare in the document. - func open(data: Data, namespaces: [XMLNamespace]) async throws -> XMLDocument + /// - Parameters: + /// - data: The raw data containing the XML content. + /// - namespaces: List of namespace prefixes to declare in the document. + /// - Throws: An error if the XML parsing fails. + func open(data: Data, namespaces: [XMLNamespace]) throws -> XMLDocument /// Opens an XML document from its raw string content. /// - /// - Parameter namespaces: List of namespace prefixes to declare in the document. - func open(string: String, namespaces: [XMLNamespace]) async throws -> XMLDocument + /// - Parameters: + /// - string: The string containing the XML content. + /// - namespaces: List of namespace prefixes to declare in the document. + /// - Throws: An error if the XML parsing fails. + func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument } public class DefaultXMLDocumentFactory: XMLDocumentFactory, Loggable { public init() {} public func open(file: FileURL, namespaces: [XMLNamespace]) async throws -> XMLDocument { - warnIfMainThread() - return try await open(string: String(contentsOf: file.url), namespaces: namespaces) + let string = try await Task.detached(priority: Task.currentPriority) { + try String(contentsOf: file.url) + }.value + return try open(string: string, namespaces: namespaces) } - public func open(string: String, namespaces: [XMLNamespace]) async throws -> XMLDocument { + public func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument { try FuziXMLDocument(string: string, namespaces: namespaces) } - public func open(data: Data, namespaces: [XMLNamespace]) async throws -> XMLDocument { + public func open(data: Data, namespaces: [XMLNamespace]) throws -> XMLDocument { try FuziXMLDocument(data: data, namespaces: namespaces) } } diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift index 809fdd6a37..4312b1008a 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index ffc8659fde..6a344e4085 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -42,15 +42,18 @@ final class MinizipContainer: Container, Loggable { return .success(Self(file: file, entries: entries)) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } private let file: FileURL private let entriesMetadata: [RelativeURL: MinizipEntryMetadata] - public var sourceURL: AbsoluteURL? { file } - public let entries: Set + var sourceURL: AbsoluteURL? { + file + } + + let entries: Set private init(file: FileURL, entries: [RelativeURL: MinizipEntryMetadata]) { self.file = file @@ -98,7 +101,7 @@ private actor MinizipResource: Resource, Loggable { } } - public let sourceURL: AbsoluteURL? = nil + let sourceURL: AbsoluteURL? = nil func estimatedLength() async -> ReadResult { .success(metadata.length) @@ -123,7 +126,7 @@ private actor MinizipResource: Resource, Loggable { try consume(zipFile.readFromCurrentOffset(length: UInt64(range.count))) return .success(()) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } } @@ -149,7 +152,7 @@ private final class MinizipFile { case readFailed } - // Holds an entry's metadata. + /// Holds an entry's metadata. enum Entry { case file(String, length: UInt64, compressedLength: UInt64?) case directory(String) @@ -160,7 +163,9 @@ private final class MinizipFile { /// Information about the currently opened entry. private(set) var openedEntry: (path: String, offset: UInt64)? /// Length of the buffer used when reading an entry's data. - private var bufferLength: Int { 1024 * 32 } + private var bufferLength: Int { + 1024 * 32 + } init?(url: URL) { guard let file = unzOpen64(url.path) else { diff --git a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift index 9b66cd4ec4..1b2476637f 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift index 86cd0cbfac..09dddd1a3d 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift index fff6932024..1c09e67e55 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index 8fe7ec0ffc..dcbb449938 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -35,15 +35,18 @@ final class ZIPFoundationContainer: Container, Loggable { return .success(Self(archiveFactory: archiveFactory, entries: entries)) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } private let archiveFactory: ZIPFoundationArchiveFactory private let entriesByPath: [RelativeURL: Entry] - public var sourceURL: AbsoluteURL? { archiveFactory.sourceURL } - public let entries: Set + var sourceURL: AbsoluteURL? { + archiveFactory.sourceURL + } + + let entries: Set private init( archiveFactory: ZIPFoundationArchiveFactory, @@ -85,7 +88,7 @@ private actor ZIPFoundationResource: Resource, Loggable { self.entry = entry } - public let sourceURL: AbsoluteURL? = nil + let sourceURL: AbsoluteURL? = nil func estimatedLength() async -> ReadResult { .success(entry.uncompressedSize) @@ -117,7 +120,7 @@ private actor ZIPFoundationResource: Resource, Loggable { } return .success(()) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } } @@ -128,7 +131,7 @@ private actor ZIPFoundationResource: Resource, Loggable { do { _archive = try await .success(archiveFactory.make()) } catch { - _archive = .failure(.decoding(error)) + _archive = .failure(.wrap(error) ?? .decoding(error)) } } return _archive! diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index bc6b201240..821415a608 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -76,7 +76,7 @@ public final class AudioParser: PublicationParser { await makeBuilder( container: asset.container, readingOrder: readingOrder, - title: asset.container.guessTitle(ignoring: ignores) + title: nil ) } } diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index f59830e3ea..24c25be8b1 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -52,16 +52,32 @@ public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifest metadata.published = avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }) metadata.languages = avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates() metadata.subjects = avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) } - metadata.authors = avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.artists = avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + // Authors are often stored as "artist": + // - https://www.audiobookshelf.org/docs/#book-audio-metadata + // - https://github.com/denizsafak/abogen#about-metadata-tags + metadata.authors = avMetadata.filter( + [ + .commonIdentifierAuthor, + .iTunesMetadataAuthor, + .commonIdentifierArtist, + .id3MetadataOriginalArtist, + .iTunesMetadataArtist, + .iTunesMetadataOriginalArtist, + ] + ).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.illustrators = avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.contributors = avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.publishers = avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + // Narrators are often stored as "composer": + // - https://www.audiobookshelf.org/docs/#book-audio-metadata + // - https://github.com/denizsafak/abogen#about-metadata-tags + metadata.narrators = avMetadata.filter([.id3MetadataComposer, .iTunesMetadataComposer]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } metadata.description = avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue metadata.duration = avAssets.reduce(0) { duration, avAsset in guard let duration = duration, let avAsset = avAsset else { return nil } return duration + avAsset.duration.seconds } + manifest.metadata = metadata let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) }) return .init(manifest: manifest, cover: cover) diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index 7f0b9eb102..5fea158fe0 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/CompositePublicationParser.swift b/Sources/Streamer/Parser/CompositePublicationParser.swift index c1005c9d42..49f53633aa 100644 --- a/Sources/Streamer/Parser/CompositePublicationParser.swift +++ b/Sources/Streamer/Parser/CompositePublicationParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/DefaultPublicationParser.swift b/Sources/Streamer/Parser/DefaultPublicationParser.swift index ac22c6e8f4..1466d9cac8 100644 --- a/Sources/Streamer/Parser/DefaultPublicationParser.swift +++ b/Sources/Streamer/Parser/DefaultPublicationParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift b/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift index ee64f36d8b..3f79004753 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBContainerParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift b/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift index 97f6a0b52c..18a9817509 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBEncryptionParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift index b0291e0293..19a5ac62b2 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,11 +21,10 @@ final class EPUBManifestParser { // Extracts metadata and links from the OPF. let opfPackage = try await OPFParser(container: container, opfHREF: opfHREF, encryptions: encryptions).parsePublication() - let metadata = opfPackage.metadata let links = opfPackage.readingOrder + opfPackage.resources var manifest = await Manifest( - metadata: metadata, + metadata: opfPackage.metadata, readingOrder: opfPackage.readingOrder, resources: opfPackage.resources, subcollections: parseCollections(in: container, package: opfPackage, links: links) @@ -153,50 +152,4 @@ final class EPUBManifestParser { return collections } - - /// Parse the mediaOverlays informations contained in the ressources then - /// parse the associted SMIL files to populate the MediaOverlays objects - /// in each of the ReadingOrder's Links. - private func parseMediaOverlay(from container: Container, to publication: inout Publication) throws { - // FIXME: For now we don't fill the media-overlays anymore, since it was only half implemented and the API will change -// let mediaOverlays = publication.resources.filter(byType: .smil) -// -// guard !mediaOverlays.isEmpty else { -// log(.info, "No media-overlays found in the Publication.") -// return -// } -// for mediaOverlayLink in mediaOverlays { -// let node = MediaOverlayNode() -// -// guard let smilData = try? fetcher.data(at: mediaOverlayLink.href), -// let smilXml = try? XMLDocument(data: smilData) else -// { -// throw OPFParserError.invalidSmilResource -// } -// -// smilXml.definePrefix("smil", forNamespace: "http://www.w3.org/ns/SMIL") -// smilXml.definePrefix("epub", forNamespace: "http://www.idpf.org/2007/ops") -// guard let body = smilXml.firstChild(xpath: "./smil:body") else { -// continue -// } -// -// node.role.append("section") -// if let textRef = body.attr("textref") { // Prevent the crash on the japanese book -// node.text = HREF(textRef, relativeTo: mediaOverlayLink.href).string -// } -// // get body parameters a -// let href = mediaOverlayLink.href -// SMILParser.parseParameters(in: body, withParent: node, base: href) -// SMILParser.parseSequences(in: body, withParent: node, publicationReadingOrder: &publication.readingOrder, base: href) - // "/??/xhtml/mo-002.xhtml#mo-1" => "/??/xhtml/mo-002.xhtml" - -// guard let baseHref = node.text?.components(separatedBy: "#")[0], -// let link = publication.readingOrder.first(where: { baseHref.contains($0.href) }) else -// { -// continue -// } -// link.mediaOverlays.append(node) -// link.properties.mediaOverlay = EPUBConstant.mediaOverlayURL + link.href -// } - } } diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index b387990f64..ef322ed3e5 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -39,6 +39,9 @@ final class EPUBMetadataParser: Loggable { contributorsByRole[role] ?? [] } + var other = metas.otherMetadata + if let mo = mediaOverlay() { other["mediaOverlay"] = mo.json } + return Metadata( identifier: uniqueIdentifier, conformsTo: [.epub], @@ -62,11 +65,12 @@ final class EPUBMetadataParser: Loggable { layout: layout(), readingProgression: readingProgression, description: description, + duration: mediaDuration, numberOfPages: numberOfPages, belongsToCollections: belongsToCollections, belongsToSeries: belongsToSeries, tdm: tdm(), - otherMetadata: metas.otherMetadata + otherMetadata: other ) } @@ -140,16 +144,14 @@ final class EPUBMetadataParser: Loggable { /// Maps between an element ID and its `display-seq` refine, if there's any. /// eg. 1 - private lazy var displaySeqs: [String: String] = { - metas["display-seq"] - .reduce([:]) { displaySeqs, meta in - var displaySeqs = displaySeqs - if let id = meta.refines { - displaySeqs[id] = meta.content - } - return displaySeqs + private lazy var displaySeqs: [String: String] = metas["display-seq"] + .reduce([:]) { displaySeqs, meta in + var displaySeqs = displaySeqs + if let id = meta.refines { + displaySeqs[id] = meta.content } - }() + return displaySeqs + } private lazy var mainTitleElement: ReadiumFuzi.XMLElement? = titleElements(ofType: .main).first ?? metas["title", in: .dcterms].first?.element @@ -285,6 +287,20 @@ final class EPUBMetadataParser: Loggable { .map { Accessibility.Exemption($0.content) } } + /// Publication-level SMIL duration (no `refines`). + private lazy var mediaDuration: Double? = + metas["duration", in: .media] + .first(where: { $0.refines == nil }) + .flatMap { SMILParser.parseClockValue($0.content) } + + /// Media overlay CSS class names. + private func mediaOverlay() -> EPUBMediaOverlay? { + let active = metas["active-class", in: .media].first?.content + let playbackActive = metas["playback-active-class", in: .media].first?.content + guard active != nil || playbackActive != nil else { return nil } + return EPUBMediaOverlay(activeClass: active, playbackActiveClass: playbackActive) + } + /// https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/#sec-epub3 private func tdm() -> TDM? { guard @@ -435,17 +451,15 @@ final class EPUBMetadataParser: Loggable { }() /// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#collections-and-series - private lazy var belongsToCollections: [Metadata.Collection] = { - metas["belongs-to-collection"] - // `collection-type` should not be "series" - .filter { meta in - if let id = meta.id { - return metas["collection-type", refining: id].first?.content != "series" - } - return true + private lazy var belongsToCollections: [Metadata.Collection] = metas["belongs-to-collection"] + // `collection-type` should not be "series" + .filter { meta in + if let id = meta.id { + return metas["collection-type", refining: id].first?.content != "series" } - .compactMap(collection(from:)) - }() + return true + } + .compactMap(collection(from:)) /// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#collections-and-series private lazy var belongsToSeries: [Metadata.Collection] = { @@ -464,7 +478,7 @@ final class EPUBMetadataParser: Loggable { return calibreSeries } - let epub3Series = metas["belongs-to-collection"] + return metas["belongs-to-collection"] // `collection-type` should be "series" .filter { meta in guard let id = meta.id else { @@ -473,8 +487,6 @@ final class EPUBMetadataParser: Loggable { return metas["collection-type", refining: id].first?.content == "series" } .compactMap(collection(from:)) - - return epub3Series }() private func collection(from meta: OPFMeta) -> Metadata.Collection? { diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index fecbc5dbc2..d9d18e3aa4 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,12 +8,6 @@ import Foundation import ReadiumFuzi import ReadiumShared -/// Epub related constants. -private enum EPUBConstant { - /// Media Overlays URL. - static let mediaOverlayURL = "media-overlay?resource=" -} - /// Errors thrown during the parsing of the EPUB /// /// - wrongMimeType: The mimetype file is missing or its content differs from @@ -77,12 +71,13 @@ public final class EPUBParser: PublicationParser { HTMLResourceContentIterator.Factory(), ] ), + guidedNavigation: SMILGuidedNavigationService.makeFactory(), positions: EPUBPositionsService.makeFactory(reflowableStrategy: reflowablePositionsStrategy), search: StringSearchService.makeFactory() ) )) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } } diff --git a/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift b/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift index a06ab03a18..9da0f56b36 100644 --- a/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift +++ b/Sources/Streamer/Parser/EPUB/Extensions/Layout+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift b/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift index 3ac64004b4..ff5ce48468 100644 --- a/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift +++ b/Sources/Streamer/Parser/EPUB/Extensions/LinkRelation+EPUB.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/NCXParser.swift b/Sources/Streamer/Parser/EPUB/NCXParser.swift index e7193296f3..2b5ec7d8e6 100644 --- a/Sources/Streamer/Parser/EPUB/NCXParser.swift +++ b/Sources/Streamer/Parser/EPUB/NCXParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift b/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift index 6bce202b74..80fb012e88 100644 --- a/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift +++ b/Sources/Streamer/Parser/EPUB/NavigationDocumentParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/OPFMeta.swift b/Sources/Streamer/Parser/EPUB/OPFMeta.swift index 2418eeda1e..c2c1f31aea 100644 --- a/Sources/Streamer/Parser/EPUB/OPFMeta.swift +++ b/Sources/Streamer/Parser/EPUB/OPFMeta.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,18 +11,18 @@ import ReadiumShared /// Package vocabularies used for `property`, `properties`, `scheme` and `rel`. /// http://www.idpf.org/epub/301/spec/epub-publications.html#sec-metadata-assoc enum OPFVocabulary: String { - // Fallback prefixes for metadata's properties and links' rels. + /// Fallback prefixes for metadata's properties and links' rels. case defaultMetadata, defaultLinkRel - // Reserved prefixes - // https://idpf.github.io/epub-prefixes/packages/ + /// Reserved prefixes + /// https://idpf.github.io/epub-prefixes/packages/ case a11y, dcterms, epubsc, marc, media, onix, rendition, schema, xsd - // Additional prefixes used in the streamer. + /// Additional prefixes used in the streamer. case calibre - // New TDM Reservation Protocol - // https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/ + /// New TDM Reservation Protocol + /// https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/ case tdm var uri: String { @@ -309,7 +309,7 @@ struct OPFMetaList { "language", "modified", "publisher", "subject", "title", "conformsTo", ], - .media: ["duration"], + .media: ["duration", "active-class", "playback-active-class", "narrator"], .rendition: ["layout"], .schema: [ "numberOfPages", "accessMode", "accessModeSufficient", diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 35e219ca70..0da98a2b6f 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,8 +8,8 @@ import Foundation import ReadiumFuzi import ReadiumShared -// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type -// the six basic values of the "title-type" property specified by EPUB 3: +/// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type +/// the six basic values of the "title-type" property specified by EPUB 3: public enum EPUBTitleType: String { case main case subtitle @@ -19,16 +19,17 @@ public enum EPUBTitleType: String { case expanded } -public enum OPFParserError: Error { - /// The Epub have no title. Title is mandatory. - case missingPublicationTitle - /// Smile resource couldn't be parsed. - case invalidSmilResource -} - /// EpubParser support class, able to parse the OPF package document. /// OPF: Open Packaging Format. final class OPFParser: Loggable { + /// Internal representation of a manifest item during parsing. + private struct ManifestItem { + let id: String + let link: Link + let fallbackId: String? + let mediaOverlayId: String? + } + /// Relative path to the OPF in the EPUB container private let baseURL: RelativeURL @@ -88,13 +89,19 @@ final class OPFParser: Loggable { /// Parse the OPF file of the EPUB container and return a `Publication`. /// It also complete the informations stored in the container. func parsePublication() throws -> Package { - let links = parseLinks() - let (resources, readingOrder) = splitResourcesAndReadingOrderLinks(links) - let metadata = EPUBMetadataParser(document: document, displayOptions: displayOptions, metas: metas) + let manifestItems = parseManifestItems() + let (resources, readingOrder) = splitResourcesAndReadingOrderLinks(manifestItems) + var metadata = try EPUBMetadataParser(document: document, displayOptions: displayOptions, metas: metas).parse() + + // If all reading order items are bitmaps, we infer a Divina. + if readingOrder.allAreBitmap { + metadata.layout = .fixed + metadata.conformsTo.append(.divina) + } - return try Package( + return Package( version: parseEPUBVersion(), - metadata: metadata.parse(), + metadata: metadata, readingOrder: readingOrder, resources: resources, epub2Guide: parseEPUB2Guide() @@ -129,8 +136,8 @@ final class OPFParser: Loggable { } } - /// Parses XML elements of the in the package.opf file as a list of `Link`. - private func parseLinks() -> [Link] { + /// Parses XML elements of the in the package.opf file. + private func parseManifestItems() -> [ManifestItem] { // Read meta to see if any Link is referenced as the Cover. let coverId = metas["cover"].first?.content @@ -152,44 +159,21 @@ final class OPFParser: Loggable { let isCover = (id == coverId) - guard let link = makeLink(manifestItem: manifestItem, spineItem: spineItems[id], isCover: isCover) else { + guard let item = makeManifestItem(id: id, manifestItem: manifestItem, spineItem: spineItems[id], isCover: isCover) else { log(.warning, "Can't parse link with ID \(id)") return nil } - return link + return item } } - /// Parses XML elements of the in the package.opf file. - /// They are only composed of an `idref` referencing one of the previously parsed resource (XML: idref -> id). - /// - /// - Parameter manifestLinks: The `Link` parsed in the manifest items. - /// - Returns: The `Link` in `resources` and in `readingOrder`, taken from the `manifestLinks`. - private func splitResourcesAndReadingOrderLinks(_ manifestLinks: [Link]) -> (resources: [Link], readingOrder: [Link]) { - var resources = manifestLinks - var readingOrder: [Link] = [] - - let spineItems = document.xpath("/opf:package/opf:spine/opf:itemref") - for item in spineItems { - // Find the `Link` that `idref` is referencing to from the `manifestLinks`. - guard let idref = item.attr("idref"), - let index = resources.firstIndex(where: { $0.properties["id"] as? String == idref }), - // Only linear items are added to the readingOrder. - item.attr("linear")?.lowercased() != "no" - else { - continue - } - - readingOrder.append(resources[index]) - // `resources` should only contain the links that are not already in `readingOrder`. - resources.remove(at: index) - } - - return (resources, readingOrder) - } - - private func makeLink(manifestItem: ReadiumFuzi.XMLElement, spineItem: ReadiumFuzi.XMLElement?, isCover: Bool) -> Link? { + private func makeManifestItem( + id: String, + manifestItem: ReadiumFuzi.XMLElement, + spineItem: ReadiumFuzi.XMLElement?, + isCover: Bool + ) -> ManifestItem? { guard let relativeHref = manifestItem.attr("href").flatMap(RelativeURL.init(epubHREF:)), let href = baseURL.resolve(relativeHref)?.normalized @@ -216,18 +200,83 @@ final class OPFParser: Loggable { properties["encrypted"] = encryption } - let type = manifestItem.attr("media-type") + let duration = metas["duration", in: .media, refining: id] + .first + .flatMap { SMILParser.parseClockValue($0.content) } - if let id = manifestItem.attr("id") { - properties["id"] = id - } - - return Link( + let link = Link( href: href.string, - mediaType: type.flatMap { MediaType($0) }, + mediaType: manifestItem.attr("media-type").flatMap { MediaType($0) }, rels: rels, - properties: Properties(properties) + properties: Properties(properties), + duration: duration ) + + return ManifestItem( + id: id, + link: link, + fallbackId: manifestItem.attr("fallback"), + mediaOverlayId: manifestItem.attr("media-overlay") + ) + } + + /// Parses XML elements of the spine in the package.opf file. + /// + /// They are only composed of an `idref` referencing one of the previously + /// parsed resource (XML: idref -> id). + /// + /// Handles image spine items with HTML fallbacks (and vice versa) by + /// putting the image in the reading order and the HTML in `alternates`. + /// This is because we prefer treating it as a Divina to render it. + /// + /// - Parameter manifestItems: The items parsed from the manifest. + /// - Returns: The `Link` in `resources` and in `readingOrder`. + private func splitResourcesAndReadingOrderLinks(_ manifestItems: [ManifestItem]) -> (resources: [Link], readingOrder: [Link]) { + var items = manifestItems + var readingOrder: [Link] = [] + + let spineItems = document.xpath("/opf:package/opf:spine/opf:itemref") + for spineItem in spineItems { + // Find the item that `idref` is referencing. + guard + let idref = spineItem.attr("idref"), + let index = items.firstIndex(where: { $0.id == idref }), + // Only linear items are added to the readingOrder. + spineItem.attr("linear")?.lowercased() != "no" + else { + continue + } + + let item = items.remove(at: index) + var spineLink = item.link + + // Resolve fallback: prefer bitmaps as primary to treat image-based + // EPUBs as Divina + if + let fallbackId = item.fallbackId, + let fallbackIndex = items.firstIndex(where: { $0.id == fallbackId }) + { + let fallbackItem = items.remove(at: fallbackIndex) + spineLink = resolveFallbackChain( + spineLink: spineLink, + fallbackLink: fallbackItem.link + ) + } + + // Attach the SMIL media overlay as an alternate. + if + let mediaOverlayId = item.mediaOverlayId, + let smilIndex = items.firstIndex(where: { $0.id == mediaOverlayId && $0.link.mediaType?.matches(.smil) == true }) + { + let smilItem = items.remove(at: smilIndex) + spineLink.alternates.append(smilItem.link) + } + + readingOrder.append(spineLink) + } + + let resources = items.map(\.link) + return (resources, readingOrder) } /// Parse string properties into an `otherProperties` dictionary. @@ -272,4 +321,25 @@ final class OPFParser: Loggable { return otherProperties } + + /// Resolves which link should be primary vs alternate when a fallback is + /// present. + /// + /// We prefer bitmaps as primary to treat image-based EPUBs as Divina. + private func resolveFallbackChain( + spineLink: Link, + fallbackLink: Link + ) -> Link { + var link = spineLink + // If fallback is a bitmap and spine is HTML, swap them. + if spineLink.mediaType?.isHTML == true, fallbackLink.mediaType?.isBitmap == true { + link = fallbackLink + // Transfer spine properties (like page spread) to the image + link.properties = spineLink.properties + link.alternates = [spineLink] + } else { + link.alternates = [fallbackLink] + } + return link + } } diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index 1852d0519c..7b9fbfe95d 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift new file mode 100644 index 0000000000..f03c7569d0 --- /dev/null +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift @@ -0,0 +1,90 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared + +/// A ``GuidedNavigationService`` for EPUB 3 publications with SMIL Media +/// Overlay documents. +/// +/// Discovers SMIL documents via `link.alternates` on reading order items, as +/// populated by ``EPUBParser``. +actor SMILGuidedNavigationService: GuidedNavigationService { + static func makeFactory() -> GuidedNavigationServiceFactory { + { context in + SMILGuidedNavigationService( + readingOrder: context.manifest.readingOrder, + container: context.container + ) + } + } + + nonisolated let readingOrder: [Link] + private let container: Container + private var gndCache: [AnyURL: GuidedNavigationDocument?] = [:] + + init(readingOrder: [Link], container: Container) { + self.readingOrder = readingOrder + self.container = container + } + + nonisolated var hasGuidedNavigation: Bool { + readingOrder.contains { + $0.alternates.anyMatchingMediaType(.smil) + } + } + + nonisolated func hasGuidedNavigation(for href: any URLConvertible) -> Bool { + readingOrder.firstWithHREF(href)? + .alternates + .anyMatchingMediaType(.smil) + ?? false + } + + func guidedNavigationDocument( + for href: any URLConvertible + ) async -> ReadResult { + guard + let link = readingOrder.firstWithHREF(href), + let smilURL = link.alternates.firstWithMediaType(.smil)?.url() + else { + return .success(nil) + } + + if let cached = gndCache[smilURL] { + return .success(cached) + } + let result = await retrieve(smilURL) + if case let .success(doc) = result { + // Use updateValue to properly store nil without removing the key. + // A nil doc is a valid cached result (SMIL exists but has no guided + // content), and the dictionary subscript setter treats `= nil` as + // key removal, which would cause repeated re-parsing. + gndCache.updateValue(doc, forKey: smilURL) + } + return result + } + + private func retrieve(_ smilURL: AnyURL) async -> ReadResult { + guard let resource = container[smilURL] else { + return .failure(.decoding("SMIL not found at \(smilURL)")) + } + + return await resource.read() + .flatMap { data in + do { + return try .success( + SMILParser.parseGuidedNavigationDocument( + smilData: data, + at: smilURL + ) + ) + } catch { + return .failure(.wrap(error) ?? .decoding(error)) + } + } + } +} diff --git a/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift new file mode 100644 index 0000000000..fabe4dc518 --- /dev/null +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift @@ -0,0 +1,335 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumFuzi +import ReadiumShared + +/// Parses EPUB 3 SMIL Media Overlay documents and values into Readium models +/// (e.g. ``GuidedNavigationDocument``). +/// +/// https://www.w3.org/TR/epub-mediaoverlays-33/ +enum SMILParser { + /// Parses a SMIL Media Overlay document into a ``GuidedNavigationDocument``. + /// + /// - Returns: `nil` if the document is valid but contains no guided + /// content. + static func parseGuidedNavigationDocument( + smilData: Data, + at url: AnyURL, + warnings: WarningLogger? = nil + ) throws -> GuidedNavigationDocument? { + let document = try ReadiumFuzi.XMLDocument(data: smilData) + document.defineNamespaces(.smil, .epub) + return SMILGuidedNavigationDocumentParsing( + document: document, + url: url, + warnings: warnings + ).parse() + } + + /// Parses a SMIL clock value string (e.g. `"0:01:30.5"`, `"90s"`, `"2h"`) + /// into a duration in seconds. + /// + /// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax + /// + /// - Returns: `nil` for invalid input. + static func parseClockValue(_ value: String) -> TimeInterval? { + let s = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !s.isEmpty else { return nil } + + // Timecount values: Nh, Nmin, Ns, Nms, N + let timecountPatterns: [(suffix: String, multiplier: Double)] = [ + ("h", 3600), + ("min", 60), + ("ms", 0.001), + ("s", 1), + ] + for (suffix, multiplier) in timecountPatterns { + if s.hasSuffix(suffix) { + let numStr = String(s.dropLast(suffix.count)) + if let n = Double(numStr) { + return n * multiplier + } + } + } + + // Clock values: [[hh:]mm:]ss[.fraction] + let parts = s.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) + switch parts.count { + case 2: + // mm:ss[.fraction] + guard let mm = Double(parts[0]), let ss = Double(parts[1]) else { return nil } + return mm * 60 + ss + case 3: + // hh:mm:ss[.fraction] + guard let hh = Double(parts[0]), let mm = Double(parts[1]), let ss = Double(parts[2]) else { return nil } + return hh * 3600 + mm * 60 + ss + default: + // Plain number (seconds) + return Double(s) + } + } +} + +/// Parses a SMIL Media Overlay document into a ``GuidedNavigationDocument``. +/// +/// Holds the per-parse state, avoiding parameter threading through helpers. +private struct SMILGuidedNavigationDocumentParsing { + let document: ReadiumFuzi.XMLDocument + let url: AnyURL + let warnings: WarningLogger? + + func parse() -> GuidedNavigationDocument? { + guard let body = document.firstChild(xpath: "/smil:smil/smil:body") else { + return nil + } + + let objects = parseObjects(in: body) + guard !objects.isEmpty else { + return nil + } + + return GuidedNavigationDocument(guided: objects) + } + + private func parseObjects(in element: ReadiumFuzi.XMLElement) -> [GuidedNavigationObject] { + element.xpath("smil:seq|smil:par") + .compactMap { child -> GuidedNavigationObject? in + switch child.tag?.lowercased() { + case "seq": + return parseSeq(child) + case "par": + return parsePar(child) + default: + return nil + } + } + } + + private func parseSeq(_ element: ReadiumFuzi.XMLElement) -> GuidedNavigationObject? { + let id = element.attr("id") + let epubType = element.attr("type", namespace: .epub) + + let textrefAttr = element.attr("textref", namespace: .epub) + if textrefAttr == nil { + warnings?.log(" is missing required epub:textref", model: GuidedNavigationObject.self, source: element, severity: .minor) + } + let textref = textrefAttr.flatMap { resolveURL($0) } + + let children = parseObjects(in: element) + + return GuidedNavigationObject( + id: id, + refs: textref.flatMap { GuidedNavigationObject.Refs(text: $0) }, + roles: [.sequence] + roles(for: epubType), + children: children + ) + } + + private func parsePar(_ element: ReadiumFuzi.XMLElement) -> GuidedNavigationObject? { + // A par MUST have a child - skip if absent. + guard + let textElement = element.firstChild(xpath: "smil:text"), + let textURL = textElement.attr("src").flatMap({ resolveURL($0) }) + else { + warnings?.log(" has no valid element", model: GuidedNavigationObject.self, source: element, severity: .minor) + return nil + } + + let id = element.attr("id") + let epubType = element.attr("type", namespace: .epub) + + let audioRef: AnyURL? = element.firstChild(xpath: "smil:audio").flatMap(clipURL(from:)) + let videoRef: AnyURL? = element.firstChild(xpath: "smil:video").flatMap(clipURL(from:)) + + let imgRef: AnyURL? = element.firstChild(xpath: "smil:img") + .flatMap { $0.attr("src").flatMap { resolveURL($0) } } + + let refs = GuidedNavigationObject.Refs( + text: textURL, + img: imgRef, + audio: audioRef, + video: videoRef + ) + + return GuidedNavigationObject( + id: id, + refs: refs, + roles: roles(for: epubType) + ) + } + + /// Resolves a `src` attribute value relative to the SMIL document URL. + private func resolveURL(_ src: String) -> AnyURL? { + RelativeURL(epubHREF: src).flatMap { url.resolve($0) } + } + + /// Extracts the clip URL from a `` or `` element. + private func clipURL(from element: ReadiumFuzi.XMLElement) -> AnyURL? { + guard let src = element.attr("src") else { + return nil + } + return clipURL( + src: src, + clipBegin: element.attr("clipBegin"), + clipEnd: element.attr("clipEnd") + ) + } + + /// Builds a media URL with optional W3C Media Fragment times. + /// + /// Format: `media.mp4#t=begin,end` + private func clipURL(src: String, clipBegin: String?, clipEnd: String?) -> AnyURL? { + guard let base = resolveURL(src) else { + return nil + } + + let begin = clipBegin.flatMap { SMILParser.parseClockValue($0) } + let end = clipEnd.flatMap { SMILParser.parseClockValue($0) } + + guard begin != nil || end != nil else { + return base + } + + let beginStr = begin.map { formatSeconds($0) } ?? "" + let endStr = end.map { formatSeconds($0) } ?? "" + + // Append a media fragment to the URL. + guard var components = URLComponents(url: base.url, resolvingAgainstBaseURL: false) else { + return base + } + components.fragment = "t=\(beginStr),\(endStr)" + return components.url.flatMap { AnyURL(url: $0) } + } + + /// Formats a seconds value, stripping the `.0` suffix for integers. + private func formatSeconds(_ seconds: TimeInterval) -> String { + if seconds == floor(seconds) { + return String(Int(seconds)) + } + var result = String(format: "%.3f", seconds) + while result.last == "0" { + result.removeLast() + } + if result.last == "." { result.removeLast() } + return result + } + + /// Maps an `epub:type` attribute (space-separated tokens) to roles. + private func roles(for epubType: String?) -> [GuidedNavigationObject.Role] { + guard + let epubType, + !epubType.trimmingCharacters(in: .whitespaces).isEmpty + else { + return [] + } + + return epubType + .split(separator: " ") + .map { role(for: String($0)) } + } + + private func role(for token: String) -> GuidedNavigationObject.Role { + Self.epubTypeToRole[token] + // Fall back to a full EPUB type URI role. + ?? GuidedNavigationObject.Role("http://www.idpf.org/2007/ops/type#\(token)") + } + + /// Mapping from EPUB type equivalent to Guided Navigation Roles. + /// + /// See https://readium.org/guided-navigation/roles + private static let epubTypeToRole: [String: GuidedNavigationObject.Role] = [ + // HTML and/or ARIA + + "aside": .aside, + "table-cell": .cell, + "glossdef": .definition, + "figure": .figure, + "list": .list, + "list-item": .listItem, + "table-row": .row, + "table": .table, + "glossterm": .term, + + // DPUB ARIA 1.0 + + "abstract": .abstract, + "acknowledgments": .acknowledgments, + "afterword": .afterword, + "appendix": .appendix, + "backlink": .backlink, + "bibliography": .bibliography, + "biblioref": .biblioref, + "chapter": .chapter, + "colophon": .colophon, + "conclusion": .conclusion, + "cover": .cover, + "credit": .credit, + "credits": .credits, + "dedication": .dedication, + "endnotes": .endnotes, + "epigraph": .epigraph, + "epilogue": .epilogue, + "errata": .errata, + "example": .example, + "footnote": .footnote, + "glossary": .glossary, + "glossref": .glossref, + "index": .index, + "introduction": .introduction, + "noteref": .noteref, + "notice": .notice, + "pagebreak": .pagebreak, + "page-list": .pagelist, + "part": .part, + "preface": .preface, + "prologue": .prologue, + "pullquote": .pullquote, + "qna": .qna, + "subtitle": .subtitle, + "tip": .tip, + "toc": .toc, + + // EPUB 3 Structural Semantics Vocabulary 1.1 + + "balloon": .bubble, + "foreword": .foreword, + "landmarks": .landmarks, + "loa": .loa, + "loi": .loi, + "lot": .lot, + "lov": .lov, + "panel": .panel, + "panel-group": .panelGroup, + "soundArea": .sound, + ] +} + +/// Warning raised when parsing a model object from its SMIL representation +/// fails. +public struct SMILWarning: Warning { + /// Type of the model object to be parsed. + public let modelType: Any.Type + /// Details about the failure. + public let reason: String + /// String representation of the source XML element. + public let source: String? + public let severity: WarningSeverityLevel + public var tag: String { + "smil" + } + + public var message: String { + "SMIL \(modelType): \(reason)" + } +} + +private extension WarningLogger { + func log(_ reason: String, model: Any.Type, source: ReadiumFuzi.XMLElement, severity: WarningSeverityLevel = .major) { + log(SMILWarning(modelType: model, reason: reason, source: source.rawXML, severity: severity)) + } +} diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index da3759a2b4..26a81e59ab 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/EPUB/XMLNamespace.swift b/Sources/Streamer/Parser/EPUB/XMLNamespace.swift index 258de892da..46017431ac 100644 --- a/Sources/Streamer/Parser/EPUB/XMLNamespace.swift +++ b/Sources/Streamer/Parser/EPUB/XMLNamespace.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/Image/ComicInfoParser.swift b/Sources/Streamer/Parser/Image/ComicInfoParser.swift new file mode 100644 index 0000000000..7df383522f --- /dev/null +++ b/Sources/Streamer/Parser/Image/ComicInfoParser.swift @@ -0,0 +1,360 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumFuzi +import ReadiumShared + +/// Parses ComicInfo.xml metadata from CBZ archives. +/// +/// ComicInfo.xml is a metadata format originating from the ComicRack +/// application. +/// See: https://anansi-project.github.io/docs/comicinfo/documentation +enum ComicInfoParser { + /// Parses ComicInfo.xml data and returns the parsed metadata. + static func parse(data: Data, warnings: WarningLogger?) -> ComicInfo? { + guard let document = try? XMLDocument(data: data) else { + warnings?.log(ComicInfoWarning(message: "Failed to parse ComicInfo.xml")) + return nil + } + + guard let root = document.root, root.tag == "ComicInfo" else { + warnings?.log(ComicInfoWarning(message: "ComicInfo.xml root element is not ")) + return nil + } + + return ComicInfo(element: root) + } +} + +/// Warning raised when parsing a ComicInfo.xml file. +struct ComicInfoWarning: Warning { + let message: String + var severity: WarningSeverityLevel { + .minor + } + + var tag: String { + "comicinfo" + } +} + +/// Parsed representation of ComicInfo.xml data. +/// +/// Only metadata fields that map to RWPM are exposed as first-class properties. +/// All other fields are available in the `otherMetadata` dictionary. +/// +/// See https://anansi-project.github.io/docs/comicinfo/documentation +struct ComicInfo { + /// Title of the book. + var title: String? + + /// Title of the series the book is part of. + var series: String? + + /// Number of the book in the series. + var number: String? + + /// Alternate series name, used for cross-over story arcs. + var alternateSeries: String? + + /// Number of the book in the alternate series. + var alternateNumber: String? + + /// A description or summary of the book. + var summary: String? + + /// Person or organization responsible for publishing, releasing, or + /// issuing a resource. + var publisher: String? + + /// An imprint is a group of publications under the umbrella of a larger + /// imprint or publisher. + var imprint: String? + + /// Release year of the book. + var year: Int? + + /// Release month of the book. + var month: Int? + + /// Release day of the book. + var day: Int? + + /// Language of the book using IETF BCP 47 language tags. + var languageISO: String? + + /// Global Trade Item Number identifying the book (ISBN, EAN, etc.). + var gtin: String? + + /// People or organizations responsible for creating the scenario. + var writers: [String] = [] + + /// People or organizations responsible for drawing the art. + var pencillers: [String] = [] + + /// People or organizations responsible for inking the pencil art. + var inkers: [String] = [] + + /// People or organizations responsible for applying color to drawings. + var colorists: [String] = [] + + /// People or organizations responsible for drawing text and speech bubbles. + var letterers: [String] = [] + + /// People or organizations responsible for drawing the cover art. + var coverArtists: [String] = [] + + /// People or organizations responsible for preparing the resource for + /// production. + var editors: [String] = [] + + /// People or organizations responsible for rendering text from one language + /// into another. + var translators: [String] = [] + + /// Genres of the book or series (e.g., Science-Fiction, Shonen). + var genres: [String] = [] + + /// Whether the book is a manga. The value `.yesAndRightToLeft` indicates + /// right-to-left reading direction. + var manga: Manga? + + /// Page information parsed from the element. + var pages: [PageInfo] = [] + + /// Returns the first page with the given type, if any. + func firstPageWithType(_ type: PageType) -> PageInfo? { + pages.first { $0.type == type } + } + + /// All other metadata fields not directly mapped to RWPM. + /// + /// Keys are the XML tag names (e.g., "Volume", "Characters", "AgeRating"). + /// Values are strings as they appear in the XML. + var otherMetadata: [String: String] = [:] + + /// URL prefix for otherMetadata keys when converting to RWPM. + private static let otherMetadataPrefix = "https://anansi-project.github.io/docs/comicinfo/documentation#" + + init(element: ReadiumFuzi.XMLElement) { + for child in element.children { + guard let tag = child.tag else { continue } + + // Pages element has no text content, only child elements + if tag == "Pages" { + pages = child.children(tag: "Page").compactMap { PageInfo(element: $0) } + continue + } + + let value = child.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + + switch tag { + // Core + case "AlternateNumber": alternateNumber = value + case "AlternateSeries": alternateSeries = value + case "Day": day = Int(value) + case "GTIN": gtin = value + case "Genre": genres = value.splitComma() + case "Imprint": imprint = value + case "LanguageISO": languageISO = value + case "Manga": manga = Manga(rawValue: value) + case "Month": month = Int(value) + case "Number": number = value + case "Publisher": publisher = value + case "Series": series = value + case "Summary": summary = value + case "Title": title = value + case "Year": year = Int(value) + // Contributors + case "Colorist": colorists = value.splitComma() + case "CoverArtist": coverArtists = value.splitComma() + case "Editor": editors = value.splitComma() + case "Inker": inkers = value.splitComma() + case "Letterer": letterers = value.splitComma() + case "Penciller": pencillers = value.splitComma() + case "Translator": translators = value.splitComma() + case "Writer": writers = value.splitComma() + // Everything else goes to otherMetadata + default: otherMetadata[tag] = value + } + } + } + + /// Converts to RWPM Metadata. + func toMetadata() -> Metadata { + // Build published date from year/month/day + var published: Date? + if let year = year { + var components = DateComponents() + components.year = year + components.month = month ?? 1 + components.day = day ?? 1 + published = Calendar(identifier: .gregorian).date(from: components) + } + + // Parse series + var belongsToSeries: [Contributor] = [] + if let series = series { + let position = number.flatMap { Double($0) } + belongsToSeries.append(Contributor(name: series, position: position)) + } + if let alternateSeries = alternateSeries { + let position = alternateNumber.flatMap { Double($0) } + belongsToSeries.append(Contributor(name: alternateSeries, position: position)) + } + + // Build other metadata with specification URL prefix + var rwpmOtherMetadata: [String: Any] = [:] + for (key, value) in otherMetadata { + rwpmOtherMetadata[Self.otherMetadataPrefix + key.lowercased()] = value + } + + return Metadata( + identifier: gtin, + title: title, + published: published, + languages: languageISO.map { [$0] } ?? [], + subjects: genres.map { Subject(name: $0) }, + authors: writers.map { Contributor(name: $0) }, + translators: translators.map { Contributor(name: $0) }, + editors: editors.map { Contributor(name: $0) }, + letterers: letterers.map { Contributor(name: $0) }, + pencilers: pencillers.map { Contributor(name: $0) }, + colorists: colorists.map { Contributor(name: $0) }, + inkers: inkers.map { Contributor(name: $0) }, + contributors: coverArtists.map { Contributor(name: $0, role: "cov") }, + publishers: publisher.map { [Contributor(name: $0)] } ?? [], + imprints: imprint.map { [Contributor(name: $0)] } ?? [], + readingProgression: (manga == .yesAndRightToLeft) ? .rtl : .auto, + description: summary, + belongsToSeries: belongsToSeries, + otherMetadata: rwpmOtherMetadata + ) + } + + // MARK: - ComicInfo Types + + /// Page type values from the ComicInfo specification. + /// + /// See: https://anansi-project.github.io/docs/comicinfo/documentation#type + enum PageType: Hashable, Sendable { + case frontCover + case innerCover + case roundup + case story + case advertisement + case editorial + case letters + case preview + case backCover + case other + case deleted + + /// Case-insensitive initializer. + init?(rawValue: String) { + switch rawValue.lowercased() { + case "frontcover": self = .frontCover + case "innercover": self = .innerCover + case "roundup": self = .roundup + case "story": self = .story + case "advertisement": self = .advertisement + case "editorial": self = .editorial + case "letters": self = .letters + case "preview": self = .preview + case "backcover": self = .backCover + case "other": self = .other + case "deleted", "delete": self = .deleted + default: return nil + } + } + } + + /// Information about a single page from ComicInfo.xml. + /// + /// See: https://anansi-project.github.io/docs/comicinfo/documentation#pages--comicpageinfo + struct PageInfo: Hashable, Sendable { + /// Zero-based index of this page in the reading order. + let image: Int + + /// The type/purpose of this page. + let type: PageType? + + /// Whether this is a double-page spread. + let doublePage: Bool? + + /// File size in bytes. + let imageSize: Int64? + + /// Page key/identifier. + let key: String? + + /// Bookmark name for this page. + let bookmark: String? + + /// Width of the page image in pixels. + let imageWidth: Int? + + /// Height of the page image in pixels. + let imageHeight: Int? + + /// Parses a PageInfo from an XML element. + init?(element: ReadiumFuzi.XMLElement) { + guard + let imageStr = element.attr("Image"), + let image = Int(imageStr) + else { + return nil + } + + self.image = image + type = element.attr("Type").flatMap { PageType(rawValue: $0) } + doublePage = element.attr("DoublePage").flatMap { + switch $0.lowercased() { + case "true", "1": return true + case "false", "0": return false + default: return nil + } + } + imageSize = element.attr("ImageSize").flatMap { Int64($0) } + key = element.attr("Key") + bookmark = element.attr("Bookmark") + imageWidth = element.attr("ImageWidth").flatMap { Int($0) } + imageHeight = element.attr("ImageHeight").flatMap { Int($0) } + } + } + + /// Manga field values indicating whether the book is a manga and its + /// reading direction. + /// + /// See: https://anansi-project.github.io/docs/comicinfo/documentation#manga + enum Manga { + case unknown + case no + case yes + case yesAndRightToLeft + + /// Case-insensitive initializer. + init?(rawValue: String) { + switch rawValue.lowercased() { + case "unknown": self = .unknown + case "no": self = .no + case "yes": self = .yes + case "yesandrighttoleft": self = .yesAndRightToLeft + default: return nil + } + } + } +} + +private extension String { + func splitComma() -> [String] { + split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} diff --git a/Sources/Streamer/Parser/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index ba2d51a77a..a1b25fa4d2 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -25,6 +25,7 @@ public final class ImageParser: PublicationParser { .bmp, .gif, .jpeg, + .jxl, .png, .tiff, .webp, @@ -53,8 +54,7 @@ public final class ImageParser: PublicationParser { let container = SingleResourceContainer(publication: asset) return makeBuilder( container: container, - readingOrder: [(container.entry, asset.format)], - title: nil + readingOrder: [(container.entry, asset.format)] ) } @@ -66,16 +66,32 @@ public final class ImageParser: PublicationParser { return .failure(.formatNotSupported) } + // Parse ComicInfo.xml metadata if present + let comicInfo = await parseComicInfo(from: asset.container, warnings: warnings) + return await makeReadingOrder(for: asset.container) .flatMap { readingOrder in makeBuilder( container: asset.container, readingOrder: readingOrder, - title: asset.container.guessTitle(ignoring: ignores) + comicInfo: comicInfo ) } } + /// Finds and parses the ComicInfo.xml file from the container. + private func parseComicInfo(from container: Container, warnings: WarningLogger?) async -> ComicInfo? { + // Look for ComicInfo.xml at the root or in a subdirectory + guard + let url = container.entries.first(where: { $0.lastPathSegment?.lowercased() == "comicinfo.xml" }), + let data = try? await container.readData(at: url) + else { + return nil + } + + return ComicInfoParser.parse(data: data, warnings: warnings) + } + private func makeReadingOrder(for container: Container) async -> Result<[(AnyURL, Format)], PublicationParseError> { await container .sniffFormats( @@ -113,7 +129,7 @@ public final class ImageParser: PublicationParser { private func makeBuilder( container: Container, readingOrder: [(AnyURL, Format)], - title: String? + comicInfo: ComicInfo? = nil ) -> Result { guard !readingOrder.isEmpty else { return .failure(.reading(.decoding("No bitmap resources found in the publication"))) @@ -126,15 +142,45 @@ public final class ImageParser: PublicationParser { ) } - // First valid resource is the cover. - readingOrder[0].rels = [.cover] + // Set cover if explicitly declared in ComicInfo.xml + var coverIndex: Int? + if + let coverPage = comicInfo?.firstPageWithType(.frontCover), + coverPage.image >= 0, + coverPage.image < readingOrder.count + { + coverIndex = coverPage.image + readingOrder[coverPage.image].rels.append(.cover) + } + + // Determine story start index (where actual content begins) + // Only set if different from cover page (prefer .cover if same page) + if + let storyPage = comicInfo?.firstPageWithType(.story), + storyPage.image >= 0, + storyPage.image < readingOrder.count, + storyPage.image != coverIndex + { + readingOrder[storyPage.image].rels.append(.start) + } + + // Build metadata from ComicInfo or use defaults + var metadata = comicInfo?.toMetadata() ?? Metadata() + metadata.conformsTo = [.divina] + metadata.layout = .fixed + + // Apply center page layout for double-page spreads + if let pages = comicInfo?.pages { + for pageInfo in pages where pageInfo.doublePage == true { + if readingOrder.indices.contains(pageInfo.image) { + readingOrder[pageInfo.image].properties.page = .center + } + } + } return .success(Publication.Builder( manifest: Manifest( - metadata: Metadata( - conformsTo: [.divina], - title: title - ), + metadata: metadata, readingOrder: readingOrder ), container: container, diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index ba57715f08..3914e2146c 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,13 +10,13 @@ import ReadiumShared /// Errors thrown during the parsing of the PDF. public enum PDFParserError: Error { - // The file at 'path' is missing from the container. + /// The file at 'path' is missing from the container. case missingFile(path: String) - // Failed to open the PDF + /// Failed to open the PDF case openFailed - // The PDF is encrypted with a password. This is not supported right now. + /// The PDF is encrypted with a password. This is not supported right now. case fileEncryptedWithPassword - // The LCP for PDF Package is malformed. + /// The LCP for PDF Package is malformed. case invalidLCPDF } @@ -73,7 +73,7 @@ public final class PDFParser: PublicationParser, Loggable { ) )) } catch { - return .failure(.reading(.decoding(error))) + return .failure(.reading(.wrap(error) ?? .decoding(error))) } } } diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift index 59eb13245f..bc8778a084 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift index b17d83a88d..5e90a850f9 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -44,7 +44,7 @@ final class LCPDFTableOfContentsService: TableOfContentsService, PDFPublicationS let toc = try await pdfFactory.open(resource: resource, at: url, password: nil).tableOfContents() return .success(toc.linksWithDocumentHREF(url)) } catch { - return .failure(.decoding(error)) + return .failure(.wrap(error) ?? .decoding(error)) } } diff --git a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift index 6bdfdf2cac..f4a3fc3b03 100644 --- a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Parser/PublicationParser.swift b/Sources/Streamer/Parser/PublicationParser.swift index 1ba32de9dc..3f2685cbc1 100644 --- a/Sources/Streamer/Parser/PublicationParser.swift +++ b/Sources/Streamer/Parser/PublicationParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,7 +9,7 @@ import ReadiumShared /// Parses a Publication from an asset. public protocol PublicationParser { - /// Constructs a ``Publication.Builder`` to build a ``Publication`` from a + /// Constructs a `Publication.Builder` to build a `Publication` from a /// publication asset. /// /// - Parameters: diff --git a/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift b/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift new file mode 100644 index 0000000000..56797167d7 --- /dev/null +++ b/Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift @@ -0,0 +1,86 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared + +/// A ``GuidedNavigationService`` to retrieve pre-authored Readium Guided +/// Navigation Documents. +/// +/// Discovers Guided Navigation Documents via `link.alternates` on reading order +/// items. +actor ReadiumGuidedNavigationService: GuidedNavigationService { + static func makeFactory() -> GuidedNavigationServiceFactory { + { context in + ReadiumGuidedNavigationService( + manifest: context.manifest, + container: context.container + ) + } + } + + nonisolated let manifest: Manifest + private let container: Container + private var gndCache: [AnyURL: GuidedNavigationDocument?] = [:] + + init(manifest: Manifest, container: Container) { + self.manifest = manifest + self.container = container + } + + nonisolated var hasGuidedNavigation: Bool { + manifest.readingOrder.contains { + $0.alternates.anyMatchingMediaType(.readiumGuidedNavigationDocument) + } + } + + nonisolated func hasGuidedNavigation(for href: any URLConvertible) -> Bool { + manifest.readingOrder.firstWithHREF(href)? + .alternates + .anyMatchingMediaType(.readiumGuidedNavigationDocument) + ?? false + } + + func guidedNavigationDocument( + for href: any URLConvertible + ) async -> ReadResult { + guard + let readingOrderLink = manifest.readingOrder.firstWithHREF(href), + let gnURL = readingOrderLink.alternates.firstWithMediaType(.readiumGuidedNavigationDocument)?.url() + else { + return .success(nil) + } + + if let cached = gndCache[gnURL] { + return .success(cached) + } + let result = await retrieve(gnURL) + if case let .success(doc) = result { + // Use updateValue to properly store nil without removing the key. + // A nil doc is a valid cached result (document exists but has no + // guided content), and the dictionary subscript setter treats + // `= nil` as key removal, which would cause repeated re-parsing. + gndCache.updateValue(doc, forKey: gnURL) + } + return result + } + + private func retrieve(_ gnURL: AnyURL) async -> ReadResult { + guard let resource = container[gnURL] else { + return .failure(.decoding("Guided Navigation Document not found at \(gnURL)")) + } + + return await resource.read() + .asJSONObject() + .flatMap { json in + do { + return try .success(GuidedNavigationDocument(json: json)) + } catch { + return .failure(.decoding(error)) + } + } + } +} diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 34dd520f2a..6afa363606 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -23,9 +23,12 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { private let httpClient: HTTPClient private let epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy - /// - Parameter epubReflowablePositionsStrategy: Strategy used to calculate - /// the number of positions in a reflowable resource of a web publication - /// conforming to the EPUB profile. + /// - Parameters: + /// - pdfFactory: Factory used to open PDF documents, if available. + /// - httpClient: The HTTP client used to fetch remote resources. + /// - epubReflowablePositionsStrategy: Strategy used to calculate + /// the number of positions in a reflowable resource of a web publication + /// conforming to the EPUB profile. public init(pdfFactory: PDFDocumentFactory?, httpClient: HTTPClient, epubReflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy = .recommended) { self.pdfFactory = pdfFactory self.httpClient = httpClient @@ -53,7 +56,8 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { return .failure(.formatNotSupported) } - return await resource.readAsRWPM(warnings: warnings) + return await resource.read() + .asRWPM(warnings: warnings) .flatMap { manifest in let baseURL = manifest.baseURL if baseURL == nil { @@ -98,7 +102,8 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { return .failure(.reading(.decoding("Cannot find a manifest.json file in the RPF package."))) } - return await manifestResource.readAsRWPM(warnings: warnings) + return await manifestResource.read() + .asRWPM(warnings: warnings) .flatMap(checkProfileRequirements(of:)) .map { manifest in var manifest = manifest @@ -136,6 +141,8 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { ] )) } + + $0.setGuidedNavigationServiceFactory(ReadiumGuidedNavigationService.makeFactory()) }) ) } @@ -161,16 +168,17 @@ public class ReadiumWebPubParser: PublicationParser, Loggable { } } -private extension Streamable { - /// Reads the whole content as a Readium Web Pub Manifest. - func readAsRWPM(warnings: WarningLogger?) async -> ReadResult { - await readAsJSON().flatMap { - do { - return try .success(Manifest(json: $0, warnings: warnings)) - } catch { - return .failure(.decoding(error)) +private extension ReadResult { + /// Decodes the data as a Readium Web Pub Manifest. + func asRWPM(warnings: WarningLogger?) -> ReadResult { + asJSONObject() + .flatMap { data in + do { + return try .success(Manifest(json: data, warnings: warnings)) + } catch { + return .failure(.decoding(error)) + } } - } } } @@ -179,5 +187,7 @@ public struct RWPMWarning: Warning { public let message: String public let severity: WarningSeverityLevel - public var tag: String { "rwpm" } + public var tag: String { + "rwpm" + } } diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index 2af9d56708..cf2be56964 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -7,14 +7,14 @@ import Foundation import ReadiumShared -/// Opens a ``Publication`` from an ``Asset``. +/// Opens a `Publication` from an `Asset`. /// /// - Parameters: -/// - parser: Parses the content of a publication ``Asset``. +/// - parser: Parses the content of a publication `Asset`. /// - contentProtections: Opens DRM-protected publications. /// - onCreatePublication: Called on every parsed `Publication.Builder`. It /// can be used to modify the manifest, the root container or the list of -/// service factories of a ``Publication``. +/// service factories of a `Publication`. public class PublicationOpener { private let parser: PublicationParser private let contentProtections: [ContentProtection] @@ -30,15 +30,15 @@ public class PublicationOpener { self.onCreatePublication = onCreatePublication } - /// Opens a ``Publication`` from the given asset. + /// Opens a `Publication` from the given asset. /// /// If you are opening the publication to render it in a Navigator, you - /// must set ``allowUserInteraction`` to true to prompt the user for its + /// must set `allowUserInteraction` to true to prompt the user for its /// credentials when the publication is protected. However, set it to false - /// if you just want to import the ``Publication`` without reading its + /// if you just want to import the `Publication` without reading its /// content, to avoid prompting the user. /// - /// The ``warnings`` logger can be used to observe non-fatal parsing + /// The `warnings` logger can be used to observe non-fatal parsing /// warnings, caused by publication authoring mistakes. This can be useful /// to warn users of potential rendering issues. /// @@ -50,7 +50,7 @@ public class PublicationOpener { /// attempt to unlock a publication, for example a password. /// - onCreatePublication: Transformation which will be applied on the /// Publication Builder. It can be used to modify the manifest, the root - /// container or the list of service factories of the ``Publication``. + /// container or the list of service factories of the `Publication`. /// - warnings: Logger used to broadcast non-fatal parsing warnings. /// - sender: Free object that can be used by reading apps to give some /// UX context when presenting dialogs. @@ -93,7 +93,7 @@ public class PublicationOpener { switch await parser.parse(asset: asset, warnings: warnings) { case var .success(builder): for transform in builderTransforms { - builder.apply(transform) + await builder.apply(transform) } return .success(builder.build()) diff --git a/Sources/Streamer/Toolkit/DataCompression.swift b/Sources/Streamer/Toolkit/DataCompression.swift index 7808a6fa02..e6b93a33ab 100644 --- a/Sources/Streamer/Toolkit/DataCompression.swift +++ b/Sources/Streamer/Toolkit/DataCompression.swift @@ -1,29 +1,35 @@ -/// -/// DataCompression -/// -/// A libcompression wrapper as an extension for the `Data` type -/// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) -/// -/// Created by Markus Wanke, 2016/12/05 -/// - -/// -/// Apache License, Version 2.0 -/// -/// Copyright 2016, Markus Wanke -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +// +// DataCompression +// +// A libcompression wrapper as an extension for the `Data` type +// (GZIP, ZLIB, LZFSE, LZMA, LZ4, deflate, RFC-1950, RFC-1951, RFC-1952) +// +// Created by Markus Wanke, 2016/12/05 +// + +// +// Apache License, Version 2.0 +// +// Copyright 2016, Markus Wanke +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// import Compression import Foundation @@ -210,11 +216,15 @@ public extension Data { } } if has_fname { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_cmmnt { - while pos < limit, ptr[pos] != 0x0 { pos += 1 } + while pos < limit, ptr[pos] != 0x0 { + pos += 1 + } pos += 1 // skip null byte as well } if has_crc16 { @@ -255,7 +265,7 @@ public struct Crc32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::crc32` + /// C convention function pointer type matching the signature of `libz::crc32` private typealias ZLibCrc32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -344,7 +354,7 @@ public struct Adler32: CustomStringConvertible { public init() {} - // C convention function pointer type matching the signature of `libz::adler32` + /// C convention function pointer type matching the signature of `libz::adler32` private typealias ZLibAdler32FuncPtr = @convention(c) ( _ cks: UInt32, _ buf: UnsafePointer, @@ -398,8 +408,7 @@ public struct Adler32: CustomStringConvertible { } private extension Data { - func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType - { + func withUnsafeBytes(_ body: (UnsafePointer) throws -> ResultType) rethrows -> ResultType { try withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> ResultType in try body(rawBufferPointer.bindMemory(to: ContentType.self).baseAddress!) } @@ -419,8 +428,7 @@ private extension Data.CompressionAlgorithm { private typealias Config = (operation: compression_stream_operation, algorithm: compression_algorithm) -private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? -{ +private func perform(_ config: Config, source: UnsafePointer, sourceSize: Int, preload: Data = Data()) -> Data? { guard config.operation == COMPRESSION_STREAM_ENCODE || sourceSize > 0 else { return nil } let streamBase = UnsafeMutablePointer.allocate(capacity: 1) diff --git a/Sources/Streamer/Toolkit/Extensions/Bundle.swift b/Sources/Streamer/Toolkit/Extensions/Bundle.swift index 7220889f6f..c90b400258 100644 --- a/Sources/Streamer/Toolkit/Extensions/Bundle.swift +++ b/Sources/Streamer/Toolkit/Extensions/Bundle.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Sources/Streamer/Toolkit/Extensions/Container.swift b/Sources/Streamer/Toolkit/Extensions/Container.swift index 794e5393bd..9e69dce983 100644 --- a/Sources/Streamer/Toolkit/Extensions/Container.swift +++ b/Sources/Streamer/Toolkit/Extensions/Container.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -57,25 +57,4 @@ extension Container { return .success(entries) } - - /// Guesses a publication title from a list of resource HREFs. - /// - /// If the HREFs contain a single root directory, we assume it is the - /// title. This is often the case for example with CBZ files. - func guessTitle(ignoring: (AnyURL) -> Bool = { _ in false }) -> String? { - var title: String? - - for url in entries { - if ignoring(url) { - continue - } - let segments = url.pathSegments - guard segments.count > 1, title == nil || title == segments.first else { - return nil - } - title = segments.first - } - - return title - } } diff --git a/Sources/Streamer/Toolkit/StringExtension.swift b/Sources/Streamer/Toolkit/StringExtension.swift index d71f03bf66..85395ba131 100644 --- a/Sources/Streamer/Toolkit/StringExtension.swift +++ b/Sources/Streamer/Toolkit/StringExtension.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 97c84c06da..0263d95ee4 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -1,5 +1,5 @@ # XCODEGEN VERSION -2.44.1 +2.45.2 # SPEC { @@ -81,7 +81,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -106,7 +106,7 @@ "target" : "ReadiumLCP" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -120,7 +120,7 @@ "type" : "framework" }, "ReadiumInternal" : { - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -151,7 +151,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -182,7 +182,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -215,7 +215,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -249,7 +249,7 @@ "sdk" : "CoreServices.framework" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -277,7 +277,7 @@ "target" : "ReadiumInternal" } ], - "deploymentTarget" : "13.4", + "deploymentTarget" : "15.0", "platform" : "iOS", "settings" : { "INFOPLIST_FILE" : "Info.plist", @@ -327,12 +327,12 @@ ../../Sources/Internal/Extensions/UInt64.swift ../../Sources/Internal/Extensions/URL.swift ../../Sources/Internal/JSON.swift +../../Sources/Internal/Keychain.swift ../../Sources/Internal/Measure.swift ../../Sources/Internal/UTI.swift ../../Sources/LCP +../../Sources/LCP/.DS_Store ../../Sources/LCP/Authentications -../../Sources/LCP/Authentications/Base.lproj -../../Sources/LCP/Authentications/Base.lproj/LCPDialogViewController.xib ../../Sources/LCP/Authentications/LCPAuthenticating.swift ../../Sources/LCP/Authentications/LCPDialog.swift ../../Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -376,9 +376,17 @@ ../../Sources/LCP/License/Model/Components/LSD/PotentialRights.swift ../../Sources/LCP/License/Model/LicenseDocument.swift ../../Sources/LCP/License/Model/StatusDocument.swift +../../Sources/LCP/Repositories +../../Sources/LCP/Repositories/Keychain +../../Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift +../../Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift ../../Sources/LCP/Resources ../../Sources/LCP/Resources/en.lproj ../../Sources/LCP/Resources/en.lproj/Localizable.strings +../../Sources/LCP/Resources/fr.lproj +../../Sources/LCP/Resources/fr.lproj/Localizable.strings +../../Sources/LCP/Resources/it.lproj +../../Sources/LCP/Resources/it.lproj/Localizable.strings ../../Sources/LCP/Resources/prod-license.lcpl ../../Sources/LCP/Services ../../Sources/LCP/Services/CRLService.swift @@ -389,8 +397,9 @@ ../../Sources/LCP/Toolkit/Bundle.swift ../../Sources/LCP/Toolkit/DataCompression.swift ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift -../../Sources/LCP/Toolkit/Streamable.swift +../../Sources/LCP/Toolkit/ReadResult.swift ../../Sources/Navigator +../../Sources/Navigator/.DS_Store ../../Sources/Navigator/Audiobook ../../Sources/Navigator/Audiobook/AudioNavigator.swift ../../Sources/Navigator/Audiobook/Preferences @@ -407,6 +416,7 @@ ../../Sources/Navigator/DirectionalNavigationAdapter.swift ../../Sources/Navigator/EditingAction.swift ../../Sources/Navigator/EPUB +../../Sources/Navigator/EPUB/.DS_Store ../../Sources/Navigator/EPUB/Assets ../../Sources/Navigator/EPUB/Assets/fxl-spread-one.html ../../Sources/Navigator/EPUB/Assets/fxl-spread-two.html @@ -426,10 +436,14 @@ ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-before.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/cjk-vertical/ReadiumCSS-default.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts -../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA.otf +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Bold.woff2 +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Italic.woff2 +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Regular.woff +../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/AccessibleDfA-Regular.woff2 ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/iAWriterDuospace-Regular.ttf ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/LICENSE-AccessibleDfa ../../Sources/Navigator/EPUB/Assets/Static/readium-css/fonts/LICENSE-IaWriterDuospace.md +../../Sources/Navigator/EPUB/Assets/Static/readium-css/HEAD ../../Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-after.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-before.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/ReadiumCSS-default.css @@ -439,6 +453,8 @@ ../../Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-after.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-before.css ../../Sources/Navigator/EPUB/Assets/Static/readium-css/rtl/ReadiumCSS-default.css +../../Sources/Navigator/EPUB/Assets/Static/readium-css/webPub +../../Sources/Navigator/EPUB/Assets/Static/readium-css/webPub/ReadiumCSS-webPub.css ../../Sources/Navigator/EPUB/Assets/Static/scripts ../../Sources/Navigator/EPUB/Assets/Static/scripts/.gitignore ../../Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed-wrapper-one.js @@ -455,6 +471,7 @@ ../../Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift ../../Sources/Navigator/EPUB/CSS/ReadiumCSS.swift ../../Sources/Navigator/EPUB/DiffableDecoration+HTML.swift +../../Sources/Navigator/EPUB/EPUBExtensions.swift ../../Sources/Navigator/EPUB/EPUBFixedSpreadView.swift ../../Sources/Navigator/EPUB/EPUBNavigatorViewController.swift ../../Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -468,6 +485,7 @@ ../../Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift ../../Sources/Navigator/EPUB/Preferences/EPUBSettings.swift ../../Sources/Navigator/EPUB/Scripts +../../Sources/Navigator/EPUB/Scripts/.DS_Store ../../Sources/Navigator/EPUB/Scripts/.eslintrc.json ../../Sources/Navigator/EPUB/Scripts/.gitignore ../../Sources/Navigator/EPUB/Scripts/.prettierignore @@ -518,6 +536,7 @@ ../../Sources/Navigator/EPUB/Scripts/src/vendor/hypothesis/anchoring/xpath.js ../../Sources/Navigator/EPUB/Scripts/src/vendor/hypothesis/README.md ../../Sources/Navigator/EPUB/Scripts/webpack.config.js +../../Sources/Navigator/EPUB/WebViewServer.swift ../../Sources/Navigator/Input ../../Sources/Navigator/Input/CompositeInputObserver.swift ../../Sources/Navigator/Input/InputObservable.swift @@ -532,6 +551,7 @@ ../../Sources/Navigator/Input/Key/KeyObserver.swift ../../Sources/Navigator/Input/Pointer ../../Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift +../../Sources/Navigator/Input/Pointer/DragPointerObserver.swift ../../Sources/Navigator/Input/Pointer/PointerEvent.swift ../../Sources/Navigator/Navigator.swift ../../Sources/Navigator/PDF @@ -586,6 +606,7 @@ ../../Sources/OPDS/URLHelper.swift ../../Sources/OPDS/XMLNamespace.swift ../../Sources/Shared +../../Sources/Shared/.DS_Store ../../Sources/Shared/Logger ../../Sources/Shared/Logger/Loggable.swift ../../Sources/Shared/Logger/Logger.swift @@ -616,6 +637,8 @@ ../../Sources/Shared/Publication/Extensions/Encryption/Properties+Encryption.swift ../../Sources/Shared/Publication/Extensions/EPUB ../../Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift +../../Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift +../../Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift ../../Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift ../../Sources/Shared/Publication/Extensions/EPUB/Publication+EPUB.swift ../../Sources/Shared/Publication/Extensions/HTML @@ -628,6 +651,9 @@ ../../Sources/Shared/Publication/Extensions/Presentation/Metadata+Presentation.swift ../../Sources/Shared/Publication/Extensions/Presentation/Presentation.swift ../../Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift +../../Sources/Shared/Publication/GuidedNavigation +../../Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift +../../Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift ../../Sources/Shared/Publication/HREFNormalizer.swift ../../Sources/Shared/Publication/Layout.swift ../../Sources/Shared/Publication/Link.swift @@ -636,9 +662,6 @@ ../../Sources/Shared/Publication/Locator.swift ../../Sources/Shared/Publication/Manifest.swift ../../Sources/Shared/Publication/ManifestTransformer.swift -../../Sources/Shared/Publication/Media Overlays -../../Sources/Shared/Publication/Media Overlays/MediaOverlayNode.swift -../../Sources/Shared/Publication/Media Overlays/MediaOverlays.swift ../../Sources/Shared/Publication/Metadata.swift ../../Sources/Shared/Publication/Properties.swift ../../Sources/Shared/Publication/Protection @@ -661,6 +684,9 @@ ../../Sources/Shared/Publication/Services/Cover ../../Sources/Shared/Publication/Services/Cover/CoverService.swift ../../Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +../../Sources/Shared/Publication/Services/Cover/ResourceCoverService.swift +../../Sources/Shared/Publication/Services/GuidedNavigation +../../Sources/Shared/Publication/Services/GuidedNavigation/GuidedNavigationService.swift ../../Sources/Shared/Publication/Services/Locator ../../Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift ../../Sources/Shared/Publication/Services/Locator/LocatorService.swift @@ -678,8 +704,12 @@ ../../Sources/Shared/Publication/Subject.swift ../../Sources/Shared/Publication/TDM.swift ../../Sources/Shared/Resources -../../Sources/Shared/Resources/en-US.lproj -../../Sources/Shared/Resources/en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings +../../Sources/Shared/Resources/en.lproj +../../Sources/Shared/Resources/en.lproj/W3CAccessibilityMetadataDisplayGuide.strings +../../Sources/Shared/Resources/fr.lproj +../../Sources/Shared/Resources/fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings +../../Sources/Shared/Resources/it.lproj +../../Sources/Shared/Resources/it.lproj/W3CAccessibilityMetadataDisplayGuide.strings ../../Sources/Shared/Toolkit ../../Sources/Shared/Toolkit/Archive ../../Sources/Shared/Toolkit/Archive/ArchiveOpener.swift @@ -699,6 +729,7 @@ ../../Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift ../../Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift ../../Sources/Shared/Toolkit/Data/ReadError.swift +../../Sources/Shared/Toolkit/Data/ReadResult.swift ../../Sources/Shared/Toolkit/Data/Resource ../../Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift ../../Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -764,6 +795,7 @@ ../../Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift ../../Sources/Shared/Toolkit/HTTP/HTTPServer.swift ../../Sources/Shared/Toolkit/JSON.swift +../../Sources/Shared/Toolkit/JSONValue.swift ../../Sources/Shared/Toolkit/Language.swift ../../Sources/Shared/Toolkit/Logging ../../Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -780,6 +812,7 @@ ../../Sources/Shared/Toolkit/Tokenizer ../../Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift ../../Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift +../../Sources/Shared/Toolkit/UncheckedSendable.swift ../../Sources/Shared/Toolkit/URL ../../Sources/Shared/Toolkit/URL/Absolute URL ../../Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -807,6 +840,7 @@ ../../Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift ../../Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift ../../Sources/Streamer +../../Sources/Streamer/.DS_Store ../../Sources/Streamer/Assets ../../Sources/Streamer/Assets/fonts ../../Sources/Streamer/Assets/fonts/OpenDyslexic-Regular.otf @@ -835,8 +869,12 @@ ../../Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift ../../Sources/Streamer/Parser/EPUB/Services ../../Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +../../Sources/Streamer/Parser/EPUB/SMIL +../../Sources/Streamer/Parser/EPUB/SMIL/SMILGuidedNavigationService.swift +../../Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift ../../Sources/Streamer/Parser/EPUB/XMLNamespace.swift ../../Sources/Streamer/Parser/Image +../../Sources/Streamer/Parser/Image/ComicInfoParser.swift ../../Sources/Streamer/Parser/Image/ImageParser.swift ../../Sources/Streamer/Parser/PDF ../../Sources/Streamer/Parser/PDF/PDFParser.swift @@ -846,6 +884,7 @@ ../../Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift ../../Sources/Streamer/Parser/PublicationParser.swift ../../Sources/Streamer/Parser/Readium +../../Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift ../../Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift ../../Sources/Streamer/PublicationOpener.swift ../../Sources/Streamer/Toolkit diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index bd1cdf1d49..fd61374794 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 0025BE4D568B560277323B95 /* FileResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05502AD8940D6616D6C386F /* FileResource.swift */; }; + 007A17CE92F8F2CFEBD91534 /* GuidedNavigationDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FC8BFB6FE5B5A18EA696DE /* GuidedNavigationDocument.swift */; }; + 00B5B676FD3C9F72D9B8C34F /* EPUBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC1453275FD41D13096EE97 /* EPUBExtensions.swift */; }; 01AC52ADE389D14F5274CEB2 /* Minizip.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = CFFEBDFE931745C07DACD4A3 /* Minizip.xcframework */; }; 01AD628D6DE82E1C1C4C281D /* NavigationDocumentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AD63CD2A41586290547212 /* NavigationDocumentParser.swift */; }; 01E785BEA7F30AD1C8A5F3DE /* SearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5B029CA09EE1F86A19612A /* SearchService.swift */; }; @@ -29,6 +31,7 @@ 0B9AC6EF44DA518E9F37FB49 /* ContentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E809378D79D09192A0AAE1 /* ContentService.swift */; }; 0BFCDAEC82CFF09AFC53A5D0 /* LCPDFTableOfContentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94414130EC3731CD9920F27D /* LCPDFTableOfContentsService.swift */; }; 0C038E3525BB600EF6815EB9 /* ReadiumFuzi.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */; }; + 0D13BEAB1495151C30D87B41 /* DragPointerObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */; }; 0ECE94F27E005FC454EA9D12 /* DecorableNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626CFFF131E0E840B76428F1 /* DecorableNavigator.swift */; }; 0F1AAB56A6ADEDDE2AD7E41E /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1039900AC78465AD989D7464 /* Content.swift */; }; 1004CE1C72C85CC3702C09C0 /* Asset.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC811653B33761089E270C4A /* Asset.swift */; }; @@ -43,6 +46,7 @@ 14BDA1321CFDC946E840A5E9 /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7214B2366A4E024517FF8C76 /* HTTPRequest.swift */; }; 14CEE9163A37EBD75C93820D /* Locator+Audio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2732AFC91AB15FA09C60207A /* Locator+Audio.swift */; }; 1574884ABA50F0E3A05051B8 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBB57FCAEE605484A7290DBB /* Atomic.swift */; }; + 15CD611A4A806F4A3350AA34 /* ComicInfoParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471465B269CB080E6585E2A /* ComicInfoParser.swift */; }; 1600DB04CEACF97EE8AD9CEE /* URLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258351CE21165EDED7F87878 /* URLProtocol.swift */; }; 17108D46A0353A254DA193B0 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D002FDDAD1A21AC5BB84CE /* Container.swift */; }; 1752D756BED37325D6D4ED29 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; @@ -55,6 +59,7 @@ 1BF9469B4574D30E5C9BB75E /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF859D4933121BDC376CC8A /* Event.swift */; }; 1CB986C7E440F94F264A3567 /* EPUBSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741AE26D76A8C2508437C2D /* EPUBSettings.swift */; }; 1D0B4067739311F6A54240E7 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4598F4671CE7BAE9299BF84B /* UIImage.swift */; }; + 1FB7DAE5EF125B0D05261318 /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */ = {isa = PBXBuildFile; fileRef = A686B5257C30B0EA8087EB31 /* W3CAccessibilityMetadataDisplayGuide.strings */; }; 20D530EDB2B26ADECB4DAE82 /* EPUBDeobfuscator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D785FEFDA202A61E620890 /* EPUBDeobfuscator.swift */; }; 216EA1C1ABA15836D60D910C /* GeneratedCoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */; }; 21B27CD89562506DDC1D62D1 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A5959877EC9688CB0C370E /* Signature.swift */; }; @@ -100,6 +105,7 @@ 38E81FCDABFBB514685402B8 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 342D5C0FEE79A2ABEE24A43E /* CoreServices.framework */; }; 39326587EF76BFD5AD68AED2 /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; }; 39B1DDE3571AB3F3CC6824F4 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA827FC94F5CB3F9032028F /* JSON.swift */; }; + 3AA66AB994F11FC9470C7EDB /* EPUBMediaOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD220F39BD9BBC714F837C4 /* EPUBMediaOverlay.swift */; }; 3AD9E86BB1621CF836919E33 /* ReadiumLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */; }; 3AF8DBD6431F7F5A156FFBCF /* EPUBManifestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C73E1510486CBF553651D60 /* EPUBManifestParser.swift */; }; 3B138483530FDCC545A12D6F /* ZIPFoundationArchiveFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C1E0FDB5373E672D5FF80F /* ZIPFoundationArchiveFactory.swift */; }; @@ -107,7 +113,6 @@ 3BB313823F043BA2C7D7D2F7 /* Locator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7D07E66B7E820D1A509A27 /* Locator.swift */; }; 3C4847FD7D5C5ABCF71A3E7B /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388D85FB7475709CB6CEA59E /* URL.swift */; }; 3CAF13341C4AFE25CBB7B116 /* PDFPreferencesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B80A1477F527C0B2142005E /* PDFPreferencesEditor.swift */; }; - 3D594DCB0A9FA1F50E4B69B3 /* Streamable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739566E777BA37891BCECB95 /* Streamable.swift */; }; 3E195F4601612E7B4B9CB232 /* DirectoryContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48435C1A16C23C5BBB9C590C /* DirectoryContainer.swift */; }; 3E9F244ACDA938D330B9EAEA /* Subject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CD4C99103DC795E44F56AE /* Subject.swift */; }; 3ECB25D3B226C7059D4A922A /* InputObservableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FF49B3B34628DC67CF8255 /* InputObservableViewController.swift */; }; @@ -117,6 +122,7 @@ 44152DBECE34F063AD0E93BC /* Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3E6442F0C7FE2098D71F27 /* Link.swift */; }; 448374F2605586249A6CB4C8 /* FailureResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78FFDF8CF77437EDB41E4547 /* FailureResource.swift */; }; 47125BFFEC67DEB2C3D1B48C /* AudioNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE34D74E282834684E1C999 /* AudioNavigator.swift */; }; + 483307A27A07B086D5FA8500 /* LCPKeychainPassphraseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */; }; 4977279900B4BA602D92B5C4 /* ReadiumFuzi.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */; }; 4A5F53CCC083D3E348379963 /* Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF64D7C05A790D9CA5DD442 /* Types.swift */; }; 4AD286114A634A74BE78B1A0 /* LicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15980B67505AAF10642B56C8 /* LicenseContainer.swift */; }; @@ -131,6 +137,7 @@ 4DAD724BAB72A5C6D2473770 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F89BC365BDD19BE84F4D3B5 /* Collection.swift */; }; 4DB4C10CB9AB5D38C56C1609 /* StringEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB11EA964FBB42D44C3E4A50 /* StringEncoding.swift */; }; 4E84353322A4CDBBCAD6C070 /* TailCachingResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571BBA35C6F496B007C5158C /* TailCachingResource.swift */; }; + 4F5523AB7E92BAA3AD01A88D /* JSONValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3487F36FB4BD0BDA0F12E1 /* JSONValue.swift */; }; 4F9DAB2373AE0B6225D2C589 /* InputObserving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421AE163B6A0248637504A07 /* InputObserving.swift */; }; 4FDA33D3776855A106D40A66 /* AudioPreferencesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C32FDF5E2D35BE71E45ED0 /* AudioPreferencesEditor.swift */; }; 50736D15B35B2C53140A9C14 /* ControlFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BC4119B8937D17ED80B1AB /* ControlFlow.swift */; }; @@ -139,6 +146,7 @@ 5240984F642C951743FB153F /* CBZNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */; }; 540E43EC30EEDDB740ADE046 /* BufferingResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9B7B0A5A1B891BA3D9B9C0 /* BufferingResource.swift */; }; 5591563FD08A956B80C37716 /* XMLFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF20C1D3C33365D25704663 /* XMLFormatSniffer.swift */; }; + 559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */; }; 56A9C67C15BD88FBE576ADF8 /* HTTPProblemDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05E365EBAFDA0CF841F583B /* HTTPProblemDetails.swift */; }; 56CB87DACCA10F737710BFF6 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FF131876FA3A63025F2662 /* Language.swift */; }; 5730E84475195005D1291672 /* Publication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF03272C07D6951ADC1311E /* Publication.swift */; }; @@ -148,6 +156,7 @@ 5912EC9BB073282862F325F2 /* DocumentTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A00FF0C84822A134A353BD4 /* DocumentTypes.swift */; }; 594CE84C2B11169AA0B86615 /* HTMLResourceContentIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34AB954525AC159166C96A36 /* HTMLResourceContentIterator.swift */; }; 5A9AEC4A9AE686ED74E9B8BF /* RWPMFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE4559CE100932572C843E5 /* RWPMFormatSniffer.swift */; }; + 5CA678E3D17B036E6C25BF6A /* GuidedNavigationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210A7C677948BD92A79901CB /* GuidedNavigationService.swift */; }; 5CE009E1F701CF5A02FF637D /* PDFOutlineNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */; }; 5D2820C8209F82051C36A93F /* DataResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C7A5494480CD5A896B1F388 /* DataResource.swift */; }; 5DE027530786CFB542965AC6 /* EPUBLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339637CCF01E665F4CB78B01 /* EPUBLayout.swift */; }; @@ -167,6 +176,7 @@ 674BEEF110667C3051296E9B /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3481F848A616A9A825A4BD /* Double.swift */; }; 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F609C27F073E40D662CFE093 /* PublicationParser.swift */; }; 682DFC1AF2BD7CAE0862B331 /* CryptoSwift.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */; }; + 685388EA258ACA6C80979D85 /* UncheckedSendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49029874507A91EA6141EDD /* UncheckedSendable.swift */; }; 689BD78B3A08D8934F441033 /* FileSystemError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96FD34093B3C3E83827B70C /* FileSystemError.swift */; }; 694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB32E55E1F3CAF1737979CC /* DataCompression.swift */; }; 69AA254E4A39D9B49FDFD648 /* UserKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC96A56AB406203898059B6C /* UserKey.swift */; }; @@ -175,6 +185,7 @@ 6B08C5FB1ABF696CDB6EDB03 /* LCPObservableAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791CEAC3DA5ED971DAE984CB /* LCPObservableAuthentication.swift */; }; 6BE745329D68EE0533E42D14 /* DiffableDecoration+HTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01265194649A8E2A821CC2A4 /* DiffableDecoration+HTML.swift */; }; 6C3C96A32EA2439AAEFD4967 /* ReadiumNavigatorLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBFAC2D57DE7EBB4E2F31BE /* ReadiumNavigatorLocalizedString.swift */; }; + 6CEB7B8167884E863116A1E0 /* LCPKeychainLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */; }; 6D3BCAFF29D91DCA08809D71 /* CRLService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93B0556DAAAF429893B0692 /* CRLService.swift */; }; 6DD5A5F5F08C76DA690FFB41 /* Contributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 456192DBCB3A29ADA9C3CCB9 /* Contributor.swift */; }; 6F01765B4C03EC36C95D02E3 /* CGPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6EB7CAF6D058380A2AB711A /* CGPDF.swift */; }; @@ -190,7 +201,6 @@ 74E94DF537F0DE19706003DA /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BCDFDD5327AB802F0F6460 /* NowPlayingInfo.swift */; }; 75E5BBD405026A68BF6741F9 /* AbsoluteURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */; }; 762CF84C6FB1FCCF03EA91B6 /* Loggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067E58BE65BCB4F8D1E8B911 /* Loggable.swift */; }; - 76F6EE39F504B6A80837C90D /* MediaOverlayNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */; }; 77C828CCC90DF784B2D75774 /* OpdsMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DDB25FC1693613B72DFDB6E /* OpdsMetadata.swift */; }; 78C52EED635B5F8C38A02298 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B24895126F2A744A8E9E61 /* Metadata.swift */; }; 795B476F8BA9A8704E78394A /* AccessibilityMetadataDisplayGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0B58C7A924334D41A7AB1FF /* AccessibilityMetadataDisplayGuide.swift */; }; @@ -204,7 +214,6 @@ 7E456E5AA21BCD712C325B62 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57074892837A37E3BFEDB481 /* String.swift */; }; 7E45E10720EA6B4F18196316 /* Metadata+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */; }; 8029C2773AF704561B09BA99 /* DirectionalNavigationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F7D914B293DF0A912613D2 /* DirectionalNavigationAdapter.swift */; }; - 8066A9FCBA3AA96717A01CFD /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */ = {isa = PBXBuildFile; fileRef = 63AE10E3A29A24DD9C05C1D3 /* W3CAccessibilityMetadataDisplayGuide.strings */; }; 80FACAC721EBA4A11764482C /* EPUBPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA40519A11DDE69CDDBB1C /* EPUBPreferences.swift */; }; 81ADB258F083647221CED24F /* DataCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBC685D4A0E07997088DD2D /* DataCompression.swift */; }; 824B5370C029445F0BE08741 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8930DA1683F200ACE1AFC02A /* Format.swift */; }; @@ -220,6 +229,7 @@ 8CD0D28056D2BBADE170ABF6 /* ContainerLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9234A0351FDE626D8D242223 /* ContainerLicenseContainer.swift */; }; 8D6EFD7710BEB8539E4E64E6 /* DOMRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C084C255A327387F36B97A62 /* DOMRange.swift */; }; 8E25FF2EEFA72D9B0F3025C5 /* PDFFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B72B76AB39E09E4A2E465AF /* PDFFormatSniffer.swift */; }; + 8EBE8665FD1D3BDE5FB3B8B9 /* Metadata+EPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */; }; 8F5B0B5B83BF7F1145556FF8 /* Properties+OPDS.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD79372361D085CA0500CF4 /* Properties+OPDS.swift */; }; 9065C4C0F40B6A5601541EF7 /* Streamable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622CB8B75A568846FECA44D6 /* Streamable.swift */; }; 90CFD62B993F6759716C0AF0 /* LicensesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56286133DD0AE093F2C5E9FD /* LicensesService.swift */; }; @@ -242,13 +252,15 @@ 999EF656A5CDAF3BA30C26EF /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD03AFC9C69E785886FB9620 /* Logger.swift */; }; 9A1877FBEAA0BFC4C74AD3BB /* Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87727AC33D368A88A60A12B9 /* Encryption.swift */; }; 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3230FB63D7ADDD514D74F7E6 /* LCPLicenseRepository.swift */; }; + 9A5AE6CF737280C031231519 /* GuidedNavigationObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE02C0EB77A716286A36152 /* GuidedNavigationObject.swift */; }; 9AF316DF0B1CD4452D785EBC /* KeyModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */; }; 9B0369F8C0187528486440F4 /* CompositeInputObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC8E202B8A16B960AE73CABF /* CompositeInputObserver.swift */; }; 9BC4D1F2958D2F7D7BDB88DA /* CursorList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C361F965E7A7962CA3E4C0BA /* CursorList.swift */; }; + 9C261B735546F26B01C58569 /* SMILGuidedNavigationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA0EFD6B48F11ADF92C4F76 /* SMILGuidedNavigationService.swift */; }; 9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC9ACC1EB3903149EBF21BC0 /* EPUBNavigatorViewModel.swift */; }; - 9E064BC9E99D4F7D8AC3109B /* MediaOverlays.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */; }; 9E6522796719FF1F16C243E7 /* MinizipContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E8D322A523DA324E3E2E59 /* MinizipContainer.swift */; }; 9E76790BAFFF08F0BFEA1BB0 /* DefaultPublicationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5BBF2FA3188DFFCF7B88A75 /* DefaultPublicationParser.swift */; }; + 9E89A68913325E781336977A /* ReadiumGuidedNavigationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11478ACC1A5E05A48B783D1A /* ReadiumGuidedNavigationService.swift */; }; A036CCF310EB7408408FFF00 /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87629BF68F1EDBF06FC0AD54 /* ImageViewController.swift */; }; A040DE6F2D9A6B8D16B063B9 /* SwiftSoup.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE09289EB0FEA5FEC8506B1F /* SwiftSoup.xcframework */; }; A116A1DB98A28C79CFD93EDE /* EditingAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C6D0C5860E802EDA23068C /* EditingAction.swift */; }; @@ -277,7 +289,9 @@ B0B2C38D1A7B36E73C3E3779 /* DifferenceKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F95F3F20D758BE0E7005EA3 /* DifferenceKit.xcframework */; }; B0F62AC136EF3587E147468E /* AssetRetriever.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C054DDC6D1BCF4A420C980C /* AssetRetriever.swift */; }; B1008DFBDE3E33CA552E0E26 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8FCFFDA8AA421435CB40DE /* Optional.swift */; }; + B22857E75D32AF3810D4E074 /* WebViewServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B673858EDF2BBCB316C28CBE /* WebViewServer.swift */; }; B23C740199DCBD23BDF0670F /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2085E9C042F54271D5B9555 /* Container.swift */; }; + B4162DF42D6D45176516A0FF /* SMILParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6968B9B371DF703669D90A9D /* SMILParser.swift */; }; B49522888052E9F41D0DD013 /* ZIPFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AA8BD22E2F5495286C0A1C /* ZIPFormatSniffer.swift */; }; B4D55F234AD2CB5728184346 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F669F31B0B6EC690C48916EC /* Bundle.swift */; }; B57EC602072D4276D502B80D /* AudiobookFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1674CCC0BA8B1F4E2D2B3A4C /* AudiobookFormatSniffer.swift */; }; @@ -310,6 +324,7 @@ C368C73C819F65CE3409D35D /* Fuzi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE34EA8AF2D815F7169CA45 /* Fuzi.swift */; }; C3F4CBE80D741D4158CA8407 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */ = {isa = PBXBuildFile; fileRef = 093629E752DE17264B97C598 /* LCPLicense.swift */; }; + C5D80E7716B243980FD3DFE6 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E1402A31F88054442D58F /* Keychain.swift */; }; C73D876AC0852AE89D6AC3A1 /* ManifestTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D33BD0E923EACCCDB91362C /* ManifestTransformer.swift */; }; C77A30C7519839AA94996549 /* SQLite.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F07214E263C6589987A561F9 /* SQLite.xcframework */; }; C8786D16C8EDCC0AECCA36E4 /* CoverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F0C112656C4786F3861973 /* CoverService.swift */; }; @@ -335,6 +350,7 @@ D5AF26F18E98CEF06AEC0329 /* SQLiteLCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17ACAC3E8F61DA108DCC9F51 /* SQLiteLCPLicenseRepository.swift */; }; D65BEF9DAF1FEB1A5BEED700 /* EPUBParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C234075C7F7573BA54B77D /* EPUBParser.swift */; }; D7B3500D49A188A0D6B5E7CA /* Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A5B12925813FB40C41034 /* Presentation.swift */; }; + D7B6E14061A795365DE89E80 /* ReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C40377AC93BCB71B0C08A6 /* ReadResult.swift */; }; D7FB0CC13190A17DAB7D7DB1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 866AEA533E1F119928F17990 /* Localizable.strings */; }; D81ECD34F39E58E30E7E13B2 /* PublicationCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0A149FC97C747F55F6463C /* PublicationCollection.swift */; }; D84BF71D6840FE62D7701073 /* JSONFormatSniffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8A0C50FD8E808C7A9F4D87 /* JSONFormatSniffer.swift */; }; @@ -346,6 +362,7 @@ DD04CA793E06BBAD6A75329F /* EPUBPreferences+Legacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF31AEFB5FF0E7892C6D903E /* EPUBPreferences+Legacy.swift */; }; DD8E2E0D394399A51F295380 /* Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF92954C8C8C3EC50C835CBA /* Link.swift */; }; DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529B55BE6996FCDC1082BF0A /* JSON.swift */; }; + DEC70D5DEEB5AE9F5A4183E4 /* ReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A10B4EF2A391FCE83C3FBCC /* ReadResult.swift */; }; DEF1AA526DDAF2D5EA3A6594 /* FileURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA3AA272772FCF7D6268A74 /* FileURL.swift */; }; DEF375FF574461E670FF45B9 /* ProgressionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED4C26FFA10656866E167F4 /* ProgressionStrategy.swift */; }; DEF5B692764428697150AA8A /* XMLNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 364F18D0F750A1D98A381654 /* XMLNamespace.swift */; }; @@ -373,7 +390,6 @@ ED67A0EFAE830F72846BF9C0 /* Range.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231F989F7D7E560DD5364B9 /* Range.swift */; }; EDDAB394E312B7A7AE5BB758 /* Publication+OPDS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB5D42EEF0083D833E2A572 /* Publication+OPDS.swift */; }; EE16BE486539BC9B3C3C6896 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; - EE951A131E38E316BF7A1129 /* LCPDialogViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = ED5C6546C24E5E619E4CC9D1 /* LCPDialogViewController.xib */; }; EF15E9163EBC82672B22F6E0 /* ImageParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37087C0D0B36FE7F20F1C891 /* ImageParser.swift */; }; EF26968E6A2087142F5334AF /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C94659A8749299DBE3628D /* HTTPClient.swift */; }; EF2BE6AFC79525FD9760CD9B /* OPDSPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C22408FE1FA81400DE8D5F7 /* OPDSPrice.swift */; }; @@ -506,6 +522,8 @@ 06C4BDFF128C774BCD660419 /* ReadiumCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumCSS.swift; sourceTree = ""; }; 07B5469E40752E598C070E5B /* OPDSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = ""; }; 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifiers.swift; sourceTree = ""; }; + 0885992D0F70AD0B493985CE /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 08FC8BFB6FE5B5A18EA696DE /* GuidedNavigationDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedNavigationDocument.swift; sourceTree = ""; }; 0918DA360AAB646144E435D5 /* TransformingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransformingContainer.swift; sourceTree = ""; }; 093629E752DE17264B97C598 /* LCPLicense.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLicense.swift; sourceTree = ""; }; 0977FA3A6BDEDE2F91A7C444 /* BitmapFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitmapFormatSniffer.swift; sourceTree = ""; }; @@ -519,6 +537,7 @@ 103E0171A3CDEFA1B1F1F180 /* WKWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKWebView.swift; sourceTree = ""; }; 10894CC9684584098A22D8FA /* URLExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensions.swift; sourceTree = ""; }; 10FB29EDCCE5910C869295F1 /* Either.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Either.swift; sourceTree = ""; }; + 11478ACC1A5E05A48B783D1A /* ReadiumGuidedNavigationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumGuidedNavigationService.swift; sourceTree = ""; }; 11EC0100045C12EDDFE694E8 /* PreferencesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesEditor.swift; sourceTree = ""; }; 125BAF5FDFA097BA5CC63539 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 13C2DC41BEDB70085FCBBC00 /* PDFPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFPreferences.swift; sourceTree = ""; }; @@ -534,17 +553,21 @@ 1EBC685D4A0E07997088DD2D /* DataCompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = ""; }; 1F89BC365BDD19BE84F4D3B5 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 1FBFAC2D57DE7EBB4E2F31BE /* ReadiumNavigatorLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumNavigatorLocalizedString.swift; sourceTree = ""; }; + 210A7C677948BD92A79901CB /* GuidedNavigationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedNavigationService.swift; sourceTree = ""; }; 211A98219D7D1583E829DEC2 /* Layout+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Layout+EPUB.swift"; sourceTree = ""; }; 212E89D9F2CC639C3E1F81C3 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 218BE3110D2886B252A769A2 /* UTI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTI.swift; sourceTree = ""; }; 21944E1DABB61C2CF2EA89C5 /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; 230985A228FA74F24735D6BB /* LCPRenewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPRenewDelegate.swift; sourceTree = ""; }; 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBZNavigatorViewController.swift; sourceTree = ""; }; + 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPointerObserver.swift; sourceTree = ""; }; 251275D0DF87F85158A5FEA9 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets; path = ../../Sources/Navigator/EPUB/Assets; sourceTree = SOURCE_ROOT; }; 258351CE21165EDED7F87878 /* URLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocol.swift; sourceTree = ""; }; 2732AFC91AB15FA09C60207A /* Locator+Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+Audio.swift"; sourceTree = ""; }; + 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+EPUB.swift"; sourceTree = ""; }; 2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumFuzi.xcframework; path = ../../Carthage/Build/ReadiumFuzi.xcframework; sourceTree = ""; }; 28792F801221D49F61B92CF8 /* TDM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDM.swift; sourceTree = ""; }; + 28C40377AC93BCB71B0C08A6 /* ReadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadResult.swift; sourceTree = ""; }; 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Archive.swift"; sourceTree = ""; }; 29AD63CD2A41586290547212 /* NavigationDocumentParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDocumentParser.swift; sourceTree = ""; }; 2AF56CF04F94B7BE45631897 /* LCPContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPContentProtection.swift; sourceTree = ""; }; @@ -599,7 +622,9 @@ 47B9196192A22B8AB80E6B2F /* LCPDFPositionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPDFPositionsService.swift; sourceTree = ""; }; 48435C1A16C23C5BBB9C590C /* DirectoryContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryContainer.swift; sourceTree = ""; }; 48856E9AB402E2907B5230F3 /* CGRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGRect.swift; sourceTree = ""; }; + 491E1402A31F88054442D58F /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 4944D2DB99CC59F945FDA2CA /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + 4AD220F39BD9BBC714F837C4 /* EPUBMediaOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBMediaOverlay.swift; sourceTree = ""; }; 4BB5D42EEF0083D833E2A572 /* Publication+OPDS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publication+OPDS.swift"; sourceTree = ""; }; 4BCDF341872EEFB88B6674DE /* HTTPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPServer.swift; sourceTree = ""; }; 4BF38F71FDEC1920325B62D3 /* PublicationContentIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationContentIterator.swift; sourceTree = ""; }; @@ -613,6 +638,7 @@ 5124A0F95B52BA336E07C3D3 /* RelativeURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeURL.swift; sourceTree = ""; }; 529B55BE6996FCDC1082BF0A /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5380F05215D8ED61B97F8021 /* PublicationOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationOpener.swift; sourceTree = ""; }; + 538FDA65FCB39F10BF9C8BC0 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; 53DAB92EBBB8031CA66B1E6F /* ReadiumAdapterLCPSQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumAdapterLCPSQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5420CABB4B38006F64160F49 /* AccessibilityDisplayString+Generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessibilityDisplayString+Generated.swift"; sourceTree = ""; }; 54699BC0E00F327E67908F6A /* Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Encryption.swift; sourceTree = ""; }; @@ -633,7 +659,6 @@ 616C70674FBF020FE4607617 /* DeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceService.swift; sourceTree = ""; }; 622CB8B75A568846FECA44D6 /* Streamable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Streamable.swift; sourceTree = ""; }; 626CFFF131E0E840B76428F1 /* DecorableNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecorableNavigator.swift; sourceTree = ""; }; - 63AE10E3A29A24DD9C05C1D3 /* W3CAccessibilityMetadataDisplayGuide.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = W3CAccessibilityMetadataDisplayGuide.strings; path = "en-US.lproj/W3CAccessibilityMetadataDisplayGuide.strings"; sourceTree = ""; }; 64ED7629E73022C1495081D1 /* Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Links.swift; sourceTree = ""; }; 6536C07F5A50F7F25FDBF69C /* ReadiumGCDWebServer.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumGCDWebServer.xcframework; path = ../../Carthage/Build/ReadiumGCDWebServer.xcframework; sourceTree = ""; }; 65C8719E9CC8EF0D2430AD85 /* CompletionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionList.swift; sourceTree = ""; }; @@ -646,6 +671,7 @@ 68D2804AD0439307575B3073 /* MappedPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MappedPreference.swift; sourceTree = ""; }; 68FF131876FA3A63025F2662 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; 69212974E62EF509BC1F0C7A /* UnknownAbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownAbsoluteURL.swift; sourceTree = ""; }; + 6968B9B371DF703669D90A9D /* SMILParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILParser.swift; sourceTree = ""; }; 69E17C4870C64264819EB227 /* ReadiumZIPFoundation.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumZIPFoundation.xcframework; path = ../../Carthage/Build/ReadiumZIPFoundation.xcframework; sourceTree = ""; }; 6BC71BAFF7A20D7903E6EE4D /* Properties+EPUB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+EPUB.swift"; sourceTree = ""; }; 6D80848AADD20D4384D9AF59 /* HTMLFontFamilyDeclaration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFontFamilyDeclaration.swift; sourceTree = ""; }; @@ -653,9 +679,7 @@ 714F1696AC76F6AFEA1924D5 /* NSRegularExpression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSRegularExpression.swift; sourceTree = ""; }; 7214B2366A4E024517FF8C76 /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = ""; }; 72922E22040CEFB3B7BBCDAF /* LoggerStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerStub.swift; sourceTree = ""; }; - 739566E777BA37891BCECB95 /* Streamable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Streamable.swift; sourceTree = ""; }; 74F646B746EB27124F9456F8 /* ReadingProgression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgression.swift; sourceTree = ""; }; - 75DFA22C741A09C81E23D084 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LCPDialogViewController.xib; sourceTree = ""; }; 761D7DFCF307078B7283A14E /* TextTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTokenizer.swift; sourceTree = ""; }; 76638D3D1220E4C2620B9A80 /* Properties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Properties.swift; sourceTree = ""; }; 76E46B10FD5B26A2F41718E0 /* EPUBMetadataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBMetadataParser.swift; sourceTree = ""; }; @@ -667,17 +691,20 @@ 78FFDF8CF77437EDB41E4547 /* FailureResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailureResource.swift; sourceTree = ""; }; 791CEAC3DA5ED971DAE984CB /* LCPObservableAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPObservableAuthentication.swift; sourceTree = ""; }; 79CE7AFF3ECCD705A80685BB /* MinizipArchiveOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinizipArchiveOpener.swift; sourceTree = ""; }; + 7A10B4EF2A391FCE83C3FBCC /* ReadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadResult.swift; sourceTree = ""; }; 7BB152578CBA091A41A51B25 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; 7BBD54FD376456C1925316BC /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; 7C2787EBE9D5565DA8593711 /* Properties+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Presentation.swift"; sourceTree = ""; }; 7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocatorService.swift; sourceTree = ""; }; 7C9B7B0A5A1B891BA3D9B9C0 /* BufferingResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferingResource.swift; sourceTree = ""; }; 7CDE839ECF121D2EDD0C31C7 /* InputObservingGestureRecognizerAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputObservingGestureRecognizerAdapter.swift; sourceTree = ""; }; + 7E14E1BA1A6B15BBC1C19296 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPublicationManifestAugmentor.swift; sourceTree = ""; }; 7FCA24A94D6376487FECAEF1 /* LCPPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPPassphraseRepository.swift; sourceTree = ""; }; 819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = ""; }; 8240F845F35439807CE8AF65 /* ContentProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProtectionService.swift; sourceTree = ""; }; 8456BF3665A9B9C0AE4CC158 /* Locator+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+HTML.swift"; sourceTree = ""; }; + 8471465B269CB080E6585E2A /* ComicInfoParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComicInfoParser.swift; sourceTree = ""; }; 85F7D914B293DF0A912613D2 /* DirectionalNavigationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionalNavigationAdapter.swift; sourceTree = ""; }; 868A38C213F1D0BAF276CF97 /* SingleResourceContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleResourceContainer.swift; sourceTree = ""; }; 87629BF68F1EDBF06FC0AD54 /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; @@ -688,6 +715,7 @@ 89F56CFDC7E9254E99561152 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; 8A00FF0C84822A134A353BD4 /* DocumentTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTypes.swift; sourceTree = ""; }; 8AB3B86AB42261727B2811CF /* HTMLDecorationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLDecorationTemplate.swift; sourceTree = ""; }; + 8AC1453275FD41D13096EE97 /* EPUBExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBExtensions.swift; sourceTree = ""; }; 8B6A5B12925813FB40C41034 /* Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presentation.swift; sourceTree = ""; }; 8B72B76AB39E09E4A2E465AF /* PDFFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFFormatSniffer.swift; sourceTree = ""; }; 8BF64D7C05A790D9CA5DD442 /* Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Types.swift; sourceTree = ""; }; @@ -695,6 +723,7 @@ 8DC02496961068F28D1B2A52 /* Key.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Key.swift; sourceTree = ""; }; 8EA0008AF1B9B97962824D85 /* FallbackContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackContentProtection.swift; sourceTree = ""; }; 8F485F9F15CF41925D2D3D5C /* ActivatePointerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivatePointerObserver.swift; sourceTree = ""; }; + 8FC5570EB4530B7BD60A6A88 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 91F34B9B08BC6FB84CE54A26 /* LCPProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPProgress.swift; sourceTree = ""; }; 9234A0351FDE626D8D242223 /* ContainerLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerLicenseContainer.swift; sourceTree = ""; }; 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedCoverService.swift; sourceTree = ""; }; @@ -715,6 +744,7 @@ 9C2F9F4D29EBDE812891418F /* HTTPURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPURL.swift; sourceTree = ""; }; 9D2B24C3150D502382AAC939 /* ReadiumStreamer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumStreamer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9D8FCFFDA8AA421435CB40DE /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; + 9DA0EFD6B48F11ADF92C4F76 /* SMILGuidedNavigationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMILGuidedNavigationService.swift; sourceTree = ""; }; 9DDB25FC1693613B72DFDB6E /* OpdsMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpdsMetadata.swift; sourceTree = ""; }; 9E3543F628B017E9BF65DD08 /* StringSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringSearchService.swift; sourceTree = ""; }; 9EA3A43B7709F7539F9410CD /* PaginationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationView.swift; sourceTree = ""; }; @@ -741,6 +771,7 @@ AB528B8FB604E0953E345D26 /* Range.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Range.swift; sourceTree = ""; }; ABAF1D0444B94E2CDD80087D /* PDFKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFKit.swift; sourceTree = ""; }; AC150FB45A4AB33AF516AE09 /* DefaultArchiveOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultArchiveOpener.swift; sourceTree = ""; }; + AC3487F36FB4BD0BDA0F12E1 /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = ""; }; AC811653B33761089E270C4A /* Asset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asset.swift; sourceTree = ""; }; AC8639886BD43362741AADD0 /* HREFNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HREFNormalizer.swift; sourceTree = ""; }; ACB32E55E1F3CAF1737979CC /* DataCompression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = ""; }; @@ -751,6 +782,8 @@ B22A0E76866F626D79F0A64C /* SQLiteLCPPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteLCPPassphraseRepository.swift; sourceTree = ""; }; B53B841C2F5A59BA3B161258 /* Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; B5CE464C519852D38F873ADB /* PotentialRights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialRights.swift; sourceTree = ""; }; + B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPKeychainLicenseRepository.swift; sourceTree = ""; }; + B673858EDF2BBCB316C28CBE /* WebViewServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewServer.swift; sourceTree = ""; }; B7457AD096857CA307F6ED6A /* InputObservable+Legacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InputObservable+Legacy.swift"; sourceTree = ""; }; B7C9D54352714641A87F64A0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; BAA7CEF568A02BA2CB4AAD7F /* OPDSFormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSFormatSniffer.swift; sourceTree = ""; }; @@ -781,7 +814,9 @@ C96FD34093B3C3E83827B70C /* FileSystemError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemError.swift; sourceTree = ""; }; CAD79372361D085CA0500CF4 /* Properties+OPDS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+OPDS.swift"; sourceTree = ""; }; CBB57FCAEE605484A7290DBB /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + CBE02C0EB77A716286A36152 /* GuidedNavigationObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidedNavigationObject.swift; sourceTree = ""; }; CC925E451D875E5F74748EDC /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; + CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPKeychainPassphraseRepository.swift; sourceTree = ""; }; CDA8111A330AB4D7187DD743 /* LocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocatorService.swift; sourceTree = ""; }; CF31AEFB5FF0E7892C6D903E /* EPUBPreferences+Legacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EPUBPreferences+Legacy.swift"; sourceTree = ""; }; CFE1142A6C038A35C527CE84 /* URITemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URITemplate.swift; sourceTree = ""; }; @@ -820,17 +855,18 @@ E0B58C7A924334D41A7AB1FF /* AccessibilityMetadataDisplayGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityMetadataDisplayGuide.swift; sourceTree = ""; }; E0E6147EF790DE532CE1699D /* CSSProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSProperties.swift; sourceTree = ""; }; E19D31097B3A8050A46CDAA5 /* URLHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHelper.swift; sourceTree = ""; }; - E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlayNode.swift; sourceTree = ""; }; E1FB533E84CE563807BDB012 /* FormatSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatSniffer.swift; sourceTree = ""; }; E20ED98539825B35F64D8262 /* InputObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputObservable.swift; sourceTree = ""; }; E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBSpreadView.swift; sourceTree = ""; }; E2D5DCD95C7B908BB6CA77C8 /* ResourceLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLicenseContainer.swift; sourceTree = ""; }; E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = CryptoSwift.xcframework; path = ../../Carthage/Build/CryptoSwift.xcframework; sourceTree = ""; }; + E49029874507A91EA6141EDD /* UncheckedSendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UncheckedSendable.swift; sourceTree = ""; }; E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFOutlineNode.swift; sourceTree = ""; }; E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsoluteURL.swift; sourceTree = ""; }; E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = ""; }; E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumWebPubParser.swift; sourceTree = ""; }; E7D002FDDAD1A21AC5BB84CE /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; + E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCoverService.swift; sourceTree = ""; }; EC329362A0E8AC6CC018452A /* ReadiumOPDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumOPDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Presentation.swift"; sourceTree = ""; }; EC5ED9E15482AED288A6634F /* EPUBNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBNavigatorViewController.swift; sourceTree = ""; }; @@ -844,8 +880,8 @@ EED4C26FFA10656866E167F4 /* ProgressionStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressionStrategy.swift; sourceTree = ""; }; EF99DAF66659A218CEC25EAE /* EPUBFixedSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFixedSpreadView.swift; sourceTree = ""; }; F07214E263C6589987A561F9 /* SQLite.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SQLite.xcframework; path = ../../Carthage/Build/SQLite.xcframework; sourceTree = ""; }; - F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlays.swift; sourceTree = ""; }; F2E780027410F4B6CC872B3D /* OPDSAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSAvailability.swift; sourceTree = ""; }; + F46CAAA92BFBFCCC24AD324A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; F4FC8F971F00B5876803B62A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; F5C6D0C5860E802EDA23068C /* EditingAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditingAction.swift; sourceTree = ""; }; F5DA40519A11DDE69CDDBB1C /* EPUBPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBPreferences.swift; sourceTree = ""; }; @@ -1014,6 +1050,7 @@ isa = PBXGroup; children = ( C0453DB464236F684621BEA7 /* ReadError.swift */, + 7A10B4EF2A391FCE83C3FBCC /* ReadResult.swift */, 622CB8B75A568846FECA44D6 /* Streamable.swift */, 582C2D4E376AE5490527B94F /* Asset */, 96BD1423AB18BDEEC9A0388F /* Container */, @@ -1065,6 +1102,7 @@ isa = PBXGroup; children = ( 01265194649A8E2A821CC2A4 /* DiffableDecoration+HTML.swift */, + 8AC1453275FD41D13096EE97 /* EPUBExtensions.swift */, EF99DAF66659A218CEC25EAE /* EPUBFixedSpreadView.swift */, EC5ED9E15482AED288A6634F /* EPUBNavigatorViewController.swift */, EC9ACC1EB3903149EBF21BC0 /* EPUBNavigatorViewModel.swift */, @@ -1072,6 +1110,7 @@ 98D8CC7BC117BBFB206D01CC /* EPUBSpread.swift */, E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */, 8AB3B86AB42261727B2811CF /* HTMLDecorationTemplate.swift */, + B673858EDF2BBCB316C28CBE /* WebViewServer.swift */, 01819314A9C8B44F7EF6EC7D /* CSS */, AC7E4A4E70D2E94C04BB1366 /* Preferences */, ); @@ -1248,6 +1287,7 @@ children = ( A4F0C112656C4786F3861973 /* CoverService.swift */, 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */, + E9F1EDAAC134C8E7F0EFE738 /* ResourceCoverService.swift */, ); path = Cover; sourceTree = ""; @@ -1321,6 +1361,7 @@ 47FCB3FC286AD1053854444A /* Content */, A4A409DF92515874F2F0DF6B /* Content Protection */, 3723879A352B0300CCC0006E /* Cover */, + B72442C0FC8CBF11B86B1C5D /* GuidedNavigation */, 3118D7E15D685347720A0651 /* Locator */, 5BC52D8F4F854FDA56D10A8E /* Positions */, F818D082B369A3D4BE617D46 /* Search */, @@ -1361,6 +1402,7 @@ isa = PBXGroup; children = ( 529B55BE6996FCDC1082BF0A /* JSON.swift */, + 491E1402A31F88054442D58F /* Keychain.swift */, 4FEE01FAD273864D0908C358 /* Measure.swift */, 218BE3110D2886B252A769A2 /* UTI.swift */, 40D18A37080F5B1D114CE2E1 /* Extensions */, @@ -1452,6 +1494,7 @@ 00753735EE68A5BCEF35D787 /* Extensions */, 5EC23231F487889071301718 /* Resource Transformers */, 36AE60D6C483E1F4BBE7AAE0 /* Services */, + B4C160CA74CFBD0073F81A57 /* SMIL */, ); path = EPUB; sourceTree = ""; @@ -1476,10 +1519,19 @@ path = "Absolute URL"; sourceTree = ""; }; + 6558D30FDB081E512115E361 /* GuidedNavigation */ = { + isa = PBXGroup; + children = ( + 08FC8BFB6FE5B5A18EA696DE /* GuidedNavigationDocument.swift */, + CBE02C0EB77A716286A36152 /* GuidedNavigationObject.swift */, + ); + path = GuidedNavigation; + sourceTree = ""; + }; 699E0FDF48F79D5EEACE0436 /* Resources */ = { isa = PBXGroup; children = ( - 63AE10E3A29A24DD9C05C1D3 /* W3CAccessibilityMetadataDisplayGuide.strings */, + A686B5257C30B0EA8087EB31 /* W3CAccessibilityMetadataDisplayGuide.strings */, ); path = Resources; sourceTree = ""; @@ -1532,6 +1584,14 @@ path = XML; sourceTree = ""; }; + 747E6C3EE93B2F8240970E94 /* Repositories */ = { + isa = PBXGroup; + children = ( + D0B5292E979486B3745DC1BD /* Keychain */, + ); + path = Repositories; + sourceTree = ""; + }; 75C5238287B0D2F1DF6889DB /* Protection */ = { isa = PBXGroup; children = ( @@ -1571,7 +1631,6 @@ A02248F01B66C1151614EF15 /* LCPDialog.swift */, 77392C999C0EFF83C8F2A47F /* LCPDialogAuthentication.swift */, 0BB64178365BFA9ED75C7078 /* LCPDialogViewController.swift */, - ED5C6546C24E5E619E4CC9D1 /* LCPDialogViewController.xib */, 791CEAC3DA5ED971DAE984CB /* LCPObservableAuthentication.swift */, 1D5053C2151DDDE4E8F06513 /* LCPPassphraseAuthentication.swift */, ); @@ -1770,14 +1829,32 @@ F64FBE3CA5C1B0C73A22E86D /* Bundle.swift */, 1EBC685D4A0E07997088DD2D /* DataCompression.swift */, 9883F57707AC488197F4312E /* ReadiumLCPLocalizedString.swift */, - 739566E777BA37891BCECB95 /* Streamable.swift */, + 28C40377AC93BCB71B0C08A6 /* ReadResult.swift */, ); path = Toolkit; sourceTree = ""; }; + B4C160CA74CFBD0073F81A57 /* SMIL */ = { + isa = PBXGroup; + children = ( + 9DA0EFD6B48F11ADF92C4F76 /* SMILGuidedNavigationService.swift */, + 6968B9B371DF703669D90A9D /* SMILParser.swift */, + ); + path = SMIL; + sourceTree = ""; + }; + B72442C0FC8CBF11B86B1C5D /* GuidedNavigation */ = { + isa = PBXGroup; + children = ( + 210A7C677948BD92A79901CB /* GuidedNavigationService.swift */, + ); + path = GuidedNavigation; + sourceTree = ""; + }; B74FB52A54096777BE883182 /* Readium */ = { isa = PBXGroup; children = ( + 11478ACC1A5E05A48B783D1A /* ReadiumGuidedNavigationService.swift */, E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */, ); path = Readium; @@ -1821,15 +1898,6 @@ path = ../../Sources/Adapters/LCPSQLite; sourceTree = ""; }; - C1002695D860AE505D689C26 /* Media Overlays */ = { - isa = PBXGroup; - children = ( - E1DAAE19E8372F6ECF772E0A /* MediaOverlayNode.swift */, - F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */, - ); - path = "Media Overlays"; - sourceTree = ""; - }; C42B511253C3D9C6DA8AA5CC /* Toolkit */ = { isa = PBXGroup; children = ( @@ -1842,9 +1910,11 @@ 10FB29EDCCE5910C869295F1 /* Either.swift */, D555435E2BADB2B877FD50C7 /* FileExtension.swift */, EDA827FC94F5CB3F9032028F /* JSON.swift */, + AC3487F36FB4BD0BDA0F12E1 /* JSONValue.swift */, 68FF131876FA3A63025F2662 /* Language.swift */, 5BC6AE42A31D77B548CB0BB4 /* Observable.swift */, 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */, + E49029874507A91EA6141EDD /* UncheckedSendable.swift */, EE7B762C97CFC214997EC677 /* Weak.swift */, 371E5D46DEBBE58A793B2546 /* Archive */, 0B06420A6651D6D94BE937F3 /* Data */, @@ -1887,6 +1957,15 @@ path = Parser; sourceTree = ""; }; + D0B5292E979486B3745DC1BD /* Keychain */ = { + isa = PBXGroup; + children = ( + B65A22BA2FF8230955BC7C06 /* LCPKeychainLicenseRepository.swift */, + CCD5904F9B9E29E2C1CA1CB5 /* LCPKeychainPassphraseRepository.swift */, + ); + path = Keychain; + sourceTree = ""; + }; D4358DF9D15D9ADE4F9E8BE4 /* LCP */ = { isa = PBXGroup; children = ( @@ -1902,6 +1981,7 @@ 7F42F058A2DC364B554BF7F2 /* Authentications */, F9064CEF2968AEDCDCCFD399 /* Content Protection */, 2C4C6FBF69B19C83DFCCF835 /* License */, + 747E6C3EE93B2F8240970E94 /* Repositories */, F389B1290B1CAA8E5F65573B /* Resources */, 11502B18FA9A9C92352052CE /* Services */, B25D1AE9818E91E1D1497ABB /* Toolkit */, @@ -1956,6 +2036,7 @@ E830BA9857151F8B2BA9705D /* Image */ = { isa = PBXGroup; children = ( + 8471465B269CB080E6585E2A /* ComicInfoParser.swift */, 37087C0D0B36FE7F20F1C891 /* ImageParser.swift */, ); path = Image; @@ -2026,7 +2107,7 @@ 28792F801221D49F61B92CF8 /* TDM.swift */, AD3EFEE43B6E256F6AFB1F53 /* Accessibility */, 055166DFDEE6C6A17D04D42D /* Extensions */, - C1002695D860AE505D689C26 /* Media Overlays */, + 6558D30FDB081E512115E361 /* GuidedNavigation */, 75C5238287B0D2F1DF6889DB /* Protection */, 4898F65BFF048F7966C82B74 /* Services */, ); @@ -2050,6 +2131,7 @@ isa = PBXGroup; children = ( 8F485F9F15CF41925D2D3D5C /* ActivatePointerObserver.swift */, + 24FF3141286A0CF40643D32D /* DragPointerObserver.swift */, F76073E8E6DACE7F9D22E0DD /* PointerEvent.swift */, ); path = Pointer; @@ -2058,8 +2140,8 @@ F389B1290B1CAA8E5F65573B /* Resources */ = { isa = PBXGroup; children = ( - 866AEA533E1F119928F17990 /* Localizable.strings */, 9BD31F314E7B3A61C55635E5 /* prod-license.lcpl */, + 866AEA533E1F119928F17990 /* Localizable.strings */, ); path = Resources; sourceTree = ""; @@ -2106,6 +2188,8 @@ isa = PBXGroup; children = ( 339637CCF01E665F4CB78B01 /* EPUBLayout.swift */, + 4AD220F39BD9BBC714F837C4 /* EPUBMediaOverlay.swift */, + 281F650E9B9CEE601D8125EE /* Metadata+EPUB.swift */, 6BC71BAFF7A20D7903E6EE4D /* Properties+EPUB.swift */, 508E0CD4F9F02CC851E6D1E1 /* Publication+EPUB.swift */, ); @@ -2292,19 +2376,22 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1250; + TargetAttributes = { + }; }; buildConfigurationList = 5A872BCD95ECE5673BC89051 /* Build configuration list for PBXProject "Readium" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( Base, en, - "en-US", + fr, + it, ); mainGroup = 2C63ECC3CC1230CCA416F55F; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; + productRefGroup = AE0099F78A65150DDA19FF5A /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -2325,7 +2412,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - EE951A131E38E316BF7A1129 /* LCPDialogViewController.xib in Resources */, D7FB0CC13190A17DAB7D7DB1 /* Localizable.strings in Resources */, F96C29471F3EF0CEE568AA53 /* prod-license.lcpl in Resources */, ); @@ -2352,7 +2438,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8066A9FCBA3AA96717A01CFD /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */, + 1FB7DAE5EF125B0D05261318 /* W3CAccessibilityMetadataDisplayGuide.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2391,6 +2477,8 @@ 6BE745329D68EE0533E42D14 /* DiffableDecoration+HTML.swift in Sources */, A2B9CE5A5A7F999B4D849C1F /* DiffableDecoration.swift in Sources */, 8029C2773AF704561B09BA99 /* DirectionalNavigationAdapter.swift in Sources */, + 0D13BEAB1495151C30D87B41 /* DragPointerObserver.swift in Sources */, + 00B5B676FD3C9F72D9B8C34F /* EPUBExtensions.swift in Sources */, 2E518C960D386F13E0A5E9B7 /* EPUBFixedSpreadView.swift in Sources */, B912ABB7DE8FC1A7A8EC1D84 /* EPUBNavigatorViewController.swift in Sources */, 9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */, @@ -2447,6 +2535,7 @@ B96E8865DCA4A0CEFDA24DDF /* VisualNavigator.swift in Sources */, 6F042D80A0E07C285E006678 /* WKWebView.swift in Sources */, 7305815B0C701A4E9BA2DF7C /* WebView.swift in Sources */, + B22857E75D32AF3810D4E074 /* WebViewServer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2458,6 +2547,7 @@ 57583D27AB12063C3D114A47 /* AudioParser.swift in Sources */, A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */, 61BBCC98965E362FA840DBB8 /* Bundle.swift in Sources */, + 15CD611A4A806F4A3350AA34 /* ComicInfoParser.swift in Sources */, D8B99CCB17F5E71AB3F7CE84 /* CompositePublicationParser.swift in Sources */, 17108D46A0353A254DA193B0 /* Container.swift in Sources */, 694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */, @@ -2482,7 +2572,10 @@ E5D440B49453AC615946E7FB /* PDFPositionsService.swift in Sources */, 2F8F8B6A05F8E124BA9D6B22 /* PublicationOpener.swift in Sources */, 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */, + 9E89A68913325E781336977A /* ReadiumGuidedNavigationService.swift in Sources */, C9FBD23E459FB395377E149E /* ReadiumWebPubParser.swift in Sources */, + 9C261B735546F26B01C58569 /* SMILGuidedNavigationService.swift in Sources */, + B4162DF42D6D45176516A0FF /* SMILParser.swift in Sources */, 4AE70F783C07D9938B40E792 /* StringExtension.swift in Sources */, DEF5B692764428697150AA8A /* XMLNamespace.swift in Sources */, ); @@ -2499,6 +2592,7 @@ 330690F62A5F240B77A14337 /* Date+ISO8601.swift in Sources */, 674BEEF110667C3051296E9B /* Double.swift in Sources */, DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */, + C5D80E7716B243980FD3DFE6 /* Keychain.swift in Sources */, E8C3B837B9FB2ABCB5F82380 /* Measure.swift in Sources */, F631EA324143E669070523F3 /* NSRegularExpression.swift in Sources */, 606EBE8AC2096BC681F92908 /* Number.swift in Sources */, @@ -2547,6 +2641,8 @@ B066F9DDCD00A8917478CB6C /* LCPDialogViewController.swift in Sources */, 25349166318EB00EE8A0765C /* LCPError+wrap.swift in Sources */, 98702AFB56F9C50F7246CDDA /* LCPError.swift in Sources */, + 6CEB7B8167884E863116A1E0 /* LCPKeychainLicenseRepository.swift in Sources */, + 483307A27A07B086D5FA8500 /* LCPKeychainPassphraseRepository.swift in Sources */, C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */, 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */, 6B08C5FB1ABF696CDB6EDB03 /* LCPObservableAuthentication.swift in Sources */, @@ -2564,12 +2660,12 @@ 92570B878B678E9E9138C94F /* Links.swift in Sources */, 2207C27B96F098AAF8B31F2C /* PassphrasesService.swift in Sources */, BAC8616BD37C22BC5541959A /* PotentialRights.swift in Sources */, + D7B6E14061A795365DE89E80 /* ReadResult.swift in Sources */, 969961137E590BAEFBEB9CAB /* ReadiumLCPLocalizedString.swift in Sources */, AADE9BC2642DEAD9B2936FB6 /* ResourceLicenseContainer.swift in Sources */, 6FEE606C7126F68B5018CAD0 /* Rights.swift in Sources */, 21B27CD89562506DDC1D62D1 /* Signature.swift in Sources */, 077AD829863BD952DEBFB5A0 /* StatusDocument.swift in Sources */, - 3D594DCB0A9FA1F50E4B69B3 /* Streamable.swift in Sources */, 18217BC157557A5DDA4BA119 /* User.swift in Sources */, 69AA254E4A39D9B49FDFD648 /* UserKey.swift in Sources */, ); @@ -2636,6 +2732,7 @@ 5912EC9BB073282862F325F2 /* DocumentTypes.swift in Sources */, 8BD3DB373A8785BE8E71845D /* EPUBFormatSniffer.swift in Sources */, 5DE027530786CFB542965AC6 /* EPUBLayout.swift in Sources */, + 3AA66AB994F11FC9470C7EDB /* EPUBMediaOverlay.swift in Sources */, 6263D73CD26D391A6E7D0DCA /* Either.swift in Sources */, 9A1877FBEAA0BFC4C74AD3BB /* Encryption.swift in Sources */, 188D742F80B70DE8A625AD21 /* Facet.swift in Sources */, @@ -2654,6 +2751,9 @@ C368C73C819F65CE3409D35D /* Fuzi.swift in Sources */, 216EA1C1ABA15836D60D910C /* GeneratedCoverService.swift in Sources */, 66018235ED40B89D27EE9F33 /* Group.swift in Sources */, + 007A17CE92F8F2CFEBD91534 /* GuidedNavigationDocument.swift in Sources */, + 9A5AE6CF737280C031231519 /* GuidedNavigationObject.swift in Sources */, + 5CA678E3D17B036E6C25BF6A /* GuidedNavigationService.swift in Sources */, A8F8C4F2C0795BACE0A8C62C /* HREFNormalizer.swift in Sources */, A348284A6738CD705288CB8C /* HTMLFormatSniffer.swift in Sources */, 594CE84C2B11169AA0B86615 /* HTMLResourceContentIterator.swift in Sources */, @@ -2669,6 +2769,7 @@ 5E7924342D113D94AE3A098C /* InMemoryPositionsService.swift in Sources */, 39B1DDE3571AB3F3CC6824F4 /* JSON.swift in Sources */, D84BF71D6840FE62D7701073 /* JSONFormatSniffer.swift in Sources */, + 4F5523AB7E92BAA3AD01A88D /* JSONValue.swift in Sources */, 4D4915BB3847EF285362CF50 /* LCPLicenseFormatSniffer.swift in Sources */, 56CB87DACCA10F737710BFF6 /* Language.swift in Sources */, AD572C6A7AD031FEC40A0BD7 /* LanguageFormatSniffer.swift in Sources */, @@ -2685,9 +2786,8 @@ 50A35FBDFC081B2EFF4C01C6 /* LoggerStub.swift in Sources */, 7E33030C45010C776A131BD5 /* Manifest.swift in Sources */, C73D876AC0852AE89D6AC3A1 /* ManifestTransformer.swift in Sources */, - 76F6EE39F504B6A80837C90D /* MediaOverlayNode.swift in Sources */, - 9E064BC9E99D4F7D8AC3109B /* MediaOverlays.swift in Sources */, A5073271D3DDAE4056629C53 /* MediaType.swift in Sources */, + 8EBE8665FD1D3BDE5FB3B8B9 /* Metadata+EPUB.swift in Sources */, 7E45E10720EA6B4F18196316 /* Metadata+Presentation.swift in Sources */, 78C52EED635B5F8C38A02298 /* Metadata.swift in Sources */, E39B7BCA5ACB6D33C47FCB38 /* MinizipArchiveOpener.swift in Sources */, @@ -2727,11 +2827,13 @@ 5A9AEC4A9AE686ED74E9B8BF /* RWPMFormatSniffer.swift in Sources */, ED67A0EFAE830F72846BF9C0 /* Range.swift in Sources */, A6136BE75CC1F1A78A5021E2 /* ReadError.swift in Sources */, + DEC70D5DEEB5AE9F5A4183E4 /* ReadResult.swift in Sources */, B676871C6BC2D08EC8279B8D /* ReadingProgression.swift in Sources */, 3AD9E86BB1621CF836919E33 /* ReadiumLocalizedString.swift in Sources */, 31909E8E0CB313AA7C390762 /* RelativeURL.swift in Sources */, 977C8677BEB5B235E8F82A4C /* Resource.swift in Sources */, 94E5D205567FEBB52E38F318 /* ResourceContentExtractor.swift in Sources */, + 559F3EF06F73E78848C772EA /* ResourceCoverService.swift in Sources */, 92C06DC4CF7986B15F1C82B3 /* ResourceFactory.swift in Sources */, 30F89196BD5163B0A09BF9F7 /* ResourceProperties.swift in Sources */, 01E785BEA7F30AD1C8A5F3DE /* SearchService.swift in Sources */, @@ -2754,6 +2856,7 @@ D4BBC0AD7652265497B5CD1C /* URLExtensions.swift in Sources */, 1600DB04CEACF97EE8AD9CEE /* URLProtocol.swift in Sources */, 222E5BC7A9E632DD6BB9A78E /* URLQuery.swift in Sources */, + 685388EA258ACA6C80979D85 /* UncheckedSendable.swift in Sources */, A1B834459A13B655624E6618 /* UnknownAbsoluteURL.swift in Sources */, A25F76D41A944B81CB911A63 /* UserRights.swift in Sources */, 3ECB525CEB712CEC5EFCD26D /* WarningLogger.swift in Sources */, @@ -2851,16 +2954,20 @@ isa = PBXVariantGroup; children = ( B7C9D54352714641A87F64A0 /* en */, + 0885992D0F70AD0B493985CE /* fr */, + 8FC5570EB4530B7BD60A6A88 /* it */, ); name = Localizable.strings; sourceTree = ""; }; - ED5C6546C24E5E619E4CC9D1 /* LCPDialogViewController.xib */ = { + A686B5257C30B0EA8087EB31 /* W3CAccessibilityMetadataDisplayGuide.strings */ = { isa = PBXVariantGroup; children = ( - 75DFA22C741A09C81E23D084 /* Base */, + 7E14E1BA1A6B15BBC1C19296 /* en */, + F46CAAA92BFBFCCC24AD324A /* fr */, + 538FDA65FCB39F10BF9C8BC0 /* it */, ); - name = LCPDialogViewController.xib; + name = W3CAccessibilityMetadataDisplayGuide.strings; sourceTree = ""; }; /* End PBXVariantGroup section */ @@ -2877,7 +2984,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2905,7 +3012,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2933,7 +3040,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2961,7 +3068,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2989,7 +3096,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3017,7 +3124,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3045,7 +3152,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3073,7 +3180,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3101,7 +3208,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3129,7 +3236,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3219,7 +3326,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3247,7 +3354,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3275,7 +3382,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3358,7 +3465,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3386,7 +3493,7 @@ ); INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3410,7 +3517,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Support/Carthage/project.yml b/Support/Carthage/project.yml index a552c3d548..c860029db6 100644 --- a/Support/Carthage/project.yml +++ b/Support/Carthage/project.yml @@ -9,7 +9,7 @@ targets: ReadiumShared: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Shared dependencies: @@ -26,7 +26,7 @@ targets: ReadiumStreamer: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Streamer excludes: @@ -45,7 +45,7 @@ targets: ReadiumNavigator: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Navigator excludes: @@ -66,7 +66,7 @@ targets: ReadiumOPDS: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/OPDS dependencies: @@ -80,7 +80,7 @@ targets: ReadiumLCP: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/LCP dependencies: @@ -96,7 +96,7 @@ targets: ReadiumAdapterGCDWebServer: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Adapters/GCDWebServer dependencies: @@ -110,7 +110,7 @@ targets: ReadiumAdapterLCPSQLite: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Adapters/LCPSQLite dependencies: @@ -124,7 +124,7 @@ targets: ReadiumInternal: type: framework platform: iOS - deploymentTarget: "13.4" + deploymentTarget: "15.0" sources: - path: ../../Sources/Internal settings: diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index c6a38a0445..4c73a9b6eb 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" s.homepage = "http://readium.github.io" @@ -11,11 +14,12 @@ Pod::Spec.new do |s| s.source_files = "Sources/Adapters/GCDWebServer/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index d9d0b00f18..bce299ce92 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" s.homepage = "http://readium.github.io" @@ -11,11 +14,13 @@ Pod::Spec.new do |s| s.source_files = "Sources/Adapters/LCPSQLite/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumLCP', '~> 3.4.0' - s.dependency 'ReadiumShared', '~> 3.4.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' + s.dependency 'ReadiumLCP', '~> 3.8.0' s.dependency 'SQLite.swift', '~> 0.15.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index e1a028deb9..c74977169d 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumInternal" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Private utilities used by the Readium modules" s.homepage = "http://readium.github.io" @@ -11,7 +14,8 @@ Pod::Spec.new do |s| s.source_files = "Sources/Internal/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } end diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index ee3c6a3d79..938ff45dcb 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumLCP" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium LCP" s.homepage = "http://readium.github.io" @@ -17,11 +20,13 @@ Pod::Spec.new do |s| s.source_files = "Sources/LCP/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2'} - - s.dependency 'ReadiumShared' , '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' - s.dependency 'ReadiumZIPFoundation', '~> 3.0.0' + s.ios.deployment_target = "15.0" + s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } + + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' + s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.8.0' + end diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 1b704fb9b5..50ad029c31 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumNavigator" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium Navigator" s.homepage = "http://readium.github.io" @@ -17,11 +20,12 @@ Pod::Spec.new do |s| s.source_files = "Sources/Navigator/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'DifferenceKit', '~> 1.0' - s.dependency 'SwiftSoup', '~> 2.7.0' + s.dependency 'SwiftSoup', '~> 2.11.0' end diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index 02b45e62ec..e28524b073 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumOPDS" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium OPDS" s.homepage = "http://readium.github.io" @@ -11,11 +14,12 @@ Pod::Spec.new do |s| s.source_files = "Sources/OPDS/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" + s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 61a78e7405..fe3085f17d 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -1,28 +1,32 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| - - s.name = "ReadiumShared" - s.version = "3.4.0" - s.license = "BSD 3-Clause License" - s.summary = "Readium Shared" - s.homepage = "http://readium.github.io" - s.author = { "Readium" => "contact@readium.org" } - s.source = { :git => 'https://github.com/readium/swift-toolkit.git', :tag => s.version } - s.requires_arc = true + + s.name = "ReadiumShared" + s.version = "3.8.0" + s.license = "BSD 3-Clause License" + s.summary = "Readium Shared" + s.homepage = "http://readium.github.io" + s.author = { "Readium" => "contact@readium.org" } + s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } + s.requires_arc = true s.resource_bundles = { - "ReadiumShared" => ["Sources/Shared/Resources/**"], + 'ReadiumShared' => ['Sources/Shared/Resources/**'], } s.source_files = "Sources/Shared/**/*.{m,h,swift}" s.swift_version = '5.10' - s.platform = :ios - s.ios.deployment_target = "13.4" - s.frameworks = "CoreServices" - s.libraries = "xml2" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - + s.platform = :ios + s.ios.deployment_target = "15.0" + s.frameworks = "CoreServices" + s.libraries = 'xml2' + s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } + + s.dependency 'ReadiumInternal', '~> 3.8.0' s.dependency 'Minizip', '~> 1.0.0' - s.dependency 'SwiftSoup', '~> 2.7.0' + s.dependency 'SwiftSoup', '~> 2.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumZIPFoundation', '~> 3.0.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' + s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' end diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 8c0a623b3e..f8d791512e 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -1,7 +1,10 @@ +# This file is generated by `make podspecs`. Do not edit manually. +# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. + Pod::Spec.new do |s| s.name = "ReadiumStreamer" - s.version = "3.4.0" + s.version = "3.8.0" s.license = "BSD 3-Clause License" s.summary = "Readium Streamer" s.homepage = "http://readium.github.io" @@ -17,13 +20,14 @@ Pod::Spec.new do |s| s.source_files = "Sources/Streamer/**/*.{m,h,swift}" s.swift_version = '5.10' s.platform = :ios - s.ios.deployment_target = "13.4" - s.libraries = 'z', 'xml2' + s.ios.deployment_target = "15.0" + s.libraries = 'z', 'xml2' s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } + s.dependency 'ReadiumInternal', '~> 3.8.0' + s.dependency 'ReadiumShared', '~> 3.8.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'ReadiumShared', '~> 3.4.0' - s.dependency 'ReadiumInternal', '~> 3.4.0' s.dependency 'CryptoSwift', '~> 1.8.0' end diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift new file mode 100644 index 0000000000..d43a834d99 --- /dev/null +++ b/Support/CocoaPods/Specs.swift @@ -0,0 +1,149 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +/// Readium toolkit version β€” bump this when releasing a new version, then run `make podspecs`. +let version = "3.8.0" + +/// Minimum iOS deployment target shared by all modules. +let iosTarget = "15.0" + +/// Swift version requirement shared by all modules. +let swiftVersion = "5.10" + +/// Swift package name (from Package.swift). All modules share this so that `package` access +/// level works across module boundaries, matching the SPM build behaviour. +let packageName = "Readium" + +// MARK: - Data model + +struct ModuleSpec { + let name: String + /// Path to source files, relative to the repo root (e.g. "Sources/Shared"). + let sourcePath: String + let summary: String + var frameworks: [String] = [] + var libraries: [String] = [] + var xcconfig: [String: String] = [:] + /// Key = bundle name, values = glob patterns relative to repo root. + var resourceBundles: [String: [String]] = [:] + var dependencies: [Dependency] = [] +} + +enum Dependency { + /// A sibling Readium pod at the same version (e.g. `~> 3.7.0`). + case readium(String) + /// An external pod with an explicit version constraint. + case pod(String, String) +} + +// MARK: - Module Definitions (ordered by podspec push order) + +let modules: [ModuleSpec] = [ + ModuleSpec( + name: "ReadiumInternal", + sourcePath: "Sources/Internal", + summary: "Private utilities used by the Readium modules", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"] + ), + ModuleSpec( + name: "ReadiumShared", + sourcePath: "Sources/Shared", + summary: "Readium Shared", + frameworks: ["CoreServices"], + libraries: ["xml2"], + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + resourceBundles: ["ReadiumShared": ["Sources/Shared/Resources/**"]], + dependencies: [ + .readium("ReadiumInternal"), + .pod("Minizip", "~> 1.0.0"), + .pod("SwiftSoup", "~> 2.11.0"), + .pod("ReadiumFuzi", "~> 4.0.0"), + .pod("ReadiumZIPFoundation", "~> 3.0.1"), + ] + ), + ModuleSpec( + name: "ReadiumStreamer", + sourcePath: "Sources/Streamer", + summary: "Readium Streamer", + libraries: ["z", "xml2"], + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + resourceBundles: ["ReadiumStreamer": [ + "Sources/Streamer/Resources/**", + "Sources/Streamer/Assets", + ]], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumFuzi", "~> 4.0.0"), + .pod("CryptoSwift", "~> 1.8.0"), + ] + ), + ModuleSpec( + name: "ReadiumNavigator", + sourcePath: "Sources/Navigator", + summary: "Readium Navigator", + resourceBundles: ["ReadiumNavigator": [ + "Sources/Navigator/Resources/**", + "Sources/Navigator/EPUB/Assets", + ]], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("DifferenceKit", "~> 1.0"), + .pod("SwiftSoup", "~> 2.11.0"), + ] + ), + ModuleSpec( + name: "ReadiumOPDS", + sourcePath: "Sources/OPDS", + summary: "Readium OPDS", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumFuzi", "~> 4.0.0"), + ] + ), + ModuleSpec( + name: "ReadiumLCP", + sourcePath: "Sources/LCP", + summary: "Readium LCP", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + resourceBundles: ["ReadiumLCP": [ + "Sources/LCP/Resources/**", + "Sources/LCP/**/*.xib", + ]], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumZIPFoundation", "~> 3.0.1"), + .pod("CryptoSwift", "~> 1.8.0"), + ] + ), + ModuleSpec( + name: "ReadiumAdapterGCDWebServer", + sourcePath: "Sources/Adapters/GCDWebServer", + summary: "Adapter to use GCDWebServer as an HTTP server in Readium", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .pod("ReadiumGCDWebServer", "~> 4.0.0"), + ] + ), + ModuleSpec( + name: "ReadiumAdapterLCPSQLite", + sourcePath: "Sources/Adapters/LCPSQLite", + summary: "Adapter to use SQLite.swift for the Readium LCP repositories", + xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], + dependencies: [ + .readium("ReadiumInternal"), + .readium("ReadiumShared"), + .readium("ReadiumLCP"), + .pod("SQLite.swift", "~> 0.15.0"), + ] + ), +] diff --git a/TestApp/Integrations/Carthage/project+lcp.yml b/TestApp/Integrations/Carthage/project+lcp.yml index e6d96a5294..c533f379a2 100644 --- a/TestApp/Integrations/Carthage/project+lcp.yml +++ b/TestApp/Integrations/Carthage/project+lcp.yml @@ -28,7 +28,6 @@ targets: - framework: Carthage/Build/Minizip.xcframework - framework: Carthage/Build/R2LCPClient.xcframework - framework: Carthage/Build/ReadiumAdapterGCDWebServer.xcframework - - framework: Carthage/Build/ReadiumAdapterLCPSQLite.xcframework - framework: Carthage/Build/ReadiumFuzi.xcframework - framework: Carthage/Build/ReadiumGCDWebServer.xcframework - framework: Carthage/Build/ReadiumInternal.xcframework @@ -38,7 +37,6 @@ targets: - framework: Carthage/Build/ReadiumShared.xcframework - framework: Carthage/Build/ReadiumStreamer.xcframework - framework: Carthage/Build/ReadiumZIPFoundation.xcframework - - framework: Carthage/Build/SQLite.xcframework - framework: Carthage/Build/SwiftSoup.xcframework - package: GRDB - package: Kingfisher diff --git a/TestApp/Integrations/CocoaPods/Podfile+lcp b/TestApp/Integrations/CocoaPods/Podfile+lcp index 5fa4c80ac8..7d10b49e32 100644 --- a/TestApp/Integrations/CocoaPods/Podfile+lcp +++ b/TestApp/Integrations/CocoaPods/Podfile+lcp @@ -13,7 +13,6 @@ target 'TestApp' do pod 'ReadiumOPDS', '~> VERSION' pod 'ReadiumLCP', '~> VERSION' pod 'ReadiumAdapterGCDWebServer', '~> VERSION' - pod 'ReadiumAdapterLCPSQLite', '~> VERSION' pod 'R2LCPClient', podspec: 'LCP_URL' pod 'GRDB.swift', '~> 6.0' diff --git a/TestApp/Integrations/Local/project+lcp.yml b/TestApp/Integrations/Local/project+lcp.yml index 4cc5bbbba8..b2e266f2d9 100644 --- a/TestApp/Integrations/Local/project+lcp.yml +++ b/TestApp/Integrations/Local/project+lcp.yml @@ -49,8 +49,6 @@ targets: product: ReadiumNavigator - package: Readium product: ReadiumAdapterGCDWebServer - - package: Readium - product: ReadiumAdapterLCPSQLite - package: Readium product: ReadiumOPDS - package: Readium diff --git a/TestApp/Integrations/SPM/project+lcp.yml b/TestApp/Integrations/SPM/project+lcp.yml index 193da92268..058b595927 100644 --- a/TestApp/Integrations/SPM/project+lcp.yml +++ b/TestApp/Integrations/SPM/project+lcp.yml @@ -41,8 +41,6 @@ targets: product: ReadiumNavigator - package: Readium product: ReadiumAdapterGCDWebServer - - package: Readium - product: ReadiumAdapterLCPSQLite - package: Readium product: ReadiumOPDS - package: Readium diff --git a/TestApp/Sources/About/AboutSectionView.swift b/TestApp/Sources/About/AboutSectionView.swift index 2b7da92e35..51aa7e12cb 100644 --- a/TestApp/Sources/About/AboutSectionView.swift +++ b/TestApp/Sources/About/AboutSectionView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/About/AboutView.swift b/TestApp/Sources/About/AboutView.swift index 9a010deb1c..b1d627232d 100644 --- a/TestApp/Sources/About/AboutView.swift +++ b/TestApp/Sources/About/AboutView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/App/AboutTableViewController.swift b/TestApp/Sources/App/AboutTableViewController.swift index 5c8bba553e..391f3d6f39 100644 --- a/TestApp/Sources/App/AboutTableViewController.swift +++ b/TestApp/Sources/App/AboutTableViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 5eedd8fda6..18ae743ec8 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -57,7 +57,7 @@ final class AppModule { opds = OPDSModule(delegate: self) // Set Readium 2's logging minimum level. - ReadiumEnableLog(withMinimumSeverityLevel: .info) + ReadiumEnableLog(withMinimumSeverityLevel: .debug) } private(set) lazy var aboutViewController: UIViewController = { diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 4d4c5df86f..c2232079de 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -12,7 +12,6 @@ import ReadiumStreamer #if LCP import R2LCPClient - import ReadiumAdapterLCPSQLite import ReadiumLCP #endif @@ -45,8 +44,8 @@ final class Readium { lazy var lcpService = LCPService( client: LCPClient(), - licenseRepository: try! LCPSQLiteLicenseRepository(), - passphraseRepository: try! LCPSQLitePassphraseRepository(), + licenseRepository: LCPKeychainLicenseRepository(), + passphraseRepository: LCPKeychainPassphraseRepository(), assetRetriever: assetRetriever, httpClient: httpClient ) @@ -130,6 +129,8 @@ extension ReadiumShared.FileSystemError: UserErrorConvertible { return "error_not_found".localized case .forbidden: return "error_forbidden".localized + case .outOfSpace: + return "error_out_of_space".localized case .io: return "error_io".localized } @@ -240,6 +241,7 @@ extension ReadiumNavigator.TTSError: UserErrorConvertible { switch error { case let .cancelled(date): return "lcp_error_status_cancelled".localized(dateFormatter.string(from: date)) + case let .returned(date): return "lcp_error_status_returned".localized(dateFormatter.string(from: date)) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index d2800ecdf1..8c3888cbc5 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,7 +8,7 @@ import Combine import ReadiumShared import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? diff --git a/TestApp/Sources/Common/Paths.swift b/TestApp/Sources/Common/Paths.swift index 6b2194e8b8..d33e8f0037 100644 --- a/TestApp/Sources/Common/Paths.swift +++ b/TestApp/Sources/Common/Paths.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Publication.swift b/TestApp/Sources/Common/Publication.swift index 14d279f604..5b46327a47 100644 --- a/TestApp/Sources/Common/Publication.swift +++ b/TestApp/Sources/Common/Publication.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/BarButtonItem.swift b/TestApp/Sources/Common/Toolkit/BarButtonItem.swift index 5baf9c17ac..eade10fa5b 100644 --- a/TestApp/Sources/Common/Toolkit/BarButtonItem.swift +++ b/TestApp/Sources/Common/Toolkit/BarButtonItem.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift b/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift index 245e58da14..b7805048e1 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Future.swift b/TestApp/Sources/Common/Toolkit/Extensions/Future.swift index b4799fe4e1..64df77cb41 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/Future.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/Future.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift b/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift index bed03455a0..0d4c43208f 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/Locator.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift b/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift index 336be39e90..21e7e0afed 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/UIImage.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift b/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift index e045f69730..9c19a0bb4a 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/Extensions/URL.swift b/TestApp/Sources/Common/Toolkit/Extensions/URL.swift index 3037baddcd..3aa0999133 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/URL.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/URL.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift b/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift index cc27d9cb21..1b87bf8af0 100644 --- a/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift +++ b/TestApp/Sources/Common/Toolkit/ScreenOrientation.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/UX/IconButton.swift b/TestApp/Sources/Common/UX/IconButton.swift index 0c97d84d4f..fc405c385a 100644 --- a/TestApp/Sources/Common/UX/IconButton.swift +++ b/TestApp/Sources/Common/UX/IconButton.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Common/UserError.swift b/TestApp/Sources/Common/UserError.swift index 320d588b5b..4797e5f01e 100644 --- a/TestApp/Sources/Common/UserError.swift +++ b/TestApp/Sources/Common/UserError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -38,7 +38,9 @@ struct UserError: LocalizedError { self.init(message(), cause: cause) } - var errorDescription: String? { message } + var errorDescription: String? { + message + } } /// Convenience protocol for an object (usually an ``Error``) that can be converted diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index f7c672567e..aaac6c1157 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -39,7 +39,9 @@ struct Book: Codable { /// reading progression, spreads). var preferencesJSON: String? - var mediaType: MediaType { MediaType(type) ?? .binary } + var mediaType: MediaType { + MediaType(type) ?? .binary + } init( id: Id? = nil, diff --git a/TestApp/Sources/Data/Bookmark.swift b/TestApp/Sources/Data/Bookmark.swift index 6c89756a98..6c0040cac8 100644 --- a/TestApp/Sources/Data/Bookmark.swift +++ b/TestApp/Sources/Data/Bookmark.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -66,5 +66,5 @@ final class BookmarkRepository { } } -// for the default SwiftUI support +/// for the default SwiftUI support extension Bookmark: Hashable {} diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 9dfa7635f8..0d49400581 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -76,12 +76,11 @@ final class Database { @discardableResult func write(_ updates: @escaping (GRDB.Database) throws -> T) async throws -> T { try await withCheckedThrowingContinuation { cont in - writer.asyncWrite( - { try updates($0) }, - completion: { _, result in - cont.resume(with: result) - } - ) + writer.asyncWrite { + try updates($0) + } completion: { _, result in + cont.resume(with: result) + } } } @@ -137,7 +136,9 @@ extension EntityId { // MARK: - DatabaseValueConvertible - var databaseValue: DatabaseValue { rawValue.databaseValue } + var databaseValue: DatabaseValue { + rawValue.databaseValue + } static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? { Int64.fromDatabaseValue(dbValue).map(Self.init) diff --git a/TestApp/Sources/Data/Highlight.swift b/TestApp/Sources/Data/Highlight.swift index 750d7911ca..06b4aeb436 100644 --- a/TestApp/Sources/Data/Highlight.swift +++ b/TestApp/Sources/Data/Highlight.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -118,5 +118,5 @@ final class HighlightRepository { } } -// for the default SwiftUI support +/// for the default SwiftUI support extension Highlight: Hashable {} diff --git a/TestApp/Sources/Data/UserPreferencesStore.swift b/TestApp/Sources/Data/UserPreferencesStore.swift index 8110e18077..089c7dfd48 100644 --- a/TestApp/Sources/Data/UserPreferencesStore.swift +++ b/TestApp/Sources/Data/UserPreferencesStore.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index f2ff56a1b4..e841fa177d 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -252,9 +252,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.4.0 + 3.8.0 CFBundleVersion - 3.4.0 + 3.8.0 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/TestApp/Sources/LCP/LCPModule.swift b/TestApp/Sources/LCP/LCPModule.swift index e7c47dda7e..4aaf244d0e 100644 --- a/TestApp/Sources/LCP/LCPModule.swift +++ b/TestApp/Sources/LCP/LCPModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,7 +9,6 @@ import ReadiumShared #if LCP import R2LCPClient - import ReadiumAdapterLCPSQLite import ReadiumLCP #endif diff --git a/TestApp/Sources/Library/LibraryError.swift b/TestApp/Sources/Library/LibraryError.swift index 27d7f6a92e..93456ae1a4 100644 --- a/TestApp/Sources/Library/LibraryError.swift +++ b/TestApp/Sources/Library/LibraryError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Library/LibraryFactory.swift b/TestApp/Sources/Library/LibraryFactory.swift index 90d2fa0e4d..d8d5e8e0d6 100644 --- a/TestApp/Sources/Library/LibraryFactory.swift +++ b/TestApp/Sources/Library/LibraryFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Library/LibraryModule.swift b/TestApp/Sources/Library/LibraryModule.swift index 11ea59bbb1..35fbb9af4b 100644 --- a/TestApp/Sources/Library/LibraryModule.swift +++ b/TestApp/Sources/Library/LibraryModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index 1afe6ef315..1d9e965ad8 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -116,17 +116,24 @@ final class LibraryService: Loggable { } let (pub, format) = try await openPublication(at: url, allowUserInteraction: false, sender: sender) + let title = pub.metadata.title ?? url.url.deletingPathExtension().lastPathComponent let coverPath = try await importCover(of: pub) if let file = url.fileURL { url = try moveToDocuments( from: file, - title: pub.metadata.title ?? file.lastPathSegment, + title: title, format: format ) } - return try await insertBook(at: url, publication: pub, mediaType: format.mediaType, coverPath: coverPath) + return try await insertBook( + at: url, + publication: pub, + mediaType: format.mediaType, + title: title, + coverPath: coverPath + ) } /// Fulfills the given `url` if it's a DRM license file. @@ -177,13 +184,19 @@ final class LibraryService: Loggable { } /// Inserts the given `book` in the bookshelf. - private func insertBook(at url: AbsoluteURL, publication: Publication, mediaType: MediaType?, coverPath: String?) async throws -> Book { + private func insertBook( + at url: AbsoluteURL, + publication: Publication, + mediaType: MediaType?, + title: String, + coverPath: String? + ) async throws -> Book { // Makes the URL relative to the Documents/ folder if possible. let url: AnyURL = Paths.documents.relativize(url)?.anyURL ?? url.anyURL let book = Book( identifier: publication.metadata.identifier, - title: publication.metadata.title ?? url.lastPathSegment ?? "Untitled", + title: title, authors: publication.metadata.authors .map(\.name) .joined(separator: ", "), diff --git a/TestApp/Sources/Library/LibraryViewController.swift b/TestApp/Sources/Library/LibraryViewController.swift index 7891546054..a5149b304e 100644 --- a/TestApp/Sources/Library/LibraryViewController.swift +++ b/TestApp/Sources/Library/LibraryViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -199,11 +199,11 @@ extension LibraryViewController { // MARK: - UIDocumentPickerDelegate. extension LibraryViewController: UIDocumentPickerDelegate { - public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { importFiles(at: urls) } - public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { importFiles(at: [url]) } @@ -266,7 +266,7 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio return cell } - internal func defaultCover(layout: UICollectionViewFlowLayout?, description: String) -> UITextView { + func defaultCover(layout: UICollectionViewFlowLayout?, description: String) -> UITextView { let width = layout?.itemSize.width ?? 0 let height = layout?.itemSize.height ?? 0 let titleTextView = UITextView(frame: CGRect(x: 0, y: 0, width: width, height: height)) @@ -352,8 +352,8 @@ extension LibraryViewController: PublicationCollectionViewCellDelegate { } } - // Used to reset ui of the last flipped cell, we must not have two cells - // flipped at the same time + /// Used to reset ui of the last flipped cell, we must not have two cells + /// flipped at the same time func cellFlipped(_ cell: PublicationCollectionViewCell) { lastFlippedCell?.flipMenu() lastFlippedCell = cell diff --git a/TestApp/Sources/Library/PublicationCollectionViewCell.swift b/TestApp/Sources/Library/PublicationCollectionViewCell.swift index 18720116e5..de660b7ce7 100644 --- a/TestApp/Sources/Library/PublicationCollectionViewCell.swift +++ b/TestApp/Sources/Library/PublicationCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Library/PublicationMenuViewController.swift b/TestApp/Sources/Library/PublicationMenuViewController.swift index 7055693b56..d0a8b42d88 100644 --- a/TestApp/Sources/Library/PublicationMenuViewController.swift +++ b/TestApp/Sources/Library/PublicationMenuViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Library/PublicationMetadataView.swift b/TestApp/Sources/Library/PublicationMetadataView.swift index 4b1ca02206..344619cfb6 100644 --- a/TestApp/Sources/Library/PublicationMetadataView.swift +++ b/TestApp/Sources/Library/PublicationMetadataView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift b/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift index ceeb9ae8b3..8245d0cb12 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/EditOPDSCatalogView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift index 3cca9cf6cd..6ccf144e1a 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalog.swift @@ -1,12 +1,12 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // import Foundation -struct OPDSCatalog: Identifiable, Equatable { +struct OPDSCatalog: Identifiable, Equatable, Hashable { let id: String var title: String var url: URL diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift index fa27bb2516..770066fc2f 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogRow.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,11 +14,6 @@ struct OPDSCatalogRow: View { Image(systemName: "books.vertical.fill") .foregroundColor(.accentColor) Text(title) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) } } } diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift index df4c247a09..e15e157391 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,30 +9,32 @@ import SwiftUI struct OPDSCatalogsView: View { @State private var viewModel: OPDSCatalogsViewModel - init(viewModel: OPDSCatalogsViewModel) { + private var delegate: OPDSModuleDelegate? + + init(viewModel: OPDSCatalogsViewModel, delegate: OPDSModuleDelegate?) { self.viewModel = viewModel + self.delegate = delegate } var body: some View { List(viewModel.catalogs) { catalog in - OPDSCatalogRow(title: catalog.title) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.onCatalogTap(id: catalog.id) + NavigationLink(value: catalog) { + OPDSCatalogRow(title: catalog.title) + } + .contentShape(Rectangle()) + .swipeActions(allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.onDeleteCatalogTap(id: catalog.id) + } label: { + Label("Delete", systemImage: "trash") } - .swipeActions(allowsFullSwipe: false) { - Button(role: .destructive) { - viewModel.onDeleteCatalogTap(id: catalog.id) - } label: { - Label("Delete", systemImage: "trash") - } - Button { - viewModel.onEditCatalogTap(id: catalog.id) - } label: { - Label("Edit", systemImage: "pencil") - } + Button { + viewModel.onEditCatalogTap(id: catalog.id) + } label: { + Label("Edit", systemImage: "pencil") } + } } .listStyle(.plain) .onAppear { @@ -56,5 +58,7 @@ struct OPDSCatalogsView: View { } #Preview { - OPDSCatalogsView(viewModel: OPDSCatalogsViewModel()) + NavigationStack { + OPDSCatalogsView(viewModel: OPDSCatalogsViewModel(), delegate: nil) + } } diff --git a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift index 1d1a79344f..3054711d7f 100644 --- a/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift +++ b/TestApp/Sources/OPDS/OPDSCatalogs/OPDSCatalogsViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift b/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift deleted file mode 100644 index 6938b7bfba..0000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/Feed+preview.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation -import ReadiumOPDS -import ReadiumShared - -extension Feed { - static var preview: Feed { - try! OPDS2Parser.parse( - jsonData: .preview, - url: URL(string: "http://opds-spec.org/opds.json")!, - response: URLResponse() - ).feed! - } -} - -private extension Data { - static var preview: Data { - let jsonString = """ - { - "@context": "http://opds-spec.org/opds.json", - "metadata": { - "title": "Example Library", - "modified": "2024-11-05T12:00:00Z", - "numberOfItems": 5000, - "itemsPerPage": 30 - }, - "links": [ - { - "rel": "self", - "href": "/opds", - "type": "application/opds+json" - } - ], - "facets": [ - { - "metadata": { - "title": "Genre" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=fiction", - "title": "Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1250 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=mystery", - "title": "Mystery & Detective", - "type": "application/opds+json", - "properties": { - "numberOfItems": 850 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=scifi", - "title": "Science Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 725 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?genre=non-fiction", - "title": "Non-Fiction", - "type": "application/opds+json", - "properties": { - "numberOfItems": 2175 - } - } - ] - }, - { - "metadata": { - "title": "Language" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=en", - "title": "English", - "type": "application/opds+json", - "properties": { - "numberOfItems": 3000 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=es", - "title": "Spanish", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1000 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?language=ru", - "title": "Russian", - "type": "application/opds+json", - "properties": { - "numberOfItems": 800 - } - } - ] - }, - { - "metadata": { - "title": "Availability" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=free", - "title": "Free", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1500 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=subscription", - "title": "Subscription", - "type": "application/opds+json", - "properties": { - "numberOfItems": 2500 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?availability=buy", - "title": "Purchase Required", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1000 - } - } - ] - }, - { - "metadata": { - "title": "Reading Age" - }, - "links": [ - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=children", - "title": "Children (0-11)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 800 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=teen", - "title": "Teen (12-18)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 1200 - } - }, - { - "rel": "http://opds-spec.org/facet", - "href": "/opds/books/new?age=adult", - "title": "Adult (18+)", - "type": "application/opds+json", - "properties": { - "numberOfItems": 3000 - } - } - ] - } - ], - "publications": [ - { - "metadata": { - "title": "Sample Book", - "identifier": "urn:uuid:6409a00b-7bf2-405e-826c-3fdff0fd0734", - "modified": "2024-11-05T12:00:00Z", - "language": ["en"], - "published": "2024", - "author": [ - { - "name": "Sample Author" - } - ], - "subject": [ - { - "name": "Fiction", - "code": "fiction" - } - ] - }, - "links": [ - { - "rel": "http://opds-spec.org/acquisition", - "href": "/books/sample.epub", - "type": "application/epub+zip" - } - ] - } - ] - } - """ - guard let data = jsonString.data(using: .utf8) else { - return Data() - } - return data - } -} diff --git a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift b/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift deleted file mode 100644 index a0e0a9d969..0000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetLink.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import ReadiumShared -import SwiftUI - -struct OPDSFacetLink: View { - let link: ReadiumShared.Link - - var body: some View { - HStack { - if let title = link.title { - Text(title) - .foregroundStyle(Color.primary) - } - - Spacer() - - if let count = link.properties.numberOfItems { - Text("\(count)") - .foregroundStyle(Color.secondary) - .font(.subheadline) - } - - Image(systemName: "chevron.right") - } - .font(.body) - } -} - -#Preview { - OPDSFacetLink( - link: Feed.preview.facets[0].links[0] - ) - .padding() -} diff --git a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift b/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift deleted file mode 100644 index ee73d979db..0000000000 --- a/TestApp/Sources/OPDS/OPDSFacets/OPDSFacetList.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import ReadiumShared -import SwiftUI - -struct OPDSFacetList: View { - @Environment(\.dismiss) private var dismiss - - let feed: Feed - let onLinkSelected: (ReadiumShared.Link) -> Void - - var body: some View { - NavigationView { - facets - .toolbar { cancelButton } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Facets") - } - } - - private var facets: some View { - List(feed.facets, id: \.metadata.title) { facet in - Section(facet.metadata.title) { - ForEach(facet.links, id: \.href) { link in - OPDSFacetLink(link: link) - .contentShape(Rectangle()) - .onTapGesture { - onLinkSelected(link) - dismiss() - } - } - } - } - } - - private var cancelButton: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } - } -} - -#Preview { - OPDSFacetList(feed: .preview) { link in - print("Tap on link \(link.href)") - } -} diff --git a/TestApp/Sources/OPDS/OPDSFactory.swift b/TestApp/Sources/OPDS/OPDSFactory.swift index e10e41b3bb..f111e8bfac 100644 --- a/TestApp/Sources/OPDS/OPDSFactory.swift +++ b/TestApp/Sources/OPDS/OPDSFactory.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -17,16 +17,6 @@ final class OPDSFactory { private let storyboard = UIStoryboard(name: "OPDS", bundle: nil) } -extension OPDSFactory: OPDSRootTableViewControllerFactory { - func make(feedURL: URL, indexPath: IndexPath?) -> OPDSRootTableViewController { - let controller = storyboard.instantiateViewController(withIdentifier: "OPDSRootTableViewController") as! OPDSRootTableViewController - controller.factory = self - controller.originalFeedURL = feedURL - controller.originalFeedIndexPath = nil - return controller - } -} - extension OPDSFactory: OPDSPublicationInfoViewControllerFactory { func make(publication: Publication) -> OPDSPublicationInfoViewController { let controller = storyboard.instantiateViewController(withIdentifier: "OPDSPublicationInfoViewController") as! OPDSPublicationInfoViewController diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift new file mode 100644 index 0000000000..bbc11e5c52 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFacetView.swift @@ -0,0 +1,50 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +struct OPDSFacetView: View { + let facets: [Facet] + + /// This closure is called when a facet link is tapped. + /// The parent view (OPDSFeedView) will handle the navigation. + let onLinkTapped: (ReadiumShared.Link) -> Void + + /// The dismiss action provided by the environment. + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(facets, id: \.metadata.title) { facet in + Section(header: Text(facet.metadata.title)) { + ForEach(facet.links, id: \.href) { link in + Button { + // When tapped, dismiss this sheet + // and tell the parent to navigate. + dismiss() + onLinkTapped(link) + } label: { + OPDSNavigationRow(link: link) + .foregroundColor(.primary) + } + } + } + } + } + .listStyle(.grouped) + .navigationTitle(NSLocalizedString("filter_button", comment: "Filter the OPDS feed")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("ok_button", comment: "Alert button")) { + dismiss() + } + } + } + } + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift new file mode 100644 index 0000000000..6669f10ff0 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedView.swift @@ -0,0 +1,273 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +struct OPDSFeedView: View { + @StateObject private var viewModel: OPDSFeedViewModel + + private var delegate: OPDSModuleDelegate? + + @State private var facetNavigationURL: URL? + + struct NavigablePublication: Identifiable, Hashable { + let id: String + let publication: ReadiumShared.Publication + + init(publication: ReadiumShared.Publication, index: Int) { + self.publication = publication + id = "\(publication.manifest.hashValue)-\(index)" + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: NavigablePublication, rhs: NavigablePublication) -> Bool { + lhs.id == rhs.id + } + } + + /// Converts publications to NavigablePublications with unique IDs. + /// Each publication gets an ID in the format: hash-index + private func makeNavigablePublications(_ publications: [ReadiumShared.Publication]) -> [NavigablePublication] { + publications.enumerated().map { index, publication in + NavigablePublication(publication: publication, index: index) + } + } + + init(feedURL: URL, delegate: OPDSModuleDelegate?) { + _viewModel = StateObject(wrappedValue: OPDSFeedViewModel(feedURL: feedURL, delegate: delegate)) + self.delegate = delegate + } + + var body: some View { + mainContent + .navigationTitle(viewModel.feed?.metadata.title ?? "Loading...") + .navigationBarTitleDisplayMode(.inline) // Keeps title small + .onAppear { + if viewModel.feed == nil { + viewModel.parseFeed() + } + } + .toolbar { + buildToolbar() + } + .sheet(isPresented: $viewModel.isShowingFacets) { + buildFacetView() + } + .navigationDestination( + isPresented: Binding( + get: { facetNavigationURL != nil }, + set: { if !$0 { facetNavigationURL = nil } } + ) + ) { + facetDestinationView() + } + } + + private var mainContent: some View { + Group { + // If the feed is only publications, show a grid. + if viewModel.isPublicationOnly { + buildPublicationOnlyView(viewModel.publications) + } else { + // Otherwise, show a list view. + buildListView() + } + } + } + + @ViewBuilder + private func facetDestinationView() -> some View { + if let url = facetNavigationURL { + OPDSFeedView(feedURL: url, delegate: delegate) + } else { + EmptyView() + } + } + + // MARK: - Toolbar & Sheet Builders + + @ToolbarContentBuilder + private func buildToolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + if !(viewModel.feed?.facets.isEmpty ?? true) { + Button { + viewModel.isShowingFacets = true + } label: { + Text(NSLocalizedString("filter_button", comment: "Filter the OPDS feed")) + } + } + } + } + + private func buildFacetView() -> some View { + OPDSFacetView(facets: viewModel.feed?.facets ?? []) { link in + if let url = URL(string: link.href) { + facetNavigationURL = url + } + } + } + + // MARK: - List View Builders + + private func buildListView() -> some View { + ScrollView { + LazyVStack(spacing: 0) { + if viewModel.feed != nil { + if !viewModel.navigation.isEmpty { + buildNavigationSection(viewModel.navigation) + } + + if !viewModel.groups.isEmpty { + buildGroupsSection(viewModel.groups) + } + + if let group = viewModel.rootPublicationsGroup { + buildGroupsSection([group]) + } + + if !viewModel.hasContent { + buildNoneView() + .padding() + } + + } else if viewModel.error != nil { + Text("Failed to load feed. Please try again.") + .padding() + } else { + ProgressView() + .padding() + } + } + } + } + + @ViewBuilder + private func buildNoneView() -> some View { + if let error = viewModel.error { + Text("Failed to load feed: \(error.localizedDescription)") + } else { + Text("No content in this feed.") + } + } + + // MARK: - Publication Grid Builder + + @ViewBuilder + private func buildPublicationOnlyView(_ publications: [ReadiumShared.Publication]) -> some View { + let columns = [ + GridItem(.adaptive(minimum: 140), spacing: 16), + ] + let navPublications = makeNavigablePublications(publications) + + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(navPublications) { navPublication in + NavigationLink(value: navPublication) { + OPDSPublicationItemView(publication: navPublication.publication) + } + .buttonStyle(.plain) + .onAppear { + if navPublication == navPublications.last { + viewModel.loadNextPage() + } + } + } + } + .padding() + + if viewModel.isLoadingNextPage { + ProgressView() + .padding() + } + } + } + + // MARK: - Section Builders + + @ViewBuilder + private func buildNavigationSection(_ navigation: [ReadiumShared.Link]) -> some View { + HStack { + Text(NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds")) + .font(.title3.bold()) + .textCase(nil) + Spacer() + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 8) + + Divider() + .padding(.horizontal) + + buildNavigationList(navigation, isRootList: true) + } + + private func buildGroupsSection(_ groups: [ReadiumShared.Group]) -> some View { + ForEach(Array(groups.enumerated()), id: \.element.metadata.title) { _, group in + HStack { + Text(group.metadata.title) + .font(.title3.bold()) + .textCase(nil) + + Spacer() + + if let moreLink = group.links.first, let url = URL(string: moreLink.href) { + NavigationLink(value: url) { + Text(NSLocalizedString("opds_more_button", comment: "Button to expand a feed gallery")) + .font(.title3.bold()) + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal) + .padding(.top, 32) + .padding(.bottom, 8) + + if !group.publications.isEmpty { + let navPublications = makeNavigablePublications(group.publications) + + OPDSGroupRow( + group: group, + publications: navPublications, + isLoading: viewModel.isLoadingNextPage, + onLastItemAppeared: { + viewModel.loadNextPage() + } + ) + } else if !group.navigation.isEmpty { + Divider() + .padding(.horizontal) + + buildNavigationList(group.navigation, isRootList: false) + } + } + } + + private func buildNavigationList(_ navigation: [ReadiumShared.Link], isRootList: Bool) -> some View { + ForEach(navigation.indices, id: \.self) { index in + let link = navigation[index] + + if let url = URL(string: link.href) { + NavigationLink(value: url) { + OPDSNavigationRow(link: link) + .padding(.horizontal) + } + .buttonStyle(.plain) + if isRootList { + Divider() + .padding(.horizontal) + } else { + Divider() + .padding(.leading) + } + } + } + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift new file mode 100644 index 0000000000..df028f214c --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift @@ -0,0 +1,149 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import ReadiumOPDS +import ReadiumShared + +@MainActor +class OPDSFeedViewModel: ObservableObject { + let feedURL: URL + + @Published var feed: Feed? + @Published var error: Error? + @Published var isShowingFacets = false + + /// Tracks if a pagination request is in progress. + @Published var isLoadingNextPage = false + + weak var delegate: OPDSModuleDelegate? + + /// Stores the URL for the next page of results. + private var nextPageURL: URL? + + init(feedURL: URL, delegate: OPDSModuleDelegate?) { + self.feedURL = feedURL + self.delegate = delegate + } + + /// Fetches and parses the initial OPDS feed. + func parseFeed() { + feed = nil + error = nil + nextPageURL = nil // Reset next page URL + + OPDSParser.parseURL(url: feedURL) { [weak self] data, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let data = data, let feed = data.feed { + self.feed = feed + // Find and store the next page URL + self.nextPageURL = self.findNextPageURL(feed: feed) + } else if let error = error { + self.error = error + print("Failed to parse feed: \(error)") + } else { + self.error = OPDSError.invalidURL(self.feedURL.absoluteString) + } + } + } + } + + /// Fetches and parses the next page of the feed. + func loadNextPage() { + // Don't load if already loading or if there's no next page + guard !isLoadingNextPage, let url = nextPageURL else { + return + } + + isLoadingNextPage = true + + OPDSParser.parseURL(url: url) { [weak self] data, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let data = data, let newFeed = data.feed { + // Append new publications to the existing feed + self.feed?.publications.append(contentsOf: newFeed.publications) + // Find the *next* next page URL + self.nextPageURL = self.findNextPageURL(feed: newFeed) + } else if let error = error { + print("Failed to load next page: \(error)") + } + + self.isLoadingNextPage = false + } + } + } + + /// Finds the "next" link in the feed's links. + private func findNextPageURL(feed: Feed) -> URL? { + guard let href = feed.links.firstWithRel(.next)?.href else { + return nil + } + return URL(string: href) + } + + // MARK: - View-Ready Computed Properties + + /// Provides the navigation links, or an empty array. + var navigation: [ReadiumShared.Link] { + feed?.navigation ?? [] + } + + /// Provides the feed groups, or an empty array. + var groups: [ReadiumShared.Group] { + feed?.groups ?? [] + } + + /// Provides the publications, or an empty array. + var publications: [ReadiumShared.Publication] { + feed?.publications ?? [] + } + + /// True if the feed contains only publications and no navigation or groups. + /// The View uses this to decide whether to show a grid or a list. + var isPublicationOnly: Bool { + guard let feed = feed else { return false } + return !feed.publications.isEmpty + && feed.navigation.isEmpty + && feed.groups.isEmpty + } + + /// True if the feed contains any content at all. + var hasContent: Bool { + guard let feed = feed else { return false } + return !feed.navigation.isEmpty + || !feed.groups.isEmpty + || !feed.publications.isEmpty + } + + /// Creates a group for publications at the feed's root. + /// This allows the View to render them as just another group in the list. + var rootPublicationsGroup: ReadiumShared.Group? { + guard let feed = feed, !feed.publications.isEmpty else { + return nil + } + + if isPublicationOnly { + return nil + } + + let title: String + if feed.groups.isEmpty { + title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") + } else { + title = feed.metadata.title + } + + // Create the group and assign publications + let pubGroup = ReadiumShared.Group(title: title) + pubGroup.publications = feed.publications + return pubGroup + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift new file mode 100644 index 0000000000..a472b6f8cf --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSGroupRow.swift @@ -0,0 +1,47 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +struct OPDSGroupRow: View { + let group: ReadiumShared.Group + + typealias NavigablePublication = OPDSFeedView.NavigablePublication + let publications: [NavigablePublication] + + let isLoading: Bool + let onLastItemAppeared: () -> Void + + private let rowHeight: CGFloat = 230 + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(publications) { navPublication in + NavigationLink(value: navPublication) { + OPDSPublicationItemView(publication: navPublication.publication) + } + .buttonStyle(.plain) + .onAppear { + if navPublication == publications.last { + onLastItemAppeared() + } + } + } + + if isLoading { + ZStack { + ProgressView() + } + .frame(width: 140, height: rowHeight) + } + } + .padding(.horizontal) + } + .frame(height: rowHeight) + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift new file mode 100644 index 0000000000..885cfa91f1 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSNavigationRow.swift @@ -0,0 +1,39 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +/// A view for a single navigation link in an OPDS feed. +struct OPDSNavigationRow: View { + let link: ReadiumShared.Link + + var body: some View { + rowContent + } + + private var rowContent: some View { + HStack { + Text(link.title ?? "Untitled") + .font(.body) + .padding(.vertical, 12) + .lineLimit(1) + + Spacer() + + if let count = link.properties.numberOfItems { + Text("\(count)") + .font(.body) + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.bold)) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + } + .contentShape(Rectangle()) + } +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift new file mode 100644 index 0000000000..04efd269cc --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationInfoView.swift @@ -0,0 +1,19 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +/// A SwiftUI wrapper for the UIKit OPDSPublicationInfoViewController. +struct OPDSPublicationInfoView: UIViewControllerRepresentable { + let publication: Publication + + func makeUIViewController(context: Context) -> OPDSPublicationInfoViewController { + OPDSFactory.shared.make(publication: publication) + } + + func updateUIViewController(_ uiViewController: OPDSPublicationInfoViewController, context: Context) {} +} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift new file mode 100644 index 0000000000..37905a8558 --- /dev/null +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSPublicationItemView.swift @@ -0,0 +1,57 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +struct OPDSPublicationItemView: View { + let publication: Publication + + private let coverHeight: CGFloat = 200 + private let coverWidth: CGFloat = 140 + + private var imageURL: URL? { + let primaryURL = publication.coverLink?.url(relativeTo: publication.baseURL).httpURL?.url + + let fallbackURL = publication.images.first?.url(relativeTo: publication.baseURL).httpURL?.url + + return primaryURL ?? fallbackURL + } + + var body: some View { + VStack(alignment: .leading) { + AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray.opacity(0.3) + .overlay(Image(systemName: "book.closed")) + } + .frame(width: coverWidth, height: coverHeight) + .clipped() + + Text(publication.metadata.title ?? "") + .font(.caption) + .lineLimit(2) + + Text(publication.metadata.authors.map(\.name).joined(separator: ", ")) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + .frame(width: coverWidth) + } +} + +private extension Publication { + /// Finds the first link with `cover` or thumbnail relations. + var coverLink: ReadiumShared.Link? { + links.firstWithRel(.cover) + ?? links.firstWithRel("http://opds-ps.org/image") + ?? links.firstWithRel("http://opds-ps.org/image/thumbnail") + } +} diff --git a/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift deleted file mode 100644 index c5e9e189ad..0000000000 --- a/TestApp/Sources/OPDS/OPDSGroupCollectionViewCell.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import UIKit - -class OPDSGroupCollectionViewCell: UICollectionViewCell { - @IBOutlet var navigationTitleLabel: UILabel! - @IBOutlet var navigationCountLabel: UILabel! -} diff --git a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift b/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift deleted file mode 100644 index 99cc77d2e4..0000000000 --- a/TestApp/Sources/OPDS/OPDSGroupTableViewCell.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Kingfisher -import ReadiumShared -import UIKit - -class OPDSGroupTableViewCell: UITableViewCell { - var group: Group? - weak var opdsRootTableViewController: OPDSRootTableViewController? - weak var collectionView: UICollectionView? - - var browsingState: FeedBrowsingState = .None - - static let iPadLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 4, .landscape: 5] - static let iPhoneLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 3, .landscape: 4] - - lazy var layoutNumberPerRow: [UIUserInterfaceIdiom: [ScreenOrientation: Int]] = [ - .pad: OPDSGroupTableViewCell.iPadLayoutNumberPerRow, - .phone: OPDSGroupTableViewCell.iPhoneLayoutNumberPerRow, - ] - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - override func prepareForReuse() { - super.prepareForReuse() - collectionView?.setContentOffset(.zero, animated: false) - collectionView?.reloadData() - } - - override func layoutSubviews() { - super.layoutSubviews() - collectionView?.collectionViewLayout.invalidateLayout() - } -} - -extension OPDSGroupTableViewCell: UICollectionViewDataSource { - // MARK: - Collection view data source - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - var count = 0 - - if let group = group { - if group.publications.count > 0 { - count = group.publications.count - browsingState = .Publication - } else if group.navigation.count > 0 { - count = group.navigation.count - browsingState = .Navigation - } - } - - return count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - self.collectionView = collectionView - - if browsingState == .Publication { - collectionView.register(UINib(nibName: "PublicationCollectionViewCell", bundle: nil), - forCellWithReuseIdentifier: "publicationCollectionViewCell") - - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "publicationCollectionViewCell", - for: indexPath) as! PublicationCollectionViewCell - - cell.isAccessibilityElement = true - cell.accessibilityHint = NSLocalizedString("opds_show_detail_view_a11y_hint", comment: "Accessibility hint for OPDS publication cell") - - if let publication = group?.publications[indexPath.row] { - cell.accessibilityLabel = publication.metadata.title - - let titleTextView = OPDSPlaceholderListView( - frame: cell.frame, - title: publication.metadata.title, - author: publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url - ?? publication.images.first.flatMap { URL(string: $0.href) } - - if let coverURL = coverURL { - cell.coverImageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { _ in } - } - - cell.titleLabel.text = publication.metadata.title - cell.authorLabel.text = publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - } - - return cell - - } else { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "opdsNavigationCollectionViewCell", - for: indexPath) as! OPDSGroupCollectionViewCell - - if let navigation = group?.navigation[indexPath.row] { - cell.accessibilityLabel = navigation.title - - cell.navigationTitleLabel.text = navigation.title - if let count = navigation.properties.numberOfItems { - cell.navigationCountLabel.text = "\(count)" - } else { - cell.navigationCountLabel.text = "" - } - } - - return cell - } - } -} - -extension OPDSGroupTableViewCell: UICollectionViewDelegateFlowLayout { - // MARK: - Collection view delegate - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize - { - if browsingState == .Publication { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutNumberPerRow = layoutNumberPerRow[idiom] else { return CGSize(width: 0, height: 0) } - guard let numberPerRow = deviceLayoutNumberPerRow[.current] else { return CGSize(width: 0, height: 0) } - - let minimumSpacing: CGFloat = 5.0 - let labelHeight: CGFloat = 50.0 - let coverRatio: CGFloat = 1.5 - - let itemWidth = (collectionView.frame.width / CGFloat(numberPerRow)) - (CGFloat(minimumSpacing) * CGFloat(numberPerRow)) - minimumSpacing - let itemHeight = (itemWidth * coverRatio) + labelHeight - - return CGSize(width: itemWidth, height: itemHeight) - - } else { - return CGSize(width: 200, height: 50) - } - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if browsingState == .Publication { - if let publication = group?.publications[indexPath.row] { - let opdsPublicationInfoViewController: OPDSPublicationInfoViewController = OPDSFactory.shared.make(publication: publication) - opdsRootTableViewController?.navigationController?.pushViewController(opdsPublicationInfoViewController, animated: true) - } - - } else { - if let href = group?.navigation[indexPath.row].href, let url = URL(string: href) { - let newOPDSRootTableViewController: OPDSRootTableViewController = OPDSFactory.shared.make(feedURL: url, indexPath: nil) - opdsRootTableViewController?.navigationController?.pushViewController(newOPDSRootTableViewController, animated: true) - } - } - } -} diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index d1d61049db..7468714f76 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -46,21 +46,29 @@ final class OPDSModule: OPDSModuleAPI { private(set) lazy var rootViewController: UINavigationController = { let viewModel = OPDSCatalogsViewModel() - let catalogViewController = UIHostingController( - rootView: OPDSCatalogsView(viewModel: viewModel) - ) + let rootView = NavigationStack { + OPDSCatalogsView(viewModel: viewModel, delegate: self.delegate) + .navigationDestination(for: OPDSCatalog.self) { catalog in + OPDSFeedView( + feedURL: catalog.url, + delegate: self.delegate + ) + } + .navigationDestination(for: URL.self) { url in + OPDSFeedView(feedURL: url, delegate: self.delegate) + } + .navigationDestination(for: OPDSFeedView.NavigablePublication.self) { navPublication in + OPDSPublicationInfoView(publication: navPublication.publication) + } + } - let navigationController = UINavigationController( - rootViewController: catalogViewController - ) + let catalogViewController = UIHostingController(rootView: rootView) - viewModel.openCatalog = { [weak navigationController] url, indexPath in - let viewController = OPDSFactory.shared.make( - feedURL: url, - indexPath: indexPath - ) - navigationController?.pushViewController(viewController, animated: true) - } + let navigationController = UINavigationController(rootViewController: catalogViewController) + + navigationController.isNavigationBarHidden = true + + viewModel.openCatalog = nil return navigationController }() diff --git a/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift deleted file mode 100644 index c5aa72ee42..0000000000 --- a/TestApp/Sources/OPDS/OPDSNavigationTableViewCell.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import UIKit - -class OPDSNavigationTableViewCell: UITableViewCell { - @IBOutlet var title: UILabel! - @IBOutlet var count: UILabel! - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } -} diff --git a/TestApp/Sources/OPDS/OPDSPlaceholderView.swift b/TestApp/Sources/OPDS/OPDSPlaceholderView.swift index 51d50e9b01..fc513eaffa 100644 --- a/TestApp/Sources/OPDS/OPDSPlaceholderView.swift +++ b/TestApp/Sources/OPDS/OPDSPlaceholderView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -42,7 +42,7 @@ class OPDSPlaceholderListView: OPDSPlaceholderView, Placeholder {} // MARK: - Placeholder protocol specific to publication screen extension OPDSPlaceholderPublicationView { - public func add(to imageView: KFCrossPlatformImageView) { + func add(to imageView: KFCrossPlatformImageView) { imageView.addSubview(self) translatesAutoresizingMaskIntoConstraints = false diff --git a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift index 9d35d2d6ab..f1eb9b3be1 100644 --- a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift +++ b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift b/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift deleted file mode 100644 index 18452a7bb0..0000000000 --- a/TestApp/Sources/OPDS/OPDSPublicationTableViewCell.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Kingfisher -import ReadiumShared -import UIKit - -class OPDSPublicationTableViewCell: UITableViewCell { - @IBOutlet var collectionView: UICollectionView! - - var feed: Feed? - weak var opdsRootTableViewController: OPDSRootTableViewController? - - static let iPadLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 4, .landscape: 5] - static let iPhoneLayoutNumberPerRow: [ScreenOrientation: Int] = [.portrait: 3, .landscape: 4] - - lazy var layoutNumberPerRow: [UIUserInterfaceIdiom: [ScreenOrientation: Int]] = [ - .pad: OPDSPublicationTableViewCell.iPadLayoutNumberPerRow, - .phone: OPDSPublicationTableViewCell.iPhoneLayoutNumberPerRow, - ] - - override func awakeFromNib() { - super.awakeFromNib() - collectionView.register(UINib(nibName: "PublicationCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: "publicationCollectionViewCell") - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - override func layoutSubviews() { - super.layoutSubviews() - collectionView.collectionViewLayout.invalidateLayout() - } -} - -extension OPDSPublicationTableViewCell: UICollectionViewDataSource { - // MARK: - Collection view data source - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - feed?.publications.count ?? 0 - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "publicationCollectionViewCell", - for: indexPath) as! PublicationCollectionViewCell - - cell.isAccessibilityElement = true - cell.accessibilityHint = NSLocalizedString("opds_show_detail_view_a11y_hint", comment: "Accessibility hint for OPDS publication cell") - - if let publications = feed?.publications, let publication = feed?.publications[indexPath.row] { - cell.accessibilityLabel = publication.metadata.title - - let titleTextView = OPDSPlaceholderListView( - frame: cell.frame, - title: publication.metadata.title, - author: publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - ) - - let coverURL: URL? = publication.linkWithRel(.cover)?.url(relativeTo: publication.baseURL).url - ?? publication.images.first.flatMap { URL(string: $0.href) } - - if let coverURL = coverURL { - cell.coverImageView.kf.setImage( - with: coverURL, - placeholder: titleTextView, - options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil - ) { _ in } - } else { - cell.coverImageView.addSubview(titleTextView) - } - - cell.titleLabel.text = publication.metadata.title - cell.authorLabel.text = publication.metadata.authors - .map(\.name) - .joined(separator: ", ") - - if indexPath.row == publications.count - 3 { - opdsRootTableViewController?.loadNextPage(completionHandler: { feed in - self.feed = feed - collectionView.reloadData() - }) - } - } - - return cell - } -} - -extension OPDSPublicationTableViewCell: UICollectionViewDelegateFlowLayout { - // MARK: - Collection view delegate - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize - { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutNumberPerRow = layoutNumberPerRow[idiom] else { return CGSize(width: 0, height: 0) } - guard let numberPerRow = deviceLayoutNumberPerRow[.current] else { return CGSize(width: 0, height: 0) } - - let minimumSpacing: CGFloat = 5.0 - let labelHeight: CGFloat = 50.0 - let coverRatio: CGFloat = 1.5 - - let itemWidth = (collectionView.frame.width / CGFloat(numberPerRow)) - (CGFloat(minimumSpacing) * CGFloat(numberPerRow)) - minimumSpacing - let itemHeight = (itemWidth * coverRatio) + labelHeight - - return CGSize(width: itemWidth, height: itemHeight) - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if let publication = feed?.publications[indexPath.row] { - let opdsPublicationInfoViewController: OPDSPublicationInfoViewController = OPDSFactory.shared.make(publication: publication) - opdsRootTableViewController?.navigationController?.pushViewController(opdsPublicationInfoViewController, animated: true) - } - } -} diff --git a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift b/TestApp/Sources/OPDS/OPDSRootTableViewController.swift deleted file mode 100644 index a56bc3acf6..0000000000 --- a/TestApp/Sources/OPDS/OPDSRootTableViewController.swift +++ /dev/null @@ -1,603 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import ReadiumOPDS -import ReadiumShared -import SwiftUI -import UIKit - -enum FeedBrowsingState { - case Navigation - case Publication - case MixedGroup - case MixedNavigationPublication - case MixedNavigationGroup - case MixedNavigationGroupPublication - case None -} - -protocol OPDSRootTableViewControllerFactory { - func make(feedURL: URL, indexPath: IndexPath?) -> OPDSRootTableViewController -} - -class OPDSRootTableViewController: UITableViewController { - typealias Factory = - OPDSRootTableViewControllerFactory - - var factory: Factory! - var originalFeedURL: URL? - - var nextPageURL: URL? - var originalFeedIndexPath: IndexPath? - var mustEditFeed = false - - var parseData: ParseData? - var feed: Feed? - var publication: Publication? - - var browsingState: FeedBrowsingState = .None - - static let iPadLayoutHeightForRow: [ScreenOrientation: CGFloat] = [.portrait: 330, .landscape: 340] - static let iPhoneLayoutHeightForRow: [ScreenOrientation: CGFloat] = [.portrait: 230, .landscape: 280] - - lazy var layoutHeightForRow: [UIUserInterfaceIdiom: [ScreenOrientation: CGFloat]] = [ - .pad: OPDSRootTableViewController.iPadLayoutHeightForRow, - .phone: OPDSRootTableViewController.iPhoneLayoutHeightForRow, - ] - - override func viewDidLoad() { - super.viewDidLoad() - navigationController?.delegate = self - - parseFeed() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - tableView.reloadData() - } - - // MARK: - OPDS feed parsing - - func parseFeed() { - if let url = originalFeedURL { - OPDSParser.parseURL(url: url) { data, _ in - DispatchQueue.main.async { - if let data = data { - self.parseData = data - } - self.finishFeedInitialization() - } - } - } - } - - func finishFeedInitialization() { - if let feed = parseData?.feed { - self.feed = feed - - navigationItem.title = feed.metadata.title - nextPageURL = findNextPageURL(feed: feed) - - if feed.facets.count > 0 { - let filterButton = UIBarButtonItem( - title: NSLocalizedString("filter_button", comment: "Filter the OPDS feed"), - style: UIBarButtonItem.Style.plain, - target: self, - action: #selector(OPDSRootTableViewController.filterMenuClicked) - ) - navigationItem.rightBarButtonItem = filterButton - } - - // Check feed compozition. Then, browsingState will be used to build the UI. - if feed.navigation.count > 0, feed.groups.count == 0, feed.publications.count == 0 { - browsingState = .Navigation - } else if feed.publications.count > 0, feed.groups.count == 0, feed.navigation.count == 0 { - browsingState = .Publication - tableView.separatorStyle = .none - tableView.isScrollEnabled = false - } else if feed.groups.count > 0, feed.publications.count == 0, feed.navigation.count == 0 { - browsingState = .MixedGroup - } else if feed.navigation.count > 0, feed.groups.count == 0, feed.publications.count > 0 { - browsingState = .MixedNavigationPublication - } else if feed.navigation.count > 0, feed.groups.count > 0, feed.publications.count == 0 { - browsingState = .MixedNavigationGroup - } else if feed.navigation.count > 0, feed.groups.count > 0, feed.publications.count > 0 { - browsingState = .MixedNavigationGroupPublication - } else { - browsingState = .None - } - - } else { - tableView.backgroundView = UIView(frame: UIScreen.main.bounds) - tableView.separatorStyle = .none - - let frame = CGRect(x: 0, y: tableView.backgroundView!.bounds.height / 2, width: tableView.backgroundView!.bounds.width, height: 20) - - let messageLabel = UILabel(frame: frame) - messageLabel.textColor = UIColor.darkGray - messageLabel.textAlignment = .center - messageLabel.text = NSLocalizedString("opds_failure_message", comment: "Error message when the feed couldn't be loaded") - - let editButton = UIButton(type: .system) - editButton.frame = frame - editButton.setTitle(NSLocalizedString("opds_edit_button", comment: "Button to edit the OPDS catalog"), for: .normal) - editButton.addTarget(self, action: #selector(editButtonClicked), for: .touchUpInside) - editButton.isHidden = originalFeedIndexPath == nil ? true : false - - let stackView = UIStackView(arrangedSubviews: [messageLabel, editButton]) - stackView.axis = .vertical - stackView.distribution = .equalSpacing - let spacing: CGFloat = 15 - stackView.spacing = spacing - - tableView.backgroundView?.addSubview(stackView) - - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.widthAnchor.constraint(equalTo: tableView.backgroundView!.widthAnchor).isActive = true - stackView.heightAnchor.constraint(equalToConstant: messageLabel.frame.height + editButton.frame.height + spacing).isActive = true - stackView.centerYAnchor.constraint(equalTo: tableView.backgroundView!.centerYAnchor).isActive = true - } - - DispatchQueue.main.async { - self.tableView.reloadData() - } - } - - @objc func editButtonClicked(_ sender: UIBarButtonItem) { - mustEditFeed = true - navigationController?.popViewController(animated: true) - } - - func findNextPageURL(feed: Feed) -> URL? { - guard let href = feed.links.firstWithRel(.next)?.href else { - return nil - } - return URL(string: href) - } - - public func loadNextPage(completionHandler: @escaping (Feed?) -> Void) { - if let nextPageURL = nextPageURL { - OPDSParser.parseURL(url: nextPageURL) { data, _ in - DispatchQueue.main.async { - guard let newFeed = data?.feed else { - return - } - - self.nextPageURL = self.findNextPageURL(feed: newFeed) - self.feed?.publications.append(contentsOf: newFeed.publications) - completionHandler(self.feed) - } - } - } - } - - // MARK: - Facets - - @objc func filterMenuClicked(_ sender: UIBarButtonItem) { - guard let feed = feed else { - return - } - - let facetViewController = UIHostingController(rootView: OPDSFacetList( - feed: feed, - onLinkSelected: { [weak self] link in - self?.pushOpdsRootViewController(href: link.href) - } - )) - - facetViewController.modalPresentationStyle = UIModalPresentationStyle.popover - - present(facetViewController, animated: true, completion: nil) - - if let popoverPresentationController = facetViewController.popoverPresentationController { - popoverPresentationController.barButtonItem = sender - } - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - var numberOfSections = 0 - - switch browsingState { - case .Navigation, .Publication: - numberOfSections = 1 - - case .MixedGroup: - numberOfSections = feed!.groups.count - - case .MixedNavigationPublication: - numberOfSections = 2 - - case .MixedNavigationGroup: - // 1 section for the nav + groups count for the next sections - numberOfSections = 1 + feed!.groups.count - - case .MixedNavigationGroupPublication: - numberOfSections = 1 + feed!.groups.count + 1 - - default: - numberOfSections = 0 - } - - return numberOfSections - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - var numberOfRowsInSection = 0 - - switch browsingState { - case .Navigation: - numberOfRowsInSection = feed!.navigation.count - - case .Publication: - numberOfRowsInSection = 1 - - case .MixedGroup: - if feed!.groups[section].navigation.count > 0 { - numberOfRowsInSection = feed!.groups[section].navigation.count - } else { - numberOfRowsInSection = 1 - } - - case .MixedNavigationPublication: - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - if section == 1 { - numberOfRowsInSection = 1 - } - - case .MixedNavigationGroup: - // Nav - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - // Groups - if section >= 1, section <= feed!.groups.count { - if feed!.groups[section - 1].navigation.count > 0 { - // Nav inside a group - numberOfRowsInSection = feed!.groups[section - 1].navigation.count - } else { - // No nav inside a group - numberOfRowsInSection = 1 - } - } - - case .MixedNavigationGroupPublication: - if section == 0 { - numberOfRowsInSection = feed!.navigation.count - } - if section >= 1, section <= feed!.groups.count { - if feed!.groups[section - 1].navigation.count > 0 { - numberOfRowsInSection = feed!.groups[section - 1].navigation.count - } else { - numberOfRowsInSection = 1 - } - } - if section == (feed!.groups.count + 1) { - numberOfRowsInSection = 1 - } - - default: - numberOfRowsInSection = 0 - } - - return numberOfRowsInSection - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - var heightForRowAt: CGFloat = 0.0 - - switch browsingState { - case .Publication: - heightForRowAt = tableView.bounds.height - - case .MixedGroup: - if feed!.groups[indexPath.section].navigation.count > 0 { - heightForRowAt = 44 - } else { - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section]) - } - - case .MixedNavigationPublication: - if indexPath.section == 0 { - heightForRowAt = 44 - } else { - heightForRowAt = tableView.bounds.height / 2 - } - - case .MixedNavigationGroup: - // Nav - if indexPath.section == 0 { - heightForRowAt = 44 - // Group - } else { - // Nav inside a group - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - heightForRowAt = 44 - } else { - // No nav inside a group - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section - 1]) - } - } - - case .MixedNavigationGroupPublication: - if indexPath.section == 0 { - heightForRowAt = 44 - } else if indexPath.section >= 1, indexPath.section <= feed!.groups.count { - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - heightForRowAt = 44 - } else { - heightForRowAt = calculateRowHeightForGroup(feed!.groups[indexPath.section - 1]) - } - } else { - let group = ReadiumShared.Group(title: feed!.metadata.title) - group.publications = feed!.publications - heightForRowAt = calculateRowHeightForGroup(group) - } - - default: - heightForRowAt = 44 - } - - return heightForRowAt - } - - fileprivate func calculateRowHeightForGroup(_ group: ReadiumShared.Group) -> CGFloat { - if group.navigation.count > 0 { - return tableView.bounds.height / 2 - - } else { - let idiom = { () -> UIUserInterfaceIdiom in - let tempIdion = UIDevice.current.userInterfaceIdiom - return (tempIdion != .pad) ? .phone : .pad // ignnore carplay and others - }() - - guard let deviceLayoutHeightForRow = layoutHeightForRow[idiom] else { return 44 } - guard let heightForRow = deviceLayoutHeightForRow[.current] else { return 44 } - - return heightForRow - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - var title: String? - - switch browsingState { - case .MixedGroup: - if section >= 0, section <= feed!.groups.count { - title = feed!.groups[section].metadata.title - } - - case .MixedNavigationGroup: - // Nav - if section == 0 { - title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") - } - // Groups - if section >= 1, section <= feed!.groups.count { - title = feed!.groups[section - 1].metadata.title - } - - case .MixedNavigationGroupPublication: - if section == 0 { - title = NSLocalizedString("opds_browse_title", comment: "Title of the section displaying the feeds") - } - if section >= 1, section <= feed!.groups.count { - title = feed!.groups[section - 1].metadata.title - } - if section > feed!.groups.count { - title = feed!.metadata.title - } - - default: - title = nil - } - - return title - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell: UITableViewCell? - - switch browsingState { - case .Navigation: - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - - case .Publication: - cell = buildPublicationCell(tableView: tableView, indexPath: indexPath) - - case .MixedGroup: - cell = buildGroupCell(tableView: tableView, indexPath: indexPath) - - case .MixedNavigationPublication: - if indexPath.section == 0 { - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - cell = buildPublicationCell(tableView: tableView, indexPath: indexPath) - } - - case .MixedNavigationGroup, .MixedNavigationGroupPublication: - if indexPath.section == 0 { - // Nav - cell = buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - // Groups - cell = buildGroupCell(tableView: tableView, indexPath: indexPath) - } - - default: - cell = nil - } - - return cell! - } - - func buildNavigationCell(tableView: UITableView, indexPath: IndexPath) -> OPDSNavigationTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsNavigationCell", for: indexPath) as! OPDSNavigationTableViewCell - - var currentNavigation: [ReadiumShared.Link]? - - if let navigation = feed?.navigation, navigation.count > 0 { - currentNavigation = navigation - } else { - if let navigation = feed?.groups[indexPath.section].navigation, navigation.count > 0 { - currentNavigation = navigation - } - } - - if let currentNavigation = currentNavigation { - castedCell.title.text = currentNavigation[indexPath.row].title - if let count = currentNavigation[indexPath.row].properties.numberOfItems { - castedCell.count.text = "\(count)" - } else { - castedCell.count.text = "" - } - } - - return castedCell - } - - func buildPublicationCell(tableView: UITableView, indexPath: IndexPath) -> OPDSPublicationTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsPublicationCell", for: indexPath) as! OPDSPublicationTableViewCell - castedCell.feed = feed - castedCell.opdsRootTableViewController = self - return castedCell - } - - func buildGroupCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { - if browsingState != .MixedGroup { - if indexPath.section > feed!.groups.count { - let group = ReadiumShared.Group(title: feed!.metadata.title) - group.publications = feed!.publications - return preparedGroupCell(group: group, indexPath: indexPath, offset: 0) - } else { - if feed!.groups[indexPath.section - 1].navigation.count > 0 { - return buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - return preparedGroupCell(group: nil, indexPath: indexPath, offset: 1) - } - } - } else { - if feed!.groups[indexPath.section].navigation.count > 0 { - return buildNavigationCell(tableView: tableView, indexPath: indexPath) - } else { - return preparedGroupCell(group: nil, indexPath: indexPath, offset: 0) - } - } - } - - fileprivate func preparedGroupCell(group: ReadiumShared.Group?, indexPath: IndexPath, offset: Int) -> OPDSGroupTableViewCell { - let castedCell = tableView.dequeueReusableCell(withIdentifier: "opdsGroupCell", for: indexPath) as! OPDSGroupTableViewCell - castedCell.group = group != nil ? group : feed?.groups[indexPath.section - offset] - castedCell.opdsRootTableViewController = self - return castedCell - } - - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch browsingState { - case .Navigation, .MixedNavigationPublication, .MixedNavigationGroup, .MixedNavigationGroupPublication: - var link: ReadiumShared.Link? - if indexPath.section == 0 { - link = feed!.navigation[indexPath.row] - } else if indexPath.section >= 1, indexPath.section <= feed!.groups.count, feed!.groups[indexPath.section - 1].navigation.count > 0 { - link = feed!.groups[indexPath.section - 1].navigation[indexPath.row] - } - - if let link = link { - pushOpdsRootViewController(href: link.href) - } - - default: - break - } - } - - override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - let header = view as! UITableViewHeaderFooterView - header.isAccessibilityElement = false - - header.textLabel?.font = UIFont.boldSystemFont(ofSize: 13) - header.textLabel?.accessibilityHint = NSLocalizedString("opds_feed_header_a11y_hint", comment: "Accessibility hint feed section header") - - var offset: Int - - if browsingState != .MixedGroup { - offset = section - 1 - } else { - offset = section - } - - if let feed = feed { - if let moreButton = view.subviews.last as? OPDSMoreButton { - if offset >= 0, offset < feed.groups.count { - moreButton.offset = offset - } else { - view.subviews.last?.removeFromSuperview() - } - return - } - - if offset >= 0, offset < feed.groups.count { - let links = feed.groups[offset].links - if links.count > 0 { - let buttonWidth: CGFloat = 70 - let moreButton = OPDSMoreButton(type: .system) - moreButton.frame = CGRect(x: header.frame.width - buttonWidth, y: 0, width: buttonWidth, height: header.frame.height) - - moreButton.setTitle(NSLocalizedString("opds_more_button", comment: "Button to expand a feed gallery"), for: .normal) - moreButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 11) - moreButton.setTitleColor(UIColor.darkGray, for: .normal) - - moreButton.offset = offset - moreButton.addTarget(self, action: #selector(moreAction), for: .touchUpInside) - - moreButton.isAccessibilityElement = true - moreButton.accessibilityLabel = NSLocalizedString("opds_more_button_a11y_label", comment: "Button to expand a feed gallery") - - view.addSubview(moreButton) - - moreButton.translatesAutoresizingMaskIntoConstraints = false - moreButton.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true - moreButton.heightAnchor.constraint(equalToConstant: header.frame.height).isActive = true - moreButton.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - } - } - } - } - - // MARK: - Target action - - @objc func moreAction(sender: UIButton!) { - if let moreButton = sender as? OPDSMoreButton { - if let href = feed?.groups[moreButton.offset!].links[0].href { - pushOpdsRootViewController(href: href) - } - } - } -} - -// MARK: - UINavigationController delegate and tooling - -extension OPDSRootTableViewController: UINavigationControllerDelegate { - fileprivate func pushOpdsRootViewController(href: String) { - guard let url = URL(string: href) else { - return - } - - let viewController: OPDSRootTableViewController = factory.make(feedURL: url, indexPath: nil) - navigationController?.pushViewController(viewController, animated: true) - } -} - -// MARK: - Sublass of UIButton - -class OPDSMoreButton: UIButton { - var offset: Int? -} diff --git a/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift b/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift index 69680462f0..e8ac883f46 100644 --- a/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift +++ b/TestApp/Sources/Reader/Audiobook/AudiobookModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift b/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift index 8fe9d9bfd9..ea74ea20e3 100644 --- a/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift +++ b/TestApp/Sources/Reader/Audiobook/AudiobookViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/CBZ/CBZModule.swift b/TestApp/Sources/Reader/CBZ/CBZModule.swift deleted file mode 100644 index 63cd1ae29a..0000000000 --- a/TestApp/Sources/Reader/CBZ/CBZModule.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation -import ReadiumShared -import UIKit - -final class CBZModule: ReaderFormatModule { - weak var delegate: ReaderFormatModuleDelegate? - - init(delegate: ReaderFormatModuleDelegate?) { - self.delegate = delegate - } - - func supports(_ publication: Publication) -> Bool { - publication.conforms(to: .divina) - } - - @MainActor - func makeReaderViewController( - for publication: Publication, - locator: Locator?, - bookId: Book.Id, - books: BookRepository, - bookmarks: BookmarkRepository, - highlights: HighlightRepository, - readium: Readium - ) async throws -> UIViewController { - let cbzVC = try CBZViewController( - publication: publication, - locator: locator, - bookId: bookId, - books: books, - bookmarks: bookmarks, - httpServer: readium.httpServer - ) - cbzVC.moduleDelegate = delegate - return cbzVC - } -} diff --git a/TestApp/Sources/Reader/CBZ/CBZViewController.swift b/TestApp/Sources/Reader/CBZ/CBZViewController.swift deleted file mode 100644 index 3ecde62f2c..0000000000 --- a/TestApp/Sources/Reader/CBZ/CBZViewController.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import ReadiumNavigator -import ReadiumShared -import ReadiumStreamer -import UIKit - -class CBZViewController: VisualReaderViewController { - init( - publication: Publication, - locator: Locator?, - bookId: Book.Id, - books: BookRepository, - bookmarks: BookmarkRepository, - httpServer: HTTPServer - ) throws { - let navigator = try CBZNavigatorViewController( - publication: publication, - initialLocation: locator, - httpServer: httpServer - ) - - super.init( - navigator: navigator, - publication: publication, - bookId: bookId, - books: books, - bookmarks: bookmarks, - highlights: nil - ) - - navigator.delegate = self - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .black - } -} - -extension CBZViewController: CBZNavigatorDelegate {} diff --git a/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift b/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift index dc03dea363..972dbede8e 100644 --- a/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift +++ b/TestApp/Sources/Reader/Common/Bookmark/BookmarkCellView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift b/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift index a1f1fbd0a3..3b76ffd8f7 100644 --- a/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift +++ b/TestApp/Sources/Reader/Common/DRM/LCPManagementTableViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -29,7 +29,7 @@ import UIKit @IBOutlet var renewButton: UIButton! @IBOutlet var returnButton: UIButton! - public var viewModel: LCPViewModel! + var viewModel: LCPViewModel! weak var moduleDelegate: ReaderModuleDelegate? @@ -99,7 +99,7 @@ import UIKit present(alert, animated: true) } - internal func reload() { + func reload() { typeLabel.text = "Readium LCP" stateLabel.text = viewModel.state providerLabel.text = viewModel.provider diff --git a/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift b/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift index 07b3a4361c..bdcc80d511 100644 --- a/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift +++ b/TestApp/Sources/Reader/Common/DRM/LCPViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift b/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift index c9355916fe..9b4e1be45c 100644 --- a/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift +++ b/TestApp/Sources/Reader/Common/Highlight/HighlightCellView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift b/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift index 10bc44af22..edf4c5b90e 100644 --- a/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift +++ b/TestApp/Sources/Reader/Common/Highlight/HighlightContextMenu.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift index 89ddd58079..64fe706967 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -24,7 +24,7 @@ struct OutlineTableView: View { @ObservedObject private var highlightsModel: HighlightsViewModel @State private var selectedSection: OutlineSection = .tableOfContents - // Outlines (list of links) to display for each section. + /// Outlines (list of links) to display for each section. @State private var outlines: [OutlineSection: [(level: Int, link: ReadiumShared.Link)]] = [:] init(publication: Publication, bookId: Book.Id, bookmarkRepository: BookmarkRepository, highlightRepository: HighlightRepository) { @@ -78,6 +78,7 @@ struct OutlineTableView: View { } } .onAppear { bookmarksModel.loadIfNeeded() } + case .highlights: List(highlightsModel.highlights, id: \.self) { highlight in HighlightCellView(highlight: highlight) diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift b/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift index cd1b94f940..7222369086 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineViewModels.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -85,7 +85,7 @@ private protocol OutlineViewModelLoaderDelegate: AnyObject { func setLoadedValues(_ values: [T]) } -// This loader contains a state enum which can be used for expressive UI (loading progress, error handling etc). For this, status overlay view can be used (see https://stackoverflow.com/a/61858358/2567725). +/// This loader contains a state enum which can be used for expressive UI (loading progress, error handling etc). For this, status overlay view can be used (see https://stackoverflow.com/a/61858358/2567725). private final class OutlineViewModelLoader { weak var delegate: Delegate! private var state = State.ready diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index 407a251935..95ab1c1918 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -72,13 +72,14 @@ struct UserPreferences< userPreferences(editor: model.editor, commit: model.commit) } - @ViewBuilder func userPreferences(editor: PE, commit: @escaping () -> Void) -> some View { + func userPreferences(editor: PE, commit: @escaping () -> Void) -> some View { NavigationView { List { switch editor { case let editor as PDFPreferencesEditor: fixedLayoutUserPreferences( commit: commit, + fit: editor.fit, offsetFirstPage: editor.offsetFirstPage, pageSpacing: editor.pageSpacing, readingProgression: editor.readingProgression, @@ -123,7 +124,9 @@ struct UserPreferences< fixedLayoutUserPreferences( commit: commit, backgroundColor: editor.backgroundColor, + fit: editor.fit, language: editor.language, + nullableOffsetFirstPage: editor.offsetFirstPage, readingProgression: editor.readingProgression, spread: editor.spread ) @@ -173,6 +176,7 @@ struct UserPreferences< fit: AnyEnumPreference? = nil, language: AnyPreference? = nil, offsetFirstPage: AnyPreference? = nil, + nullableOffsetFirstPage: AnyPreference? = nil, pageSpacing: AnyRangePreference? = nil, readingProgression: AnyEnumPreference? = nil, scroll: AnyPreference? = nil, @@ -254,14 +258,22 @@ struct UserPreferences< } } ) - } - if let offsetFirstPage = offsetFirstPage { - toggleRow( - title: "Offset first page", - preference: offsetFirstPage, - commit: commit - ) + if let offsetFirstPage = offsetFirstPage { + toggleRow( + title: "Offset first page", + preference: offsetFirstPage, + commit: commit + ) + } + + if let nullableOffsetFirstPage = nullableOffsetFirstPage { + nullableBoolPickerRow( + title: "Offset first page", + preference: nullableOffsetFirstPage, + commit: commit + ) + } } } @@ -273,10 +285,9 @@ struct UserPreferences< commit: commit, formatValue: { v in switch v { - case .cover: return "Cover" - case .contain: return "Contain" + case .auto: return "Auto" + case .page: return "Page" case .width: return "Width" - case .height: return "Height" } } ) @@ -370,7 +381,7 @@ struct UserPreferences< stepperRow( title: "Columns", preference: columnCount, - commit: commit, + commit: commit ) } } @@ -584,7 +595,7 @@ struct UserPreferences< } /// User preferences screen for an audiobook. - @ViewBuilder func audioUserPreferences( + func audioUserPreferences( commit: @escaping () -> Void, volume: AnyRangePreference? = nil, speed: AnyRangePreference? = nil @@ -609,7 +620,7 @@ struct UserPreferences< } /// Component for a boolean `Preference` switchable with a `Toggle` button. - @ViewBuilder func toggleRow( + func toggleRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -623,7 +634,7 @@ struct UserPreferences< } /// Component for a boolean `Preference` switchable with a `Toggle` button. - @ViewBuilder func toggleRow( + func toggleRow( title: String, value: Binding, isActive: Bool, @@ -637,8 +648,30 @@ struct UserPreferences< } } + /// Component for a nullable boolean `Preference` displayed in a `Picker` view + /// with three options: Auto, Yes, No. + func nullableBoolPickerRow( + title: String, + preference: AnyPreference, + commit: @escaping () -> Void + ) -> some View { + preferenceRow( + isActive: preference.isEffective, + onClear: { preference.clear(); commit() } + ) { + Picker(title, selection: Binding( + get: { preference.value ?? preference.effectiveValue }, + set: { preference.set($0); commit() } + )) { + Text("Auto").tag(nil as Bool?) + Text("Yes").tag(true as Bool?) + Text("No").tag(false as Bool?) + } + } + } + /// Component for an `EnumPreference` displayed in a `Picker` view. - @ViewBuilder func pickerRow( + func pickerRow( title: String, preference: AnyEnumPreference, commit: @escaping () -> Void, @@ -655,7 +688,7 @@ struct UserPreferences< } /// Component for an `EnumPreference` displayed in a `Picker` view. - @ViewBuilder func pickerRow( + func pickerRow( title: String, value: Binding, values: [V], @@ -676,7 +709,7 @@ struct UserPreferences< } /// Component for a `RangePreference` modifiable by a `Stepper` view. - @ViewBuilder func stepperRow( + func stepperRow( title: String, preference: AnyRangePreference, commit: @escaping () -> Void @@ -692,7 +725,7 @@ struct UserPreferences< } /// Component for a `RangePreference` modifiable by a `Stepper` view. - @ViewBuilder func stepperRow( + func stepperRow( title: String, value: String, isActive: Bool, @@ -716,7 +749,7 @@ struct UserPreferences< } /// Component for a `Preference` holding a `Language` value. - @ViewBuilder func languageRow( + func languageRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -737,7 +770,7 @@ struct UserPreferences< } /// Component for a `Preference` holding a `Color` value. - @ViewBuilder func colorRow( + func colorRow( title: String, preference: AnyPreference, commit: @escaping () -> Void @@ -757,7 +790,7 @@ struct UserPreferences< } /// Component for a `Preference` holding a `Color` value. - @ViewBuilder func colorRow( + func colorRow( title: String, value: Binding, isActive: Bool, @@ -774,7 +807,7 @@ struct UserPreferences< } /// Layout for a preference row. - @ViewBuilder func preferenceRow( + func preferenceRow( isActive: Bool, onClear: @escaping () -> Void, content: @escaping () -> V diff --git a/TestApp/Sources/Reader/Common/ReaderViewController.swift b/TestApp/Sources/Reader/Common/ReaderViewController.swift index 6b746bbffb..921045f8ef 100644 --- a/TestApp/Sources/Reader/Common/ReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/ReaderViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -227,7 +227,7 @@ class ReaderViewController: UIViewController, // MARK: - UIPopoverPresentationControllerDelegate - // Prevent the popOver to be presented fullscreen on iPhones. + /// Prevent the popOver to be presented fullscreen on iPhones. func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { .none } diff --git a/TestApp/Sources/Reader/Common/Search/SearchView.swift b/TestApp/Sources/Reader/Common/Search/SearchView.swift index 7b9ae886db..fda7c372a8 100644 --- a/TestApp/Sources/Reader/Common/Search/SearchView.swift +++ b/TestApp/Sources/Reader/Common/Search/SearchView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift b/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift index bbcba00efc..fd2de49d27 100644 --- a/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift +++ b/TestApp/Sources/Reader/Common/Search/SearchViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,21 +8,21 @@ import Foundation import ReadiumOPDS import ReadiumShared -// See https://github.com/readium/r2-testapp-swift/discussions/402 +/// See https://github.com/readium/r2-testapp-swift/discussions/402 @MainActor final class SearchViewModel: ObservableObject { enum State { - // Empty state / waiting for a search query + /// Empty state / waiting for a search query case empty - // Starting a new search, after calling `publication.search(...)` + /// Starting a new search, after calling `publication.search(...)` case starting - // Waiting state after receiving a SearchIterator and waiting for a next() call + /// Waiting state after receiving a SearchIterator and waiting for a next() call case idle(SearchIterator) - // Loading the next page of result + /// Loading the next page of result case loadingNext(SearchIterator, Task) - // We reached the end of the search results + /// We reached the end of the search results case end - // An error occurred, we need to show it to the user + /// An error occurred, we need to show it to the user case failure(SearchError) } diff --git a/TestApp/Sources/Reader/Common/TTS/TTSView.swift b/TestApp/Sources/Reader/Common/TTS/TTSView.swift index 9fc69e142d..43f00b81c1 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSView.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSView.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -95,7 +95,7 @@ struct TTSSettings: View { .navigationViewStyle(.stack) } - @ViewBuilder private func picker( + private func picker( caption: String, for keyPath: WritableKeyPath, choices: [T], @@ -125,7 +125,7 @@ private extension Optional where Wrapped == TTSVoice { guard case let .some(voice) = self else { return "Default" } - var desc = voice.name ?? "Voice" + var desc = voice.name if let region = voice.language.localizedRegion() { desc += " (\(region))" } diff --git a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift index 0d74896ee0..e8da6f9a3e 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -174,7 +174,7 @@ final class TTSViewModel: ObservableObject, Loggable { } extension TTSViewModel: PublicationSpeechSynthesizerDelegate { - public func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, stateDidChange synthesizerState: PublicationSpeechSynthesizer.State) { + func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, stateDidChange synthesizerState: PublicationSpeechSynthesizer.State) { switch synthesizerState { case .stopped: state.showControls = false @@ -197,7 +197,7 @@ extension TTSViewModel: PublicationSpeechSynthesizerDelegate { } } - public func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, utterance: PublicationSpeechSynthesizer.Utterance, didFailWithError error: PublicationSpeechSynthesizer.Error) { + func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, utterance: PublicationSpeechSynthesizer.Utterance, didFailWithError error: PublicationSpeechSynthesizer.Error) { // FIXME: log(.error, error) } diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index ad81edc043..747f52ebec 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -76,11 +76,11 @@ class VisualReaderViewController: ReaderViewCon // return false // }) - /// This adapter will automatically turn pages when the user taps the - /// screen edges or press arrow keys. - /// - /// Bind it to the navigator before adding your own observers to prevent - /// triggering your actions when turning pages. + // This adapter will automatically turn pages when the user taps the + // screen edges or press arrow keys. + // + // Bind it to the navigator before adding your own observers to prevent + // triggering your actions when turning pages. DirectionalNavigationAdapter( pointerPolicy: .init(types: [.mouse, .touch]) ).bind(to: navigator) diff --git a/TestApp/Sources/Reader/EPUB/EPUBModule.swift b/TestApp/Sources/Reader/EPUB/EPUBModule.swift index 25867167f8..9fe245c2d8 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBModule.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -17,7 +17,7 @@ final class EPUBModule: ReaderFormatModule { } func supports(_ publication: Publication) -> Bool { - publication.conforms(to: .epub) || publication.readingOrder.allAreHTML + publication.conforms(to: .epub) || publication.conforms(to: .divina) || publication.readingOrder.allAreHTML } @MainActor @@ -30,10 +30,6 @@ final class EPUBModule: ReaderFormatModule { highlights: HighlightRepository, readium: Readium ) async throws -> UIViewController { - guard publication.metadata.identifier != nil else { - throw ReaderError.epubNotValid - } - let preferencesStore = makePreferencesStore(books: books) let epubViewController = try await EPUBViewController( publication: publication, @@ -43,8 +39,7 @@ final class EPUBModule: ReaderFormatModule { bookmarks: bookmarks, highlights: highlights, initialPreferences: preferencesStore.preferences(for: bookId), - preferencesStore: preferencesStore, - httpServer: readium.httpServer + preferencesStore: preferencesStore ) epubViewController.moduleDelegate = delegate return epubViewController diff --git a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift index 7315ea093c..06b1efa6cc 100644 --- a/TestApp/Sources/Reader/EPUB/EPUBViewController.swift +++ b/TestApp/Sources/Reader/EPUB/EPUBViewController.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -12,7 +12,7 @@ import UIKit import WebKit public extension FontFamily { - // Example of adding a custom font embedded in the application. + /// Example of adding a custom font embedded in the application. static let literata: FontFamily = "Literata" } @@ -27,8 +27,7 @@ class EPUBViewController: VisualReaderViewController, - httpServer: HTTPServer + preferencesStore: AnyUserPreferencesStore ) throws { var templates = HTMLDecorationTemplate.defaultTemplates() templates[.pageList] = .pageList @@ -61,8 +60,7 @@ class EPUBViewController: VisualReaderViewController { - override public init(rootView: OutlineTableView) { + override init(rootView: OutlineTableView) { super.init(rootView: rootView) navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed)), animated: true) } diff --git a/TestApp/Sources/Reader/ReaderFormatModule.swift b/TestApp/Sources/Reader/ReaderFormatModule.swift index ba4fc210f1..f290cabf88 100644 --- a/TestApp/Sources/Reader/ReaderFormatModule.swift +++ b/TestApp/Sources/Reader/ReaderFormatModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Reader/ReaderModule.swift b/TestApp/Sources/Reader/ReaderModule.swift index b71d040d7f..9d6473a026 100644 --- a/TestApp/Sources/Reader/ReaderModule.swift +++ b/TestApp/Sources/Reader/ReaderModule.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -48,7 +48,6 @@ final class ReaderModule: ReaderModuleAPI { formatModules = [ AudiobookModule(delegate: self), - CBZModule(delegate: self), EPUBModule(delegate: self), PDFModule(delegate: self), ] diff --git a/TestApp/Sources/Reader/Toast.swift b/TestApp/Sources/Reader/Toast.swift index f5241860a1..e6805fd93a 100644 --- a/TestApp/Sources/Reader/Toast.swift +++ b/TestApp/Sources/Reader/Toast.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/TestApp/Sources/Resources/en.lproj/Localizable.strings b/TestApp/Sources/Resources/en.lproj/Localizable.strings index 63053357df..e875e70b71 100644 --- a/TestApp/Sources/Resources/en.lproj/Localizable.strings +++ b/TestApp/Sources/Resources/en.lproj/Localizable.strings @@ -29,6 +29,7 @@ "error_io" = "A file system error occurred."; "error_network" = "A networking error occurred."; "error_not_found" = "The resource was not found."; +"error_out_of_space" = "Your storage is full. Please free up some space to continue."; "error_read" = "Failed to read the resource."; diff --git a/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift b/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift index 6cbf9c0aaa..96c5614f2e 100644 --- a/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift +++ b/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/InternalTests/Extensions/RangeTests.swift b/Tests/InternalTests/Extensions/RangeTests.swift new file mode 100644 index 0000000000..82f63663c4 --- /dev/null +++ b/Tests/InternalTests/Extensions/RangeTests.swift @@ -0,0 +1,125 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumInternal +import Testing + +@Suite struct RangeTests { + @Suite struct HTTPByteRangeParsing { + let totalLength: UInt64 = 10000 + + // MARK: - Closed range: bytes=N-M + + @Test func closedRange() { + #expect(Range(httpRange: "bytes=0-1023", in: totalLength) == 0 ..< 1024) + } + + @Test func closedRangeStartingAtNonZero() { + #expect(Range(httpRange: "bytes=500-999", in: totalLength) == 500 ..< 1000) + } + + @Test func closedRangeSingleByte() { + #expect(Range(httpRange: "bytes=0-0", in: totalLength) == 0 ..< 1) + } + + @Test func closedRangeClampedToTotalLength() { + #expect(Range(httpRange: "bytes=9000-20000", in: totalLength) == 9000 ..< 10000) + } + + @Test func closedRangeEndEqualsTotalLength() { + #expect(Range(httpRange: "bytes=9000-9999", in: totalLength) == 9000 ..< 10000) + } + + @Test func closedRangeReversedIsNil() { + #expect(Range(httpRange: "bytes=500-100", in: totalLength) == nil) + } + + @Test func closedRangeStartBeyondTotalLengthIsNil() { + #expect(Range(httpRange: "bytes=10000-10001", in: totalLength) == nil) + } + + // MARK: - Open-ended range: bytes=N- + + @Test func openEndedRange() { + #expect(Range(httpRange: "bytes=1024-", in: totalLength) == 1024 ..< 10000) + } + + @Test func openEndedRangeFromStart() { + #expect(Range(httpRange: "bytes=0-", in: totalLength) == 0 ..< 10000) + } + + @Test func openEndedRangeAtLastByte() { + #expect(Range(httpRange: "bytes=9999-", in: totalLength) == 9999 ..< 10000) + } + + @Test func openEndedRangeAtTotalLengthIsNil() { + #expect(Range(httpRange: "bytes=10000-", in: totalLength) == nil) + } + + @Test func openEndedRangeBeyondTotalLengthIsNil() { + #expect(Range(httpRange: "bytes=20000-", in: totalLength) == nil) + } + + // MARK: - Suffix range: bytes=-N + + @Test func suffixRange() { + #expect(Range(httpRange: "bytes=-512", in: totalLength) == 9488 ..< 10000) + } + + @Test func suffixRangeLargerThanTotalLength() { + #expect(Range(httpRange: "bytes=-20000", in: totalLength) == 0 ..< 10000) + } + + @Test func suffixRangeEqualToTotalLength() { + #expect(Range(httpRange: "bytes=-10000", in: totalLength) == 0 ..< 10000) + } + + @Test func suffixRangeZeroIsNil() { + #expect(Range(httpRange: "bytes=-0", in: totalLength) == nil) + } + + // MARK: - Malformed / absent + + @Test func emptyStringIsNil() { + #expect(Range(httpRange: "", in: totalLength) == nil) + } + + @Test func missingPrefixIsNil() { + #expect(Range(httpRange: "0-1023", in: totalLength) == nil) + } + + @Test func wrongPrefixIsNil() { + #expect(Range(httpRange: "chars=0-1023", in: totalLength) == nil) + } + + @Test func nonNumericStartIsNil() { + #expect(Range(httpRange: "bytes=abc-1023", in: totalLength) == nil) + } + + @Test func nonNumericEndIsNil() { + #expect(Range(httpRange: "bytes=0-abc", in: totalLength) == nil) + } + + @Test func missingDashIsNil() { + #expect(Range(httpRange: "bytes=1024", in: totalLength) == nil) + } + + // MARK: - Zero total length + + @Test func zeroTotalLengthClosedRangeIsNil() { + #expect(Range(httpRange: "bytes=0-0", in: 0) == nil) + } + + @Test func zeroTotalLengthOpenEndedIsNil() { + #expect(Range(httpRange: "bytes=0-", in: 0) == nil) + } + + @Test func zeroTotalLengthSuffixIsNil() { + #expect(Range(httpRange: "bytes=-0", in: 0) == nil) + } + } +} diff --git a/Tests/InternalTests/Extensions/StringTests.swift b/Tests/InternalTests/Extensions/StringTests.swift index 490e8d28d1..880c60f1d3 100644 --- a/Tests/InternalTests/Extensions/StringTests.swift +++ b/Tests/InternalTests/Extensions/StringTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/InternalTests/Extensions/URLTests.swift b/Tests/InternalTests/Extensions/URLTests.swift index d504656ba9..ea30e49309 100644 --- a/Tests/InternalTests/Extensions/URLTests.swift +++ b/Tests/InternalTests/Extensions/URLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,12 +10,12 @@ import XCTest class URLTests: XCTestCase { func testAddingSchemeWhenMissing() { XCTAssertEqual( - URL(string: "//www.google.com/path")!.addingSchemeWhenMissing("test"), - URL(string: "test://www.google.com/path")! + URL(string: "//www.google.com/path")?.addingSchemeWhenMissing("test"), + URL(string: "test://www.google.com/path") ) XCTAssertEqual( - URL(string: "http://www.google.com/path")!.addingSchemeWhenMissing("test"), - URL(string: "http://www.google.com/path")! + URL(string: "http://www.google.com/path")?.addingSchemeWhenMissing("test"), + URL(string: "http://www.google.com/path") ) } } diff --git a/Tests/InternalTests/KeychainTests.swift b/Tests/InternalTests/KeychainTests.swift new file mode 100644 index 0000000000..c5c285a87f --- /dev/null +++ b/Tests/InternalTests/KeychainTests.swift @@ -0,0 +1,235 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumInternal +import Testing + +// FIXME: Keychain testing require an host application with entitlements. +/* + @Suite struct KeychainTests { + let keychain: Keychain + let testServiceName = "org.readium.lcp.test.keychain-helper" + + init() throws { + keychain = Keychain( + serviceName: testServiceName, + synchronizable: false + ) + // Clean up any existing test data + try? keychain.deleteAll() + } + + // MARK: - Save Tests + + @Test func saveData() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "test-key") + + let retrieved = try keychain.load(forKey: "test-key") + #expect(retrieved == testData) + } + + @Test func saveDuplicateKeyThrowsError() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "duplicate-key") + + #expect(throws: KeychainError.self) { + try keychain.save(data: testData, forKey: "duplicate-key") + } + } + + @Test func saveMultipleKeys() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + let data3 = "Value 3".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + try keychain.save(data: data3, forKey: "key3") + + let loaded1 = try keychain.load(forKey: "key1") + let loaded2 = try keychain.load(forKey: "key2") + let loaded3 = try keychain.load(forKey: "key3") + #expect(loaded1 == data1) + #expect(loaded2 == data2) + #expect(loaded3 == data3) + } + + // MARK: - Load Tests + + @Test func loadNonExistentKeyReturnsNil() throws { + defer { try? keychain.deleteAll() } + + let result = try keychain.load(forKey: "non-existent") + #expect(result == nil) + } + + @Test func loadAfterSave() throws { + defer { try? keychain.deleteAll() } + + let testData = "Persistent Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "persistent-key") + + let loaded = try keychain.load(forKey: "persistent-key") + #expect(loaded == testData) + } + + // MARK: - Update Tests + + @Test func updateExistingKey() throws { + defer { try? keychain.deleteAll() } + + let originalData = "Original".data(using: .utf8)! + let updatedData = "Updated".data(using: .utf8)! + + try keychain.save(data: originalData, forKey: "update-key") + try keychain.update(data: updatedData, forKey: "update-key") + + let result = try keychain.load(forKey: "update-key") + #expect(result == updatedData) + } + + @Test func updateNonExistentKeyThrowsError() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test".data(using: .utf8)! + + #expect(throws: KeychainError.self) { + try keychain.update(data: testData, forKey: "non-existent") + } + } + + // MARK: - Delete Tests + + @Test func deleteExistingKey() throws { + defer { try? keychain.deleteAll() } + + let testData = "Delete Me".data(using: .utf8)! + try keychain.save(data: testData, forKey: "delete-key") + + try keychain.delete(forKey: "delete-key") + + let result = try keychain.load(forKey: "delete-key") + #expect(result == nil) + } + + @Test func deleteNonExistentKeyDoesNotThrow() throws { + defer { try? keychain.deleteAll() } + + // Should not throw an error + #expect(throws: Never.self) { + try keychain.delete(forKey: "non-existent") + } + } + + // MARK: - DeleteAll Tests + + @Test func deleteAll() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + let data3 = "Value 3".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + try keychain.save(data: data3, forKey: "key3") + + try keychain.deleteAll() + + let loaded1 = try keychain.load(forKey: "key1") + let loaded2 = try keychain.load(forKey: "key2") + let loaded3 = try keychain.load(forKey: "key3") + #expect(loaded1 == nil) + #expect(loaded2 == nil) + #expect(loaded3 == nil) + } + + @Test func deleteAllWithNoItemsDoesNotThrow() throws { + #expect(throws: Never.self) { + try keychain.deleteAll() + } + } + + // MARK: - AllKeys Tests + + @Test func allKeysEmpty() throws { + defer { try? keychain.deleteAll() } + + let keys = try keychain.allKeys() + #expect(keys.isEmpty) + } + + @Test func allKeysReturnsSavedKeys() throws { + defer { try? keychain.deleteAll() } + + let data = "Test".data(using: .utf8)! + try keychain.save(data: data, forKey: "key1") + try keychain.save(data: data, forKey: "key2") + try keychain.save(data: data, forKey: "key3") + + let keys = try keychain.allKeys() + #expect(Set(keys) == Set(["key1", "key2", "key3"])) + } + + // MARK: - AllItems Tests + + @Test func allItemsEmpty() throws { + defer { try? keychain.deleteAll() } + + let items = try keychain.allItems() + #expect(items.isEmpty) + } + + @Test func allItemsReturnsSavedData() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + + let items = try keychain.allItems() + #expect(items.count == 2) + #expect(items["key1"] == data1) + #expect(items["key2"] == data2) + } + + // MARK: - Service Isolation Tests + + @Test func serviceIsolation() throws { + // Create two keychains with different service names + let keychain1 = Keychain( + serviceName: "org.readium.lcp.test.service1", + synchronizable: false + ) + let keychain2 = Keychain( + serviceName: "org.readium.lcp.test.service2", + synchronizable: false + ) + + defer { + try? keychain1.deleteAll() + try? keychain2.deleteAll() + } + + let data = "Test".data(using: .utf8)! + try keychain1.save(data: data, forKey: "shared-key") + + // keychain2 should not see the data from keychain1 + let loaded = try keychain2.load(forKey: "shared-key") + #expect(loaded == nil) + } + } + */ diff --git a/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift b/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift index 9a6c179f54..d163169677 100644 --- a/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift +++ b/Tests/LCPTests/Content Protection/LCPDecryptorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -34,7 +34,7 @@ class LCPDecryptorTests: XCTestCase { } /// Checks that we can decrypt the full content successfully. - func testDecryptFull() throws { + func testDecryptFull() { retrieveLicense(path: "daisy.lcpdf", passphrase: "test") { license in let decryptedResource = LCPDecryptor(license: license).decrypt(resource: self.encryptedResource) @@ -43,7 +43,7 @@ class LCPDecryptorTests: XCTestCase { } /// Checks that we can decrypt various ranges successfully. - func testDecryptRanges() throws { + func testDecryptRanges() { retrieveLicense(path: "daisy.lcpdf", passphrase: "test") { license in let decryptedResource = LCPDecryptor(license: license).decrypt(resource: self.encryptedResource) diff --git a/Tests/LCPTests/Fixtures.swift b/Tests/LCPTests/Fixtures.swift index 12fe8585bf..a14e0787ee 100644 --- a/Tests/LCPTests/Fixtures.swift +++ b/Tests/LCPTests/Fixtures.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/LCPTests/LCPTestClient.swift b/Tests/LCPTests/LCPTestClient.swift index 275e72ce49..83ddeb53c4 100644 --- a/Tests/LCPTests/LCPTestClient.swift +++ b/Tests/LCPTests/LCPTestClient.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift new file mode 100644 index 0000000000..94340e3018 --- /dev/null +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift @@ -0,0 +1,444 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumLCP +import ReadiumShared +import Testing + +@Suite struct LCPKeychainLicenseRepositoryTests { + let repository: LCPKeychainLicenseRepository + + init() throws { + repository = LCPKeychainLicenseRepository( + synchronizable: false + ) + // Clean up any existing test data + try? cleanupAllTestData() + } + + private func cleanupAllTestData() throws { + // Delete all test licenses by using the Keychain directly + let keychain = Keychain( + serviceName: "org.readium.lcp.licenses", + synchronizable: false + ) + try keychain.deleteAll() + } + + // MARK: - Test Helpers + + private func createTestLicenseDocument( + id: String = UUID().uuidString, + printLimit: Int? = 10, + copyLimit: Int? = 100 + ) throws -> LicenseDocument { + let licenseJSON = """ + { + "provider": "https://test.provider.com", + "id": "\(id)", + "issued": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + "encryption": { + "profile": "http://readium.org/lcp/basic-profile", + "content_key": { + "algorithm": "http://www.w3.org/2001/04/xmlenc#aes256-cbc", + "encrypted_value": "dGVzdA==" + }, + "user_key": { + "algorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "text_hint": "Enter your passphrase", + "key_check": "dGVzdA==" + } + }, + "links": [ + { + "rel": "publication", + "href": "https://test.com/publication", + "type": "application/epub+zip" + } + ], + "user": { + "id": "user123", + "email": "test@example.com", + "name": "Test User" + }, + "rights": { + \(printLimit != nil ? "\"print\": \(printLimit!)," : "") + \(copyLimit != nil ? "\"copy\": \(copyLimit!)," : "") + "start": "2024-01-01T00:00:00Z" + }, + "signature": { + "algorithm": "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + "certificate": "dGVzdA==", + "value": "dGVzdA==" + } + } + """ + + let data = licenseJSON.data(using: .utf8)! + return try LicenseDocument(data: data) + } + + // MARK: - AddLicense Tests + + @Test func addLicenseNewLicense() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-license-1", + printLimit: 5, + copyLimit: 50 + ) + + try await repository.addLicense(license) + + // Verify the license was added + let storedLicense = try await repository.license(for: license.id) + #expect(storedLicense != nil) + #expect(storedLicense?.id == license.id) + + // Verify user rights were initialized + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 5) + #expect(rights.copy == 50) + + // Verify device not registered initially + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(!registered) + } + + @Test func addLicenseExistingLicenseDoesNotOverwriteRights() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-license-2", + printLimit: 10, + copyLimit: 100 + ) + + // Add license first time + try await repository.addLicense(license) + + // Consume some rights + try await repository.updateUserRights(for: license.id) { rights in + rights.print = 5 + rights.copy = 50 + } + + // Add license again (simulating re-adding same license) + try await repository.addLicense(license) + + // Rights should NOT be reset + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 5) + #expect(rights.copy == 50) + } + + @Test func addLicenseWithNilRights() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-license-unlimited", + printLimit: nil, + copyLimit: nil + ) + + try await repository.addLicense(license) + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == nil) + #expect(rights.copy == nil) + } + + // MARK: - License Retrieval Tests + + @Test func licenseRetrievalReturnsStoredDocument() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-license-retrieve") + + try await repository.addLicense(license) + + let retrieved = try await repository.license(for: license.id) + #expect(retrieved != nil) + #expect(retrieved?.id == license.id) + #expect(retrieved?.provider == license.provider) + #expect(retrieved?.user.id == license.user.id) + } + + @Test func licenseRetrievalNonExistentReturnsNil() async throws { + defer { try? cleanupAllTestData() } + + let retrieved = try await repository.license(for: "non-existent-license") + #expect(retrieved == nil) + } + + // MARK: - Device Registration Tests + + @Test func deviceRegistrationInitiallyFalse() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-registration-1") + try await repository.addLicense(license) + + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(!registered) + } + + @Test func registerDevice() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-registration-2") + try await repository.addLicense(license) + + try await repository.registerDevice(for: license.id) + + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(registered) + } + + @Test func registerDeviceIdempotent() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument(id: "test-registration-3") + try await repository.addLicense(license) + + try await repository.registerDevice(for: license.id) + try await repository.registerDevice(for: license.id) + + let registered = try await repository.isDeviceRegistered(for: license.id) + #expect(registered) + } + + @Test func deviceRegistrationNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + _ = try await repository.isDeviceRegistered(for: "non-existent") + } + } + + @Test func registerDeviceNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + try await repository.registerDevice(for: "non-existent") + } + } + + // MARK: - User Rights Tests + + @Test func userRightsRetrieval() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-1", + printLimit: 20, + copyLimit: 200 + ) + try await repository.addLicense(license) + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 20) + #expect(rights.copy == 200) + } + + @Test func userRightsNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + _ = try await repository.userRights(for: "non-existent") + } + } + + @Test func updateUserRights() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-update", + printLimit: 10, + copyLimit: 100 + ) + try await repository.addLicense(license) + + try await repository.updateUserRights(for: license.id) { rights in + rights.print = 5 + rights.copy = 50 + } + + let updatedRights = try await repository.userRights(for: license.id) + #expect(updatedRights.print == 5) + #expect(updatedRights.copy == 50) + } + + @Test func updateUserRightsDecrement() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-decrement", + printLimit: 10, + copyLimit: 100 + ) + try await repository.addLicense(license) + + // Simulate consuming a print + try await repository.updateUserRights(for: license.id) { rights in + if let currentPrint = rights.print { + rights.print = max(0, currentPrint - 1) + } + } + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 9) + #expect(rights.copy == 100) + } + + @Test func updateUserRightsToNil() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "test-rights-nil", + printLimit: 10, + copyLimit: 100 + ) + try await repository.addLicense(license) + + try await repository.updateUserRights(for: license.id) { rights in + rights.print = nil + rights.copy = nil + } + + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == nil) + #expect(rights.copy == nil) + } + + @Test func updateUserRightsNonExistentLicenseThrows() async throws { + defer { try? cleanupAllTestData() } + + await #expect(throws: (any Error).self) { + try await repository.updateUserRights(for: "non-existent") { _ in } + } + } + + // MARK: - Concurrency Tests + + @Test func concurrentAddLicense() async throws { + defer { try? cleanupAllTestData() } + + let licenses = try (0 ..< 5).map { index in + try createTestLicenseDocument(id: "concurrent-\(index)") + } + + // Add licenses concurrently + try await withThrowingTaskGroup(of: Void.self) { group in + for license in licenses { + group.addTask { + try await repository.addLicense(license) + } + } + try await group.waitForAll() + } + + // Verify all licenses were added + for license in licenses { + let stored = try await repository.license(for: license.id) + #expect(stored != nil) + } + } + + @Test func concurrentUserRightsUpdate() async throws { + defer { try? cleanupAllTestData() } + + let license = try createTestLicenseDocument( + id: "concurrent-rights", + printLimit: 100, + copyLimit: 1000 + ) + try await repository.addLicense(license) + + // Perform concurrent updates + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0 ..< 10 { + group.addTask { + try await repository.updateUserRights(for: license.id) { rights in + if let currentPrint = rights.print { + rights.print = max(0, currentPrint - 1) + } + } + } + } + try await group.waitForAll() + } + + // Verify rights were decremented correctly + // Note: Due to actor serialization, all updates should apply + let rights = try await repository.userRights(for: license.id) + #expect(rights.print == 90) + } + + // MARK: - Clear Tests + + @Test func clearRemovesAllLicenses() async throws { + defer { try? cleanupAllTestData() } + + let license1 = try createTestLicenseDocument(id: "clear-1") + let license2 = try createTestLicenseDocument(id: "clear-2") + let license3 = try createTestLicenseDocument(id: "clear-3") + + try await repository.addLicense(license1) + try await repository.addLicense(license2) + try await repository.addLicense(license3) + + try await repository.clear() + + // Verify all licenses are gone + #expect(try await repository.license(for: "clear-1") == nil) + #expect(try await repository.license(for: "clear-2") == nil) + #expect(try await repository.license(for: "clear-3") == nil) + } + + @Test func clearOnEmptyRepositorySucceeds() async throws { + defer { try? cleanupAllTestData() } + + try await repository.clear() + } + + // MARK: - Multiple License Tests + + @Test func multipleLicenses() async throws { + defer { try? cleanupAllTestData() } + + let license1 = try createTestLicenseDocument( + id: "multi-1", + printLimit: 5, + copyLimit: 50 + ) + let license2 = try createTestLicenseDocument( + id: "multi-2", + printLimit: 10, + copyLimit: 100 + ) + let license3 = try createTestLicenseDocument( + id: "multi-3", + printLimit: 15, + copyLimit: 150 + ) + + try await repository.addLicense(license1) + try await repository.addLicense(license2) + try await repository.addLicense(license3) + + let rights1 = try await repository.userRights(for: "multi-1") + let rights2 = try await repository.userRights(for: "multi-2") + let rights3 = try await repository.userRights(for: "multi-3") + + #expect(rights1.print == 5) + #expect(rights2.print == 10) + #expect(rights3.print == 15) + } +} diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift new file mode 100644 index 0000000000..e4fc09ec44 --- /dev/null +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift @@ -0,0 +1,408 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumLCP +import ReadiumShared +import Testing + +@Suite struct LCPKeychainPassphraseRepositoryTests { + let repository: LCPKeychainPassphraseRepository + + init() throws { + repository = LCPKeychainPassphraseRepository( + synchronizable: false + ) + // Clean up any existing test data + try? cleanupAllTestData() + } + + private func cleanupAllTestData() throws { + // Delete all test passphrases by using the Keychain directly + let keychain = Keychain( + serviceName: "org.readium.lcp.passphrases", + synchronizable: false + ) + try keychain.deleteAll() + } + + // MARK: - AddPassphrase Tests + + @Test func addPassphrase() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash123", + for: "license-1", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-1") + #expect(retrieved == "hash123") + } + + @Test func addPassphraseUpsert() async throws { + defer { try? cleanupAllTestData() } + + // Add initial passphrase + try await repository.addPassphrase( + "hash-old", + for: "license-2", + userID: "user-1", + provider: "https://provider.com" + ) + + // Update with new passphrase + try await repository.addPassphrase( + "hash-new", + for: "license-2", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-2") + #expect(retrieved == "hash-new") + } + + @Test func addPassphraseWithNilUserID() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-no-user", + for: "license-3", + userID: nil, + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-3") + #expect(retrieved == "hash-no-user") + } + + // MARK: - Passphrase Retrieval Tests + + @Test func passphraseForLicense() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-retrieve", + for: "license-retrieve", + userID: "user-1", + provider: "https://provider.com" + ) + + let passphrase = try await repository.passphrase(for: "license-retrieve") + #expect(passphrase == "hash-retrieve") + } + + @Test func passphraseForNonExistentLicense() async throws { + defer { try? cleanupAllTestData() } + + let passphrase = try await repository.passphrase(for: "non-existent-license") + #expect(passphrase == nil) + } + + // MARK: - PassphrasesMatching Tests + + @Test func passphrasesMatchingByProviderAndUserID() async throws { + defer { try? cleanupAllTestData() } + + // Add passphrases with different providers and user IDs + try await repository.addPassphrase( + "hash-1", + for: "license-1", + userID: "user-1", + provider: "https://provider1.com" + ) + try await repository.addPassphrase( + "hash-2", + for: "license-2", + userID: "user-1", + provider: "https://provider1.com" + ) + try await repository.addPassphrase( + "hash-3", + for: "license-3", + userID: "user-2", + provider: "https://provider1.com" + ) + try await repository.addPassphrase( + "hash-4", + for: "license-4", + userID: "user-1", + provider: "https://provider2.com" + ) + + // Search for passphrases with provider1 and user-1 + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: "https://provider1.com" + ) + + #expect(Set(matches) == Set(["hash-1", "hash-2"])) + } + + @Test func passphrasesMatchingByProviderOnly() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-1", + for: "license-1", + userID: "user-1", + provider: "https://provider.com" + ) + try await repository.addPassphrase( + "hash-2", + for: "license-2", + userID: "user-2", + provider: "https://provider.com" + ) + try await repository.addPassphrase( + "hash-3", + for: "license-3", + userID: "user-3", + provider: "https://other-provider.com" + ) + + // Search with nil userID should match all for the provider + let matches = try await repository.passphrasesMatching( + userID: nil, + provider: "https://provider.com" + ) + + #expect(Set(matches) == Set(["hash-1", "hash-2"])) + } + + @Test func passphrasesMatchingNoMatches() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-1", + for: "license-1", + userID: "user-1", + provider: "https://provider.com" + ) + + let matches = try await repository.passphrasesMatching( + userID: "user-99", + provider: "https://non-existent.com" + ) + + #expect(matches.isEmpty) + } + + @Test func passphrasesMatchingEmptyRepository() async throws { + defer { try? cleanupAllTestData() } + + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: "https://provider.com" + ) + + #expect(matches.isEmpty) + } + + // MARK: - Multiple Passphrases Tests + + @Test func multiplePassphrasesForDifferentLicenses() async throws { + defer { try? cleanupAllTestData() } + + let passphrases = [ + ("license-1", "hash-1"), + ("license-2", "hash-2"), + ("license-3", "hash-3"), + ] + + for (licenseID, hash) in passphrases { + try await repository.addPassphrase( + hash, + for: licenseID, + userID: "user-1", + provider: "https://provider.com" + ) + } + + // Verify each passphrase can be retrieved + for (licenseID, expectedHash) in passphrases { + let retrieved = try await repository.passphrase(for: licenseID) + #expect(retrieved == expectedHash) + } + } + + // MARK: - Concurrency Tests + + @Test func concurrentAddPassphrase() async throws { + defer { try? cleanupAllTestData() } + + let passphrases = (0 ..< 10).map { index in + ("license-concurrent-\(index)", "hash-\(index)") + } + + // Add passphrases concurrently + try await withThrowingTaskGroup(of: Void.self) { group in + for (licenseID, hash) in passphrases { + group.addTask { + try await repository.addPassphrase( + hash, + for: licenseID, + userID: "user-1", + provider: "https://provider.com" + ) + } + } + try await group.waitForAll() + } + + // Verify all passphrases were added + for (licenseID, expectedHash) in passphrases { + let retrieved = try await repository.passphrase(for: licenseID) + #expect(retrieved == expectedHash) + } + } + + // MARK: - Clear Tests + + @Test func clearRemovesAllPassphrases() async throws { + defer { try? cleanupAllTestData() } + + try await repository.addPassphrase( + "hash-1", + for: "license-clear-1", + userID: "user-1", + provider: "https://provider.com" + ) + try await repository.addPassphrase( + "hash-2", + for: "license-clear-2", + userID: "user-2", + provider: "https://provider.com" + ) + + try await repository.clear() + + // Verify all passphrases are gone + #expect(try await repository.passphrase(for: "license-clear-1") == nil) + #expect(try await repository.passphrase(for: "license-clear-2") == nil) + let all = try await repository.passphrases() + #expect(all.isEmpty) + } + + @Test func clearOnEmptyRepositorySucceeds() async throws { + defer { try? cleanupAllTestData() } + + try await repository.clear() + } + + // MARK: - Special Characters Tests + + @Test func passphraseWithSpecialCharacters() async throws { + defer { try? cleanupAllTestData() } + + let specialHashes = [ + "hash+with+plus", + "hash/with/slash", + "hash=with=equals", + "hash-with-unicode-Γ©-Γ±-δΈ­", + ] + + for (index, hash) in specialHashes.enumerated() { + try await repository.addPassphrase( + hash, + for: "license-special-\(index)", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-special-\(index)") + #expect(retrieved == hash) + } + } + + @Test func providerWithSpecialCharacters() async throws { + defer { try? cleanupAllTestData() } + + let providers = [ + "https://provider.com/path?query=value", + "https://provider.com:8080", + "https://provider.com/path#fragment", + ] + + for (index, provider) in providers.enumerated() { + try await repository.addPassphrase( + "hash-\(index)", + for: "license-provider-\(index)", + userID: "user-1", + provider: provider + ) + + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: provider + ) + + #expect(matches.contains("hash-\(index)")) + } + } + + // MARK: - Edge Cases Tests + + @Test func longPassphraseHash() async throws { + defer { try? cleanupAllTestData() } + + // Test with very long hash (e.g., 512-bit hash) + let longHash = String(repeating: "a", count: 128) + + try await repository.addPassphrase( + longHash, + for: "license-long-hash", + userID: "user-1", + provider: "https://provider.com" + ) + + let retrieved = try await repository.passphrase(for: "license-long-hash") + #expect(retrieved == longHash) + } + + @Test func longUserID() async throws { + defer { try? cleanupAllTestData() } + + let longUserID = String(repeating: "u", count: 200) + + try await repository.addPassphrase( + "hash", + for: "license-long-user", + userID: longUserID, + provider: "https://provider.com" + ) + + let matches = try await repository.passphrasesMatching( + userID: longUserID, + provider: "https://provider.com" + ) + + #expect(matches == ["hash"]) + } + + @Test func longProvider() async throws { + defer { try? cleanupAllTestData() } + + let longProvider = "https://provider.com/" + String(repeating: "p", count: 200) + + try await repository.addPassphrase( + "hash", + for: "license-long-provider", + userID: "user-1", + provider: longProvider + ) + + let matches = try await repository.passphrasesMatching( + userID: "user-1", + provider: longProvider + ) + + #expect(matches == ["hash"]) + } +} diff --git a/Tests/NavigatorTests/Asserts.swift b/Tests/NavigatorTests/Asserts.swift index 1f85398741..3bd1e882bd 100644 --- a/Tests/NavigatorTests/Asserts.swift +++ b/Tests/NavigatorTests/Asserts.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift index 80823af50b..1a2d62a0e9 100644 --- a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift +++ b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,22 +9,22 @@ import XCTest class PublicationMediaLoaderTests: XCTestCase { func testURLToHREF() { - XCTAssertEqual(URL(string: "readium:relative/file.mp3")!.audioHREF!.string, "relative/file.mp3") - XCTAssertEqual(URL(string: "readium:/absolute/file.mp3")!.audioHREF!.string, "/absolute/file.mp3") - XCTAssertEqual(URL(string: "readiumfile:///directory/file.mp3")!.audioHREF!.string, "file:///directory/file.mp3") - XCTAssertEqual(URL(string: "readiumhttp:///domain.com/file.mp3")!.audioHREF!.string, "http:///domain.com/file.mp3") - XCTAssertEqual(URL(string: "readiumhttps:///domain.com/file.mp3")!.audioHREF!.string, "https:///domain.com/file.mp3") + XCTAssertEqual(URL(string: "readium:relative/file.mp3")?.audioHREF?.string, "relative/file.mp3") + XCTAssertEqual(URL(string: "readium:/absolute/file.mp3")?.audioHREF?.string, "/absolute/file.mp3") + XCTAssertEqual(URL(string: "readiumfile:///directory/file.mp3")?.audioHREF?.string, "file:///directory/file.mp3") + XCTAssertEqual(URL(string: "readiumhttp:///domain.com/file.mp3")?.audioHREF?.string, "http:///domain.com/file.mp3") + XCTAssertEqual(URL(string: "readiumhttps:///domain.com/file.mp3")?.audioHREF?.string, "https:///domain.com/file.mp3") // Encoded characters - XCTAssertEqual(URL(string: "readium:relative/a%20file.mp3")!.audioHREF!.string, "relative/a%20file.mp3") - XCTAssertEqual(URL(string: "readium:/absolute/a%20file.mp3")!.audioHREF!.string, "/absolute/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumfile:///directory/a%20file.mp3")!.audioHREF!.string, "file:///directory/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumhttp:///domain.com/a%20file.mp3")!.audioHREF!.string, "http:///domain.com/a%20file.mp3") - XCTAssertEqual(URL(string: "readiumhttps:///domain.com/a%20file.mp3")!.audioHREF!.string, "https:///domain.com/a%20file.mp3") + XCTAssertEqual(URL(string: "readium:relative/a%20file.mp3")?.audioHREF?.string, "relative/a%20file.mp3") + XCTAssertEqual(URL(string: "readium:/absolute/a%20file.mp3")?.audioHREF?.string, "/absolute/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumfile:///directory/a%20file.mp3")?.audioHREF?.string, "file:///directory/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumhttp:///domain.com/a%20file.mp3")?.audioHREF?.string, "http:///domain.com/a%20file.mp3") + XCTAssertEqual(URL(string: "readiumhttps:///domain.com/a%20file.mp3")?.audioHREF?.string, "https:///domain.com/a%20file.mp3") // Ignores if the r2 prefix is missing. - XCTAssertNil(URL(string: "relative/file.mp3")!.audioHREF) - XCTAssertNil(URL(string: "file:///directory/file.mp3")!.audioHREF) - XCTAssertNil(URL(string: "http:///domain.com/file.mp3")!.audioHREF) + XCTAssertNil(URL(string: "relative/file.mp3")?.audioHREF) + XCTAssertNil(URL(string: "file:///directory/file.mp3")?.audioHREF) + XCTAssertNil(URL(string: "http:///domain.com/file.mp3")?.audioHREF) } } diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift index 3e103bb6b2..12a9b13aa9 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSLayoutTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift index fa6f88f14f..87fb112143 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSRSPropertiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -33,7 +33,6 @@ class CSSRSPropertiesTests: XCTestCase { "--RS__visitedColor": nil, "--RS__primaryColor": nil, "--RS__secondaryColor": nil, - "--RS__typeScale": nil, "--RS__baseFontFamily": nil, "--RS__baseLineHeight": nil, "--RS__oldStyleTf": nil, @@ -53,7 +52,7 @@ class CSSRSPropertiesTests: XCTestCase { func testOverrideProperties() { let props = CSSRSProperties( - colCount: .one, + colCount: 1, overrides: [ "--RS__colCount": "2", "--RS__custom": "value", @@ -68,7 +67,7 @@ class CSSRSPropertiesTests: XCTestCase { XCTAssertEqual( CSSRSProperties( colWidth: CSSCmLength(1.2), - colCount: .two, + colCount: 2, colGap: CSSPtLength(2.3), pageGutter: CSSPcLength(3.4), flowSpacing: CSSMmLength(4.5), @@ -87,7 +86,6 @@ class CSSRSPropertiesTests: XCTestCase { visitedColor: CSSHexColor("#0000FF"), primaryColor: CSSHexColor("#FA4358"), secondaryColor: CSSHexColor("#CBC322"), - typeScale: 10.11, baseFontFamily: ["Palatino", "Comic Sans MS"], baseLineHeight: .length(CSSVhLength(11.12)), oldStyleTf: ["Old", "Style"], @@ -123,7 +121,6 @@ class CSSRSPropertiesTests: XCTestCase { "--RS__visitedColor": "#0000FF", "--RS__primaryColor": "#FA4358", "--RS__secondaryColor": "#CBC322", - "--RS__typeScale": "10.11000", "--RS__baseFontFamily": #"Palatino, "Comic Sans MS""#, "--RS__baseLineHeight": "11.12000vh", "--RS__oldStyleTf": #"Old, Style"#, diff --git a/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift b/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift index cd1d529b95..6d2e4441fe 100644 --- a/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/CSSUserPropertiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -15,19 +15,18 @@ class CSSUserPropertiesTests: XCTestCase { [ "--USER__view": nil, "--USER__colCount": nil, - "--USER__pageMargins": nil, "--USER__appearance": nil, + "--USER__blendImages": nil, "--USER__darkenImages": nil, "--USER__invertImages": nil, + "--USER__invertGaiji": nil, "--USER__textColor": nil, "--USER__backgroundColor": nil, - "--USER__fontOverride": nil, "--USER__fontFamily": nil, "--USER__fontSize": nil, - "--USER__advancedSettings": nil, - "--USER__typeScale": nil, "--USER__textAlign": nil, "--USER__lineHeight": nil, + "--USER__lineLength": nil, "--USER__paraSpacing": nil, "--USER__paraIndent": nil, "--USER__wordSpacing": nil, @@ -43,21 +42,20 @@ class CSSUserPropertiesTests: XCTestCase { XCTAssertEqual( CSSUserProperties( view: .scroll, - colCount: .auto, - pageMargins: 1.2, + colCount: 2, appearance: .night, - darkenImages: true, - invertImages: true, + blendImages: true, + darkenImages: CSSPercent(0.5), + invertImages: CSSPercent(0.5), + invertGaiji: CSSPercent(0.5), textColor: CSSHexColor("#FF0000"), backgroundColor: CSSHexColor("#00FF00"), - fontOverride: true, fontFamily: ["Times New"], - fontSize: CSSVMaxLength(2.3), - advancedSettings: true, - typeScale: 3.4, + fontSize: CSSPxLength(12), textAlign: .justify, - lineHeight: .length(CSSPtLength(4.5)), - paraSpacing: CSSPtLength(5.6), + lineLength: CSSPxLength(500), + lineHeight: .unitless(1.2), + paraSpacing: CSSPxLength(5.6), paraIndent: CSSRemLength(6.7), wordSpacing: CSSRemLength(7.8), letterSpacing: CSSRemLength(8.9), @@ -67,21 +65,20 @@ class CSSUserPropertiesTests: XCTestCase { ).cssProperties(), [ "--USER__view": "readium-scroll-on", - "--USER__colCount": "auto", - "--USER__pageMargins": "1.20000", + "--USER__colCount": "2", "--USER__appearance": "readium-night-on", - "--USER__darkenImages": "readium-darken-on", - "--USER__invertImages": "readium-invert-on", + "--USER__blendImages": "readium-blend-on", + "--USER__darkenImages": "50.00000%", + "--USER__invertImages": "50.00000%", + "--USER__invertGaiji": "50.00000%", "--USER__textColor": "#FF0000", "--USER__backgroundColor": "#00FF00", - "--USER__fontOverride": "readium-font-on", "--USER__fontFamily": "\"Times New\"", - "--USER__fontSize": "2.30000vmax", - "--USER__advancedSettings": "readium-advanced-on", - "--USER__typeScale": "3.40000", + "--USER__fontSize": "12.00000px", "--USER__textAlign": "justify", - "--USER__lineHeight": "4.50000pt", - "--USER__paraSpacing": "5.60000pt", + "--USER__lineLength": "500.00000px", + "--USER__lineHeight": "1.20000", + "--USER__paraSpacing": "5.60000px", "--USER__paraIndent": "6.70000rem", "--USER__wordSpacing": "7.80000rem", "--USER__letterSpacing": "8.90000rem", @@ -94,7 +91,7 @@ class CSSUserPropertiesTests: XCTestCase { func testOverrideUserProperties() { let props = CSSUserProperties( - colCount: .one, + colCount: 1, overrides: [ "--USER__colCount": "2", "--USER__custom": "value", @@ -113,10 +110,10 @@ class CSSUserPropertiesTests: XCTestCase { XCTAssertEqual( CSSUserProperties( view: .scroll, - colCount: .auto + colCount: 2 ).css(), """ - --USER__colCount: auto !important; + --USER__colCount: 2 !important; --USER__view: readium-scroll-on !important; """ @@ -127,21 +124,20 @@ class CSSUserPropertiesTests: XCTestCase { XCTAssertEqual( CSSUserProperties( view: .scroll, - colCount: .auto, - pageMargins: 1.2, + colCount: 2, appearance: .night, - darkenImages: true, - invertImages: true, + blendImages: true, + darkenImages: CSSPercent(0.5), + invertImages: CSSPercent(0.5), + invertGaiji: CSSPercent(0.5), textColor: CSSHexColor("#FF0000"), backgroundColor: CSSHexColor("#00FF00"), - fontOverride: true, fontFamily: ["Times New", "Comic Sans"], - fontSize: CSSVMaxLength(2.3), - advancedSettings: true, - typeScale: 3.4, + fontSize: CSSPxLength(12), textAlign: .justify, - lineHeight: .length(CSSPtLength(4.5)), - paraSpacing: CSSPtLength(5.6), + lineLength: CSSPxLength(500), + lineHeight: .unitless(1.2), + paraSpacing: CSSPxLength(5.6), paraIndent: CSSRemLength(6.7), wordSpacing: CSSRemLength(7.8), letterSpacing: CSSRemLength(8.9), @@ -151,25 +147,24 @@ class CSSUserPropertiesTests: XCTestCase { ).css(), """ --USER__a11yNormalize: readium-a11y-on !important; - --USER__advancedSettings: readium-advanced-on !important; --USER__appearance: readium-night-on !important; --USER__backgroundColor: #00FF00 !important; + --USER__blendImages: readium-blend-on !important; --USER__bodyHyphens: auto !important; - --USER__colCount: auto !important; - --USER__darkenImages: readium-darken-on !important; + --USER__colCount: 2 !important; + --USER__darkenImages: 50.00000% !important; --USER__fontFamily: "Times New", "Comic Sans" !important; - --USER__fontOverride: readium-font-on !important; - --USER__fontSize: 2.30000vmax !important; - --USER__invertImages: readium-invert-on !important; + --USER__fontSize: 12.00000px !important; + --USER__invertGaiji: 50.00000% !important; + --USER__invertImages: 50.00000% !important; --USER__letterSpacing: 8.90000rem !important; --USER__ligatures: common-ligatures !important; - --USER__lineHeight: 4.50000pt !important; - --USER__pageMargins: 1.20000 !important; + --USER__lineHeight: 1.20000 !important; + --USER__lineLength: 500.00000px !important; --USER__paraIndent: 6.70000rem !important; - --USER__paraSpacing: 5.60000pt !important; + --USER__paraSpacing: 5.60000px !important; --USER__textAlign: justify !important; --USER__textColor: #FF0000 !important; - --USER__typeScale: 3.40000 !important; --USER__view: readium-scroll-on !important; --USER__wordSpacing: 7.80000rem !important; diff --git a/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift b/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift index 861cbbe419..a4f76d9cf7 100644 --- a/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift +++ b/Tests/NavigatorTests/EPUB/CSS/ReadiumCSSTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift new file mode 100644 index 0000000000..d869943bec --- /dev/null +++ b/Tests/NavigatorTests/EPUB/EPUBSpreadTests.swift @@ -0,0 +1,484 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumNavigator +import ReadiumShared +import Testing + +@Suite enum EPUBSpreadTests { + @Suite("Single pages") struct SinglePages { + @Test("with an empty reading order") + func emptyReadingOrder() { + let pub = fxlPublication(readingOrder: []) + let spreads = makeSpreads(publication: pub, spread: false) + #expect(spreads.isEmpty) + } + + @Test("each link produces a single spread") + func multipleLinks() { + let pub = fxlPublication(readingOrder: [ + link("p1.html"), + link("p2.html"), + link("p3.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: false) + + #expect(spreads.count == 3) + for (i, spread) in spreads.enumerated() { + guard case let .single(s) = spread else { + Issue.record("Expected .single at index \(i)") + continue + } + #expect(s.resource.index == i) + } + } + } + + @Suite("Dual pages") struct DualPages { + @Test("never combines reflowable pages") + func neverCombinesReflowable() { + let pub = reflowablePublication(readingOrder: [ + link("c1.html"), + link("c2.html"), + link("c3.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + for spread in spreads { + guard case .single = spread else { + Issue.record("Expected all .single for reflowable") + return + } + } + } + + @Suite("FXL") enum FXL { + @Suite("First page position") struct FirstPagePosition { + @Test("defaults to center when no page property") + func firstPageDefaultsToCenter() { + let pub = fxlPublication(readingOrder: [ + link("cover.html"), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 2) + guard case let .single(cover) = spreads[0] else { + Issue.record("Expected cover to be .single") + return + } + #expect(cover.resource.link.href == "cover.html") + guard case .double = spreads[1] else { + Issue.record("Expected p1+p2 to be .double") + return + } + } + + @Test("offsetFirstPage: true keeps first page single") + func offsetFirstPageTrue() { + let pub = fxlPublication(readingOrder: [ + link("cover.html"), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true, offsetFirstPage: true) + + #expect(spreads.count == 2) + guard case .single = spreads[0] else { + Issue.record("Expected cover to be .single with offsetFirstPage=true") + return + } + } + + @Test("offsetFirstPage: false allows first page to combine") + func offsetFirstPageFalse() { + let pub = fxlPublication(readingOrder: [ + link("p1.html"), + link("p2.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true, offsetFirstPage: false) + + #expect(spreads.count == 1) + guard case .double = spreads[0] else { + Issue.record("Expected .double when offsetFirstPage=false") + return + } + } + + @Test("explicit .left on first page is preserved") + func firstPageExplicitLeftKeepsIt() { + let pub = fxlPublication(readingOrder: [ + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 1) + guard case .double = spreads[0] else { + Issue.record("Expected .double when first page has explicit .left") + return + } + } + } + + @Suite("Page pairing (LTR)") struct PairingLTR { + @Test("left + right pages are combined") + func leftRightCombined() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 2) + guard case let .double(d) = spreads[1] else { + Issue.record("Expected left+right to combine in LTR") + return + } + #expect(d.first.link.href == "p1.html") + #expect(d.second.link.href == "p2.html") + } + + @Test("right + left pages are not combined") + func rightLeftNotCombined() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .right), + link("p2.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + for spread in spreads { + guard case .single = spread else { + Issue.record("Expected all .single for wrong-order LTR pages") + return + } + } + } + + @Test("nil + nil defaults to left + right") + func nilNilDefaultsToLeftRight() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html"), + link("p2.html"), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 2) + guard case .double = spreads[1] else { + Issue.record("Expected nil+nil to combine in LTR") + return + } + } + + @Test("center + left pages are not combined") + func centerLeftNotCombined() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .center), + link("p2.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + } + + @Test("odd number of pages leaves last page single") + func oddNumberLastPageSingle() { + let pub = fxlPublication(readingProgression: .ltr, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("p2.html", page: .right), + link("p3.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, spread: true) + + #expect(spreads.count == 3) + guard case .single = spreads[0] else { + Issue.record("Expected cover to be single") + return + } + guard case .double = spreads[1] else { + Issue.record("Expected p1+p2 to be double") + return + } + guard case let .single(last) = spreads[2] else { + Issue.record("Expected last page to be single") + return + } + #expect(last.resource.link.href == "p3.html") + } + } + + @Suite("Page pairing (RTL)") struct PairingRTL { + @Test("right + left pages are combined") + func rightLeftCombined() { + let pub = fxlPublication(readingProgression: .rtl, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .right), + link("p2.html", page: .left), + ]) + let spreads = makeSpreads(publication: pub, readingProgression: .rtl, spread: true) + + #expect(spreads.count == 2) + guard case let .double(d) = spreads[1] else { + Issue.record("Expected right+left to combine in RTL") + return + } + #expect(d.first.link.href == "p1.html") + #expect(d.second.link.href == "p2.html") + } + + @Test("left + right pages are not combined") + func leftRightNotCombined() { + let pub = fxlPublication(readingProgression: .rtl, readingOrder: [ + link("cover.html", page: .center), + link("p1.html", page: .left), + link("p2.html", page: .right), + ]) + let spreads = makeSpreads(publication: pub, readingProgression: .rtl, spread: true) + + #expect(spreads.count == 3) + } + + @Test("nil + nil defaults to right + left") + func nilNilDefaultsToRightLeft() { + let pub = fxlPublication(readingProgression: .rtl, readingOrder: [ + link("cover.html", page: .center), + link("p1.html"), + link("p2.html"), + ]) + let spreads = makeSpreads(publication: pub, readingProgression: .rtl, spread: true) + + #expect(spreads.count == 2) + guard case .double = spreads[1] else { + Issue.record("Expected nil+nil to combine in RTL") + return + } + } + } + } + } + + @Suite enum Properties { + @Suite struct SingleSpread { + @Test func readingOrderIndices() { + let spread = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 3, link: link("p.html")) + )) + #expect(spread.readingOrderIndices == 3 ... 3) + } + + @Test func first() { + let resource = EPUBSpreadResource(index: 5, link: link("p.html")) + let spread = EPUBSpread.single(EPUBSingleSpread(resource: resource)) + #expect(spread.first.index == 5) + #expect(spread.first.link.href == "p.html") + } + + @Test func containsIndex() { + let single = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 2, link: link("p.html")) + )) + #expect(single.contains(index: 2)) + #expect(!single.contains(index: 0)) + #expect(!single.contains(index: 3)) + } + } + + @Suite struct DoubleSpread { + @Test func readingOrderIndices() { + let spread = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 2, link: link("p1.html")), + second: EPUBSpreadResource(index: 3, link: link("p2.html")) + )) + #expect(spread.readingOrderIndices == 2 ... 3) + } + + @Test func first() { + let spread = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 1, link: link("p1.html")), + second: EPUBSpreadResource(index: 2, link: link("p2.html")) + )) + #expect(spread.first.index == 1) + #expect(spread.first.link.href == "p1.html") + } + + @Test func containsIndex() { + let double = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 4, link: link("p1.html")), + second: EPUBSpreadResource(index: 5, link: link("p2.html")) + )) + #expect(double.contains(index: 4)) + #expect(double.contains(index: 5)) + #expect(!double.contains(index: 3)) + #expect(!double.contains(index: 6)) + } + + @Test("LTR: left is first, right is second") + func ltrLeftIsFirstRightIsSecond() { + let first = EPUBSpreadResource(index: 0, link: link("p1.html")) + let second = EPUBSpreadResource(index: 1, link: link("p2.html")) + let spread = EPUBDoubleSpread(first: first, second: second) + + #expect(spread.left(for: .ltr).link.href == "p1.html") + #expect(spread.right(for: .ltr).link.href == "p2.html") + } + + @Test("RTL: left is second, right is first") + func rtlLeftIsSecondRightIsFirst() { + let first = EPUBSpreadResource(index: 0, link: link("p1.html")) + let second = EPUBSpreadResource(index: 1, link: link("p2.html")) + let spread = EPUBDoubleSpread(first: first, second: second) + + #expect(spread.left(for: .rtl).link.href == "p2.html") + #expect(spread.right(for: .rtl).link.href == "p1.html") + } + } + } + + @Suite struct PositionCount { + @Test("for a single spread") + func single() { + let readingOrder: ReadingOrder = [ + link("p0.html"), + link("p1.html"), + link("p2.html"), + ] + let positions: [[Locator]] = [ + [Locator(href: "p0.html", mediaType: .html)], + [ + Locator(href: "p1.html", mediaType: .html), + Locator(href: "p1.html", mediaType: .html), + Locator(href: "p1.html", mediaType: .html), + ], + [Locator(href: "p2.html", mediaType: .html)], + ] + + let spread = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 1, link: link("p1.html")) + )) + #expect(spread.positionCount(in: readingOrder, positionsByReadingOrder: positions) == 3) + } + + @Test("for a double spread sums both resources") + func double() { + let readingOrder: ReadingOrder = [ + link("p0.html"), + link("p1.html"), + link("p2.html"), + ] + let positions: [[Locator]] = [ + [Locator(href: "p0.html", mediaType: .html)], + [ + Locator(href: "p1.html", mediaType: .html), + Locator(href: "p1.html", mediaType: .html), + ], + [Locator(href: "p2.html", mediaType: .html)], + ] + + let spread = EPUBSpread.double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 1, link: link("p1.html")), + second: EPUBSpreadResource(index: 2, link: link("p2.html")) + )) + #expect(spread.positionCount(in: readingOrder, positionsByReadingOrder: positions) == 3) + } + + @Test("returns 0 for out-of-bounds index") + func outOfBounds() { + let readingOrder: ReadingOrder = [link("p0.html")] + let positions: [[Locator]] = [[Locator(href: "p0.html", mediaType: .html)]] + + let spread = EPUBSpread.single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 5, link: link("missing.html")) + )) + #expect(spread.positionCount(in: readingOrder, positionsByReadingOrder: positions) == 0) + } + } + + @Suite struct FirstIndexWithReadingOrderIndex { + @Test("finds the spread index containing a reading order index") + func findsSpreadIndex() { + let spreads: [EPUBSpread] = [ + .single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 0, link: link("cover.html")) + )), + .double(EPUBDoubleSpread( + first: EPUBSpreadResource(index: 1, link: link("p1.html")), + second: EPUBSpreadResource(index: 2, link: link("p2.html")) + )), + .single(EPUBSingleSpread( + resource: EPUBSpreadResource(index: 3, link: link("p3.html")) + )), + ] + + #expect(spreads.firstIndexWithReadingOrderIndex(0) == 0) + #expect(spreads.firstIndexWithReadingOrderIndex(1) == 1) + #expect(spreads.firstIndexWithReadingOrderIndex(2) == 1) + #expect(spreads.firstIndexWithReadingOrderIndex(3) == 2) + #expect(spreads.firstIndexWithReadingOrderIndex(99) == nil) + } + } +} + +// MARK: - Helpers + +private func link(_ href: String, page: Properties.Page? = nil) -> Link { + var properties = Properties() + properties.page = page + return Link(href: href, properties: properties) +} + +private func fxlPublication( + readingProgression: ReadiumShared.ReadingProgression = .auto, + readingOrder: [Link] +) -> Publication { + Publication( + manifest: Manifest( + metadata: Metadata( + title: "FXL", + layout: .fixed, + readingProgression: readingProgression + ), + readingOrder: readingOrder + ) + ) +} + +private func reflowablePublication(readingOrder: [Link]) -> Publication { + Publication( + manifest: Manifest( + metadata: Metadata(title: "Reflowable"), + readingOrder: readingOrder + ) + ) +} + +private func makeSpreads( + publication: Publication, + readingProgression: ReadiumNavigator.ReadingProgression = .ltr, + spread: Bool, + offsetFirstPage: Bool? = nil +) -> [EPUBSpread] { + EPUBSpread.makeSpreads( + for: publication, + readingOrder: publication.readingOrder, + readingProgression: readingProgression, + spread: spread, + offsetFirstPage: offsetFirstPage + ) +} + +private extension Locator { + init(href: String, mediaType: MediaType) { + self.init(href: AnyURL(string: href)!, mediaType: mediaType) + } +} diff --git a/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift b/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift index 105f02d5fa..ebcb7d19c4 100644 --- a/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift +++ b/Tests/NavigatorTests/EPUB/Preferences/EPUBSettingsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/NavigatorTests/TTS/TTSVoiceTests.swift b/Tests/NavigatorTests/TTS/TTSVoiceTests.swift index cd4e6f2aed..955409517e 100644 --- a/Tests/NavigatorTests/TTS/TTSVoiceTests.swift +++ b/Tests/NavigatorTests/TTS/TTSVoiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift b/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift index f99d869289..874a38304b 100644 --- a/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift +++ b/Tests/NavigatorTests/Toolkit/HTMLElementTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -27,7 +27,7 @@ class HTMLElementTests: XCTestCase { XCTAssertEqual(body.locate(.start, in: html), nil) } - func testLocateStart() { + func testLocateStart() throws { let html = """ @@ -37,12 +37,12 @@ class HTMLElementTests: XCTestCase { """ - let target = html.firstIndex(of: "πŸ“")! + let target = try XCTUnwrap(html.firstIndex(of: "πŸ“")) XCTAssertEqual(body.locate(.start, in: html), target) } - func testLocateStartIsCaseInsensitive() { + func testLocateStartIsCaseInsensitive() throws { let html = """ @@ -52,12 +52,12 @@ class HTMLElementTests: XCTestCase { """ - let target = html.firstIndex(of: "πŸ“")! + let target = try XCTUnwrap(html.firstIndex(of: "πŸ“")) XCTAssertEqual(body.locate(.start, in: html), target) } - func testLocateStartIgnoresAttributesAndNewlines() { + func testLocateStartIgnoresAttributesAndNewlines() throws { let html = """ @@ -69,12 +69,12 @@ class HTMLElementTests: XCTestCase { """ - let target = html.firstIndex(of: "πŸ“")! + let target = try XCTUnwrap(html.firstIndex(of: "πŸ“")) XCTAssertEqual(body.locate(.start, in: html), target) } - func testLocateEnd() { + func testLocateEnd() throws { let html = """ @@ -84,13 +84,13 @@ class HTMLElementTests: XCTestCase { πŸ“ """ - let target = html.firstIndex(of: "πŸ“") - .map { html.index($0, offsetBy: 1) }! + let target = try XCTUnwrap(html.firstIndex(of: "πŸ“") + .map { html.index($0, offsetBy: 1) }) XCTAssertEqual(body.locate(.end, in: html), target) } - func testLocateEndIsCaseInsensitive() { + func testLocateEndIsCaseInsensitive() throws { let html = """ @@ -100,13 +100,13 @@ class HTMLElementTests: XCTestCase { πŸ“ """ - let target = html.firstIndex(of: "πŸ“") - .map { html.index($0, offsetBy: 1) }! + let target = try XCTUnwrap(html.firstIndex(of: "πŸ“") + .map { html.index($0, offsetBy: 1) }) XCTAssertEqual(body.locate(.end, in: html), target) } - func testLocateEndIgnoresWhitespaces() { + func testLocateEndIgnoresWhitespaces() throws { let html = """ @@ -117,8 +117,8 @@ class HTMLElementTests: XCTestCase { > """ - let target = html.firstIndex(of: "πŸ“") - .map { html.index($0, offsetBy: 1) }! + let target = try XCTUnwrap(html.firstIndex(of: "πŸ“") + .map { html.index($0, offsetBy: 1) }) XCTAssertEqual(body.locate(.end, in: html), target) } diff --git a/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift b/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift index 7c1b29a6d3..5a42c9c3a7 100644 --- a/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift +++ b/Tests/NavigatorTests/Toolkit/HTMLInjectionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/NavigatorTests/UITests/.gitignore b/Tests/NavigatorTests/UITests/.gitignore new file mode 100644 index 0000000000..4640ebbac8 --- /dev/null +++ b/Tests/NavigatorTests/UITests/.gitignore @@ -0,0 +1 @@ +*.xcodeproj diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift new file mode 100644 index 0000000000..0594898caa --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift @@ -0,0 +1,23 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import SwiftUI + +enum AccessibilityID: String { + case open + case close + case allMemoryDeallocated + case isNavigatorReady + case runStressTest + case stressTestCompleted +} + +extension View { + func accessibilityIdentifier(_ id: AccessibilityID) -> ModifiedContent { + accessibilityIdentifier(id.rawValue) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift new file mode 100644 index 0000000000..7e896d841c --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift @@ -0,0 +1,79 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumAdapterGCDWebServer +import ReadiumNavigator +import ReadiumShared +import ReadiumStreamer +import UIKit + +/// Shared Readium infrastructure for testing. +@MainActor class Container { + static let shared = Container() + + let memoryTracker = MemoryTracker() + let httpClient: HTTPClient + let httpServer: HTTPServer + let assetRetriever: AssetRetriever + let publicationOpener: PublicationOpener + + init() { + httpClient = DefaultHTTPClient() + assetRetriever = AssetRetriever(httpClient: httpClient) + httpServer = GCDHTTPServer(assetRetriever: assetRetriever) + + publicationOpener = PublicationOpener( + parser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory() + ), + contentProtections: [] + ) + } + + func publication(at url: FileURL) async throws -> Publication { + let asset = try await assetRetriever.retrieve(url: url).get() + let publication = try await publicationOpener.open( + asset: asset, + allowUserInteraction: false, + sender: nil + ).get() + + memoryTracker.track(publication) + return publication + } + + func navigator(for publication: Publication) throws -> VisualNavigator & UIViewController { + if publication.conforms(to: .epub) { + return try epubNavigator(for: publication) + } else if publication.conforms(to: .pdf) { + return try pdfNavigator(for: publication) + } else { + fatalError("Publication not supported") + } + } + + func epubNavigator(for publication: Publication) throws -> EPUBNavigatorViewController { + let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: nil, + config: EPUBNavigatorViewController.Configuration() + ) + memoryTracker.track(navigator) + return navigator + } + + func pdfNavigator(for publication: Publication) throws -> PDFNavigatorViewController { + let navigator = try PDFNavigatorViewController( + publication: publication, + initialLocation: nil, + httpServer: httpServer + ) + memoryTracker.track(navigator) + return navigator + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift new file mode 100644 index 0000000000..b4536efe25 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift @@ -0,0 +1,103 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +/// Provides a simple UI for opening publication fixtures and display memory +/// status for UI test verification. +struct FixtureList: View { + @ObservedObject private var memoryTracker: MemoryTracker + @StateObject private var viewModel = FixtureListViewModel() + + init() { + memoryTracker = Container.shared.memoryTracker + } + + var body: some View { + List { + Section { + fixture(.childrensLiteratureEPUB) + fixture(.daisyPDF) + } + + Section { + Toggle(isOn: $memoryTracker.allDeallocated) { + Text("All memory is deallocated") + } + .accessibilityIdentifier(.allMemoryDeallocated) + } + .disabled(true) + } + .fullScreenCover(item: $viewModel.readerViewModel) { viewModel in + ReaderView(viewModel: viewModel) + } + } + + private func fixture(_ fixture: PublicationFixture) -> some View { + ListRow(action: { viewModel.open(fixture) }) { + VStack(alignment: .leading) { + Text(fixture.filename) + .font(.headline) + + Text(fixture.description) + .font(.caption) + } + + Spacer() + + Image(systemName: "chevron.right") + } + .accessibilityIdentifier(fixture.accessibilityIdentifier) + } +} + +@MainActor +class FixtureListViewModel: ObservableObject { + @Published var readerViewModel: ReaderViewModel? + + private var openTask: Task? + + func open(_ fixture: PublicationFixture) { + openTask?.cancel() + openTask = Task { try! await open(fixture) } + } + + private func open(_ fixture: PublicationFixture) async throws { + let components = fixture.filename.split(separator: ".", maxSplits: 1) + .map { String($0) } + + guard + components.count == 2, + let epubURL = Bundle.main.url( + forResource: components[0], + withExtension: components[1], + subdirectory: "Publications" + ) + else { + throw FixtureError.notFound(fixture) + } + + let fileURL = FileURL(url: epubURL)! + + let container = Container.shared + let publication = try await container.publication(at: fileURL) + let navigator = try container.navigator(for: publication) + + readerViewModel = ReaderViewModel(navigator: navigator) + } +} + +enum FixtureError: LocalizedError { + case notFound(PublicationFixture) + + var errorDescription: String? { + switch self { + case let .notFound(fixture): + return "Test fixture \(fixture.filename) not found in bundle" + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift new file mode 100644 index 0000000000..88c0308381 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift @@ -0,0 +1,26 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +struct PublicationFixture { + let filename: String + let description: String + + var accessibilityIdentifier: String { + "publication://\(filename)" + } + + static let childrensLiteratureEPUB: PublicationFixture = .init( + filename: "childrens-literature.epub", + description: "Basic reflowable EPUB with a page-list." + ) + + static let daisyPDF: PublicationFixture = .init( + filename: "daisy.pdf", + description: "Basic PDF document." + ) +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist b/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist new file mode 100644 index 0000000000..ee46fcec7e --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift new file mode 100644 index 0000000000..2f04dcfa46 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift @@ -0,0 +1,43 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +struct ListRow: View { + private let action: (@MainActor () async -> Void)? + private let content: () -> Content + + @State private var isActionRunning = false + + init( + action: (() async -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.action = action + self.content = content + } + + var body: some View { + HStack { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.interaction, .rect) + .onTapGesture(perform: activate) + .disabled(isActionRunning) + } + + private func activate() { + guard let action, !isActionRunning else { + return + } + isActionRunning = true + Task { @MainActor in + await action() + isActionRunning = false + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift new file mode 100644 index 0000000000..4738211734 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift @@ -0,0 +1,67 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumNavigator + +/// Tracks object instances to detect memory leaks in UI tests. +@MainActor class MemoryTracker: ObservableObject { + @Published var allDeallocated: Bool = true + + class Ref { + private weak var object: AnyObject? + + var isDeallocated: Bool { + object == nil + } + + init(_ object: AnyObject) { + self.object = object + } + } + + private var refs: [Ref] = [] + private var pollingTask: Task? + + /// Records a weak reference to track. + @discardableResult + func track(_ object: T) -> Ref { + let ref = Ref(object) + refs.append(ref) + startPollingIfNeeded() + return ref + } + + private func startPollingIfNeeded() { + guard pollingTask == nil else { return } + + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(seconds: 0.5) + pollAllocations() + } + } + } + + private func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + private func pollAllocations() { + refs.removeAll { $0.isDeallocated } + let deallocated = refs.isEmpty + + if allDeallocated != deallocated { + allDeallocated = deallocated + } + + // Stop polling when no objects are being tracked + if refs.isEmpty { + stopPolling() + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift new file mode 100644 index 0000000000..4d719a0def --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift @@ -0,0 +1,16 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +@main +struct NavigatorTestHostApp: App { + var body: some Scene { + WindowGroup { + FixtureList() + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift new file mode 100644 index 0000000000..474f000315 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift @@ -0,0 +1,102 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumNavigator +import ReadiumShared +import SwiftUI + +/// SwiftUI wrapper for the `ReaderViewController`. +struct ReaderView: View { + @ObservedObject var viewModel: ReaderViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + ReaderViewControllerWrapper(navigator: viewModel.navigator) + // State information checked in UI tests, not meant to be + // visible. + .background( + List { + Toggle(isOn: $viewModel.isReady) {} + .accessibilityIdentifier(.isNavigatorReady) + Toggle(isOn: $viewModel.stressTestCompleted) {} + .accessibilityIdentifier(.stressTestCompleted) + } + ) + .ignoresSafeArea(.all) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + .accessibilityIdentifier(.close) + } + + ToolbarItem(placement: .primaryAction) { + Button("Run Stress Test") { + viewModel.runNavigationStressTest() + } + .accessibilityIdentifier(.runStressTest) + } + } + } + } +} + +@MainActor final class ReaderViewModel: ObservableObject, Identifiable { + nonisolated var id: ObjectIdentifier { + ObjectIdentifier(self) + } + + let navigator: VisualNavigator & UIViewController + + @Published var isReady: Bool = false + @Published var stressTestCompleted: Bool = false + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + + if let epubNavigator = navigator as? EPUBNavigatorViewController { + epubNavigator.delegate = self + } else if let pdfNavigator = navigator as? PDFNavigatorViewController { + pdfNavigator.delegate = self + } + } + + func runNavigationStressTest() { + Task { + let publication = navigator.publication + let readingOrder = publication.readingOrder + guard let positionsByReadingOrder = await publication.positionsByReadingOrder().getOrNil() else { return } + + for _ in 0 ..< 100 { + let positions = positionsByReadingOrder[Int.random(in: 0 ..< readingOrder.count)] + let locator = positions[Int.random(in: 0 ..< positions.count)] + await navigator.go(to: locator, options: NavigatorGoOptions(animated: false)) + let sleepNanos = UInt64.random(in: 0 ... 50) * 1_000_000 + try? await Task.sleep(nanoseconds: sleepNanos) + } + + stressTestCompleted = true + } + } +} + +// MARK: - NavigatorDelegate + +extension ReaderViewModel: NavigatorDelegate { + func navigator(_ navigator: Navigator, presentError error: NavigatorError) {} + + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + if !isReady { + isReady = true + } + } +} + +extension ReaderViewModel: EPUBNavigatorDelegate {} +extension ReaderViewModel: PDFNavigatorDelegate {} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift new file mode 100644 index 0000000000..118b3f728a --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift @@ -0,0 +1,44 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumNavigator +import SwiftUI +import UIKit + +class ReaderViewController: UIViewController { + private let navigator: VisualNavigator & UIViewController + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init?(coder: NSCoder) not implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Add navigator as child view controller + addChild(navigator) + navigator.view.frame = view.bounds + navigator.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(navigator.view) + navigator.didMove(toParent: self) + } +} + +struct ReaderViewControllerWrapper: UIViewControllerRepresentable { + let navigator: VisualNavigator & UIViewController + + func makeUIViewController(context: Context) -> ReaderViewController { + ReaderViewController(navigator: navigator) + } + + func updateUIViewController(_ uiViewController: ReaderViewController, context: Context) {} +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist b/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist new file mode 100644 index 0000000000..64d65ca495 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift new file mode 100644 index 0000000000..a320ae2c64 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift @@ -0,0 +1,47 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +/// These tests verify that navigator instances are properly deallocated when +/// dismissed. +/// +/// The host app maintains a weak reference to the navigator. If the navigator +/// is properly deallocated after dismissal, the weak reference becomes nil. +/// If it remains non-nil, a retain cycle or memory leak exists. +final class MemoryLeakTests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + func testEPUBNavigatorDeallocatesAfterClosing() { + app + .open(.childrensLiteratureEPUB, waitUntilReady: true) + .close(assertMemoryDeallocated: true) + } + + func testEPUBNavigatorDeallocatesAfterClosingBeforeReady() { + app + .open(.childrensLiteratureEPUB, waitUntilReady: false) + .close(assertMemoryDeallocated: true) + } + + func testPDFNavigatorDeallocatesAfterClosing() { + app + .open(.daisyPDF, waitUntilReady: true) + .close(assertMemoryDeallocated: true) + } + + func testPDFNavigatorDeallocatesAfterClosingBeforeReady() { + app + .open(.daisyPDF, waitUntilReady: false) + .close(assertMemoryDeallocated: true) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/NavigationStressTests.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/NavigationStressTests.swift new file mode 100644 index 0000000000..1abbe2632d --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/NavigationStressTests.swift @@ -0,0 +1,33 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +final class NavigationStressTests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + /// Rapidly navigates to random positions in an EPUB to trigger many + /// concurrent resource loads and cancellations. If the app crashes, XCTest + /// reports the failure automatically. + /// + /// Stress tests verifying that rapid navigation does not crash the app due to + /// `WKURLSchemeTask` cancellation races. + /// See https://github.com/readium/r2-navigator-swift/pull/160 + func testRapidNavigationDoesNotCrashOnEPUB() { + let reader = app.open(.childrensLiteratureEPUB, waitUntilReady: true) + + app.buttons[.runStressTest].tap() + app.switches[.stressTestCompleted].assertIs(true, waitForTimeout: 120) + + reader.close(assertMemoryDeallocated: true) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift new file mode 100644 index 0000000000..4ab5ca6803 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift @@ -0,0 +1,61 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +extension XCUIApplication { + /// Opens a publication fixture. + @discardableResult + func open(_ fixture: PublicationFixture, waitUntilReady: Bool = true) -> ReaderUI { + staticTexts[fixture.accessibilityIdentifier].firstMatch.tap() + + let reader = ReaderUI(app: self) + + if waitUntilReady { + // Give the navigator time to fully load content. + reader.assertReady() + } + + return reader + } + + /// Checks that some memory is allocated in the app. + @discardableResult + func assertSomeMemoryAllocated() -> Self { + switches[.allMemoryDeallocated].assertIs(false) + return self + } + + /// Checks that all the tracked memory is deallocated in the app. + /// + /// A timeout is used to make sure the memory is cleared. + @discardableResult + func assertAllMemoryDeallocated() -> Self { + switches[.allMemoryDeallocated].assertIs(true, waitForTimeout: 120) + return self + } +} + +struct ReaderUI { + let app: XCUIApplication + + /// Activates the Close button. + @discardableResult + func close(assertMemoryDeallocated: Bool = true) -> XCUIApplication { + app.buttons[.close].tap() + if assertMemoryDeallocated { + app.assertAllMemoryDeallocated() + } + return app + } + + /// Waits for the navigator to be ready. + @discardableResult + func assertReady(timeout: TimeInterval = 30) -> Self { + app.switches[.isNavigatorReady].assertIs(true, waitForTimeout: timeout) + return self + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift new file mode 100644 index 0000000000..635587bc5b --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift @@ -0,0 +1,43 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +extension XCUIElementQuery { + subscript(id: AccessibilityID) -> XCUIElement { + self[id.rawValue] + } +} + +extension XCUIElement { + var stringValue: String? { + value as? String + } + + func assertIsOn() { + assertIs(true) + } + + func assertIsOff() { + assertIs(false) + } + + func assertIs(_ on: Bool, waitForTimeout timeout: TimeInterval? = nil) { + let expectedValue = on ? "1" : "0" + let message = "Expected to be \(on ? "on" : "off")" + + if let timeout = timeout { + XCTAssertTrue(wait(toBe: on, timeout: timeout), message) + } else { + XCTAssertEqual(stringValue, expectedValue, message) + } + } + + func wait(toBe on: Bool, timeout: TimeInterval) -> Bool { + let expectedValue = on ? "1" : "0" + return wait(for: \.stringValue, toEqual: expectedValue, timeout: timeout) + } +} diff --git a/Tests/NavigatorTests/UITests/README.md b/Tests/NavigatorTests/UITests/README.md new file mode 100644 index 0000000000..e89749c262 --- /dev/null +++ b/Tests/NavigatorTests/UITests/README.md @@ -0,0 +1,20 @@ +# Navigator UI Tests + +This test host app provides a controlled environment for running UI tests against Readium Navigators in a real app context with full WebKit and SwiftUI lifecycle. It's designed to be simple and maintainable, avoiding the complexity of the main TestApp. + +## Generate Xcode Project + +```bash +cd Tests/NavigatorTests/UITests +xcodegen generate +``` + +This creates `NavigatorUITests.xcodeproj` from `project.yml`. + +## Running Tests from Xcode + +1. Open `NavigatorUITests.xcodeproj` +2. Select the `NavigatorTestHost` scheme +3. Choose a simulator (iPhone or iPad) +4. Run tests: Cmd+U or Product > Test + diff --git a/Tests/NavigatorTests/UITests/project.yml b/Tests/NavigatorTests/UITests/project.yml new file mode 100644 index 0000000000..19891c7596 --- /dev/null +++ b/Tests/NavigatorTests/UITests/project.yml @@ -0,0 +1,51 @@ +name: NavigatorUITests +options: + bundleIdPrefix: org.readium.test +packages: + Readium: + path: ../../.. +schemes: + NavigatorTestHost: + build: + targets: + NavigatorTestHost: all + test: + targets: + - NavigatorUITests +targets: + NavigatorTestHost: + type: application + platform: iOS + deploymentTarget: 15.0 + sources: + - path: NavigatorTestHost + - path: ../../Publications/Publications + type: folder + buildPhase: resources + dependencies: + - package: Readium + product: ReadiumShared + - package: Readium + product: ReadiumStreamer + - package: Readium + product: ReadiumNavigator + - package: Readium + product: ReadiumAdapterGCDWebServer + settings: + INFOPLIST_FILE: NavigatorTestHost/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: org.readium.test.NavigatorTestHost + + NavigatorUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: 15.0 + sources: + - NavigatorUITests + - NavigatorTestHost/AccessibilityID.swift + - NavigatorTestHost/Fixtures + dependencies: + - target: NavigatorTestHost + settings: + INFOPLIST_FILE: NavigatorUITests/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: org.readium.test.NavigatorUITests + TEST_TARGET_NAME: NavigatorTestHost diff --git a/Tests/OPDSTests/readium_opds1_1_test.swift b/Tests/OPDSTests/readium_opds1_1_test.swift index 26746efd47..93fc63c967 100644 --- a/Tests/OPDSTests/readium_opds1_1_test.swift +++ b/Tests/OPDSTests/readium_opds1_1_test.swift @@ -1,14 +1,12 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // -import XCTest - -import ReadiumShared - @testable import ReadiumOPDS +import ReadiumShared +import XCTest #if !SWIFT_PACKAGE extension Bundle { @@ -46,45 +44,45 @@ class readium_opds1_1_test: XCTestCase { } func testMetadata() { - XCTAssert(feed!.metadata.identifier == "urn:uuid:433a5d6a-0b8c-4933-af65-4ca4f02763eb") - XCTAssert(feed!.metadata.title == "Unpopular Publications") + XCTAssert(feed?.metadata.identifier == "urn:uuid:433a5d6a-0b8c-4933-af65-4ca4f02763eb") + XCTAssert(feed?.metadata.title == "Unpopular Publications") // TODO: add more tests... } - func testLinks() { + func testLinks() throws { XCTAssertEqual(feed.links.count, 5) // Has a "related" link - let expectedRelatedLink = Link( + let expectedRelatedLink = try Link( href: "http://test.com/opds-catalogs/vampire.farming.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")), rels: ["related"] ) let relatedLink = feed?.links.first { $0.rels.contains("related") } XCTAssertEqual(relatedLink, expectedRelatedLink) // Has a "self" link - let expectedSelfLink = Link( + let expectedSelfLink = try Link( href: "http://test.com/opds-catalogs/unpopular.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")), rels: ["self"] ) let selfLink = feed?.links.first { $0.rels.contains("self") } XCTAssertEqual(selfLink, expectedSelfLink) // Has a "start" link - let expectedStartLink = Link( + let expectedStartLink = try Link( href: "http://test.com/opds-catalogs/root.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")), rels: ["start"] ) let startLink = feed?.links.first { $0.rels.contains("start") } XCTAssertEqual(startLink, expectedStartLink) // Has an "up" link - let expectedUpLink = Link( + let expectedUpLink = try Link( href: "http://test.com/opds-catalogs/root.xml", - mediaType: MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")!, + mediaType: XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog;kind=navigation")), rels: ["up"] ) let upLink = feed?.links.first { $0.rels.contains("up") } diff --git a/Tests/OPDSTests/readium_opds2_0_test.swift b/Tests/OPDSTests/readium_opds2_0_test.swift index eb3083e73e..854ae153a1 100644 --- a/Tests/OPDSTests/readium_opds2_0_test.swift +++ b/Tests/OPDSTests/readium_opds2_0_test.swift @@ -1,14 +1,13 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // +@testable import ReadiumOPDS import ReadiumShared import XCTest -@testable import ReadiumOPDS - class readium_opds2_0_test: XCTestCase { var feed: Feed? diff --git a/Tests/SharedTests/Fixtures/Fetcher/epub.epub b/Tests/Publications/Publications/childrens-literature.epub similarity index 100% rename from Tests/SharedTests/Fixtures/Fetcher/epub.epub rename to Tests/Publications/Publications/childrens-literature.epub diff --git a/Tests/LCPTests/Fixtures/daisy.lcpdf b/Tests/Publications/Publications/daisy.lcpdf similarity index 100% rename from Tests/LCPTests/Fixtures/daisy.lcpdf rename to Tests/Publications/Publications/daisy.lcpdf diff --git a/Tests/LCPTests/Fixtures/daisy.pdf b/Tests/Publications/Publications/daisy.pdf similarity index 100% rename from Tests/LCPTests/Fixtures/daisy.pdf rename to Tests/Publications/Publications/daisy.pdf diff --git a/Tests/Publications/TestPublications.swift b/Tests/Publications/TestPublications.swift new file mode 100644 index 0000000000..420e90b60a --- /dev/null +++ b/Tests/Publications/TestPublications.swift @@ -0,0 +1,29 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// Provides access to shared test publication files. +public enum TestPublications { + /// Returns the resource bundle containing shared test publications. + public static let bundle = Bundle.module + + /// Returns a URL for the specified publication file. + /// + /// - Parameter filename: The filename with extension (e.g., "childrens-literature.epub"). + /// - Returns: A URL pointing to the publication file. + public static func url(for filename: String) -> URL { + let components = filename.split(separator: ".", maxSplits: 1) + let name = String(components[0]) + let ext = components.count > 1 ? String(components[1]) : nil + + guard let url = bundle.url(forResource: name, withExtension: ext, subdirectory: "Publications") else { + fatalError("Test publication '\(filename)' not found in TestPublications bundle") + } + + return url + } +} diff --git a/Tests/SharedTests/Asserts.swift b/Tests/SharedTests/Asserts.swift index ff464a08ca..8dc483b96a 100644 --- a/Tests/SharedTests/Asserts.swift +++ b/Tests/SharedTests/Asserts.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/EquatableError.swift b/Tests/SharedTests/EquatableError.swift index bf3bc0c567..a1c3353b3b 100644 --- a/Tests/SharedTests/EquatableError.swift +++ b/Tests/SharedTests/EquatableError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Extensions.swift b/Tests/SharedTests/Extensions.swift index dbbd600041..6b6c1221f3 100644 --- a/Tests/SharedTests/Extensions.swift +++ b/Tests/SharedTests/Extensions.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Fixtures.swift b/Tests/SharedTests/Fixtures.swift index d232a7e594..c530e4d54b 100644 --- a/Tests/SharedTests/Fixtures.swift +++ b/Tests/SharedTests/Fixtures.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/JSON.swift b/Tests/SharedTests/JSON.swift index b57c0b37ca..f5ff174bc2 100644 --- a/Tests/SharedTests/JSON.swift +++ b/Tests/SharedTests/JSON.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift b/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift index a80a6355ef..0633a98a07 100644 --- a/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift +++ b/Tests/SharedTests/OPDS/OPDSAcquisitionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift index 5d1b5318ab..64785c7a58 100644 --- a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift +++ b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/OPDS/OPDSCopiesTests.swift b/Tests/SharedTests/OPDS/OPDSCopiesTests.swift index d8385958a3..47a10da306 100644 --- a/Tests/SharedTests/OPDS/OPDSCopiesTests.swift +++ b/Tests/SharedTests/OPDS/OPDSCopiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/OPDS/OPDSHoldsTests.swift b/Tests/SharedTests/OPDS/OPDSHoldsTests.swift index 3d9ca2d867..67991790bf 100644 --- a/Tests/SharedTests/OPDS/OPDSHoldsTests.swift +++ b/Tests/SharedTests/OPDS/OPDSHoldsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/OPDS/OPDSPriceTests.swift b/Tests/SharedTests/OPDS/OPDSPriceTests.swift index 04bf8212b7..136a280c75 100644 --- a/Tests/SharedTests/OPDS/OPDSPriceTests.swift +++ b/Tests/SharedTests/OPDS/OPDSPriceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/ProxyContainer.swift b/Tests/SharedTests/ProxyContainer.swift index 5fbb91cdba..35d4c952cc 100644 --- a/Tests/SharedTests/ProxyContainer.swift +++ b/Tests/SharedTests/ProxyContainer.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift b/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift index b3ffb77830..3720fa74f5 100644 --- a/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift +++ b/Tests/SharedTests/Publication/Accessibility/AccessibilityMetadataDisplayGuideTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -30,6 +30,32 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { ) } + /// Tests the fallback behavior for strings without -compact/-descriptive + /// suffixes. + /// Some strings in thorium-locales have identical compact and descriptive + /// values, so they are stored with only the base key. + func testDisplayStatementLocalizedStringFallbackToBaseKey() { + // Test hazardsNoMetadata + XCTAssertEqual( + AccessibilityDisplayString.hazardsNoMetadata.localized(descriptive: false).string, + "No information is available" + ) + XCTAssertEqual( + AccessibilityDisplayString.hazardsNoMetadata.localized(descriptive: true).string, + "No information is available" + ) + + // Test conformanceNo + XCTAssertEqual( + AccessibilityDisplayString.additionalAccessibilityInformationRubyAnnotations.localized(descriptive: false).string, + "Some Ruby annotations" + ) + XCTAssertEqual( + AccessibilityDisplayString.additionalAccessibilityInformationRubyAnnotations.localized(descriptive: true).string, + "Some Ruby annotations" + ) + } + func testDisplayStatementCustomLocalizedString() { let statement = AccessibilityDisplayStatement( string: .waysOfReadingNonvisualReadingReadable, @@ -433,9 +459,9 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { transcript: true ).statements.map(\.id), [ - .richContentExtended, + .richContentExtendedDescriptions, .richContentAccessibleMathDescribed, - .richContentAccessibleMathAsMathml, + .richContentMathAsMathml, .richContentAccessibleMathAsLatex, .richContentAccessibleChemistryAsMathml, .richContentAccessibleChemistryAsLatex, @@ -459,7 +485,7 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { transcript: false ).statements.map(\.id), [ - .richContentExtended, + .richContentExtendedDescriptions, ] ) @@ -493,7 +519,7 @@ class AccessibilityMetadataDisplayGuideTests: XCTestCase { transcript: false ).statements.map(\.id), [ - .richContentAccessibleMathAsMathml, + .richContentMathAsMathml, ] ) diff --git a/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift b/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift index 72f5145ab1..bd5228fb69 100644 --- a/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift +++ b/Tests/SharedTests/Publication/Accessibility/AccessibilityTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/ContributorTests.swift b/Tests/SharedTests/Publication/ContributorTests.swift index f2c027fbd1..72cc201ffc 100644 --- a/Tests/SharedTests/Publication/ContributorTests.swift +++ b/Tests/SharedTests/Publication/ContributorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift b/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift index 34ee9761d3..f64f797f0d 100644 --- a/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift index ebc4706304..a483ceee78 100644 --- a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift index 90b27bae05..eb03f53caa 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/EPUBLayoutTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift new file mode 100644 index 0000000000..da76e0856d --- /dev/null +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift @@ -0,0 +1,90 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import Testing + +@Suite enum MetadataEPUBTests { + @Suite("EPUBMediaOverlay") enum EPUBMediaOverlayTests { + @Suite("JSON parsing") struct JSONParsing { + @Test("full content") + func fullContent() { + let sut = EPUBMediaOverlay(json: [ + "activeClass": "-epub-media-overlay-active", + "playbackActiveClass": "-epub-media-overlay-playing", + ] as [String: Any]) + + #expect(sut?.activeClass == "-epub-media-overlay-active") + #expect(sut?.playbackActiveClass == "-epub-media-overlay-playing") + } + + @Test("only activeClass returns non-nil") + func onlyActiveClassReturnsNonNil() { + let sut = EPUBMediaOverlay(json: ["activeClass": "-epub-media-overlay-active"] as [String: Any]) + #expect(sut?.activeClass == "-epub-media-overlay-active") + #expect(sut?.playbackActiveClass == nil) + } + + @Test("only playbackActiveClass returns non-nil") + func onlyPlaybackActiveClassReturnsNonNil() { + let sut = EPUBMediaOverlay(json: ["playbackActiveClass": "-epub-media-overlay-playing"] as [String: Any]) + #expect(sut?.playbackActiveClass == "-epub-media-overlay-playing") + #expect(sut?.activeClass == nil) + } + + @Test("empty dictionary returns nil") + func emptyDictionaryReturnsNil() { + #expect(EPUBMediaOverlay(json: [:] as [String: Any]) == nil) + } + + @Test("nil returns nil") + func nilReturnsNil() { + #expect(EPUBMediaOverlay(json: nil) == nil) + } + + @Test("non-dictionary returns nil") + func nonDictionaryReturnsNil() { + #expect(EPUBMediaOverlay(json: "not-a-dict") == nil) + } + } + + @Suite("JSON encoding") struct JSONEncoding { + @Test("round-trip preserves all values") + func roundTrip() { + let original = EPUBMediaOverlay( + activeClass: "-epub-media-overlay-active", + playbackActiveClass: "-epub-media-overlay-playing" + ) + + #expect(EPUBMediaOverlay(json: original.json) == original) + } + + @Test("nil values are omitted from JSON") + func omitsNilValues() { + let sut = EPUBMediaOverlay(activeClass: "-epub-media-overlay-active") + #expect(sut.json["playbackActiveClass"] == nil) + } + } + } + + @Suite("Metadata.mediaOverlay accessor") struct MediaOverlayAccessorTests { + @Test("returns nil when absent") + func returnsNilWhenAbsent() { + let metadata = Metadata(title: "Test") + #expect(metadata.mediaOverlay == nil) + } + + @Test("returns value when present in otherMetadata") + func returnsValueWhenPresent() { + var metadata = Metadata(title: "Test") + metadata.otherMetadata["mediaOverlay"] = [ + "activeClass": "-epub-media-overlay-active", + ] as [String: Any] + + #expect(metadata.mediaOverlay?.activeClass == "-epub-media-overlay-active") + } + } +} diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift index e43b3db63f..0d2f07fd55 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Properties+EPUBTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift b/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift index f7ecb24cc1..7f2acd6f2d 100644 --- a/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift +++ b/Tests/SharedTests/Publication/Extensions/EPUB/Publication+EPUBTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift b/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift index 6f16d3abb8..9debc4ea77 100644 --- a/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Encryption/EncryptionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift b/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift index b3fdc40e7d..bf4641f440 100644 --- a/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Encryption/Properties+EncryptionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift b/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift index 9fc9e78047..97ae6288d9 100644 --- a/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift +++ b/Tests/SharedTests/Publication/Extensions/HTML/DOMRangeTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift b/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift index 56d7ddce51..e2622abf46 100644 --- a/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift +++ b/Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift b/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift index 95372c23f8..f86050afc0 100644 --- a/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift +++ b/Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift b/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift index 757cf46541..dbda440e55 100644 --- a/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift +++ b/Tests/SharedTests/Publication/Extensions/OPDS/Publication+OPDSTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift new file mode 100644 index 0000000000..1e75c4abb9 --- /dev/null +++ b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationDocumentTests.swift @@ -0,0 +1,61 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumShared +import Testing + +@Suite enum GuidedNavigationDocumentTests { + @Suite("Parsing") struct Parsing { + @Test("minimal JSON with just guided") + func minimalJSON() throws { + let sut = try GuidedNavigationDocument(json: [ + "guided": [ + ["textref": "chapter1.html"], + ], + ]) + + #expect(try sut == GuidedNavigationDocument( + guided: [ + #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "chapter1.html")))), + ] + )) + } + + @Test("full JSON with guided") + func fullJSON() throws { + let sut = try GuidedNavigationDocument(json: [ + "guided": [ + ["textref": "chapter1.html"], + ["audioref": "track.mp3"], + ], + ]) + + #expect(sut?.guided.count == 2) + } + + @Test("missing guided throws") + func missingGuided() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationDocument(json: [:]) + } + } + + @Test("empty guided array throws") + func emptyGuided() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationDocument(json: [ + "guided": [], + ]) + } + } + + @Test("nil JSON returns nil") + func nilJSON() throws { + let sut = try GuidedNavigationDocument(json: nil) + #expect(sut == nil) + } + } +} diff --git a/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift new file mode 100644 index 0000000000..9ad3a1cfa1 --- /dev/null +++ b/Tests/SharedTests/Publication/GuidedNavigation/GuidedNavigationObjectTests.swift @@ -0,0 +1,243 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +@testable import ReadiumShared +import Testing + +@Suite enum GuidedNavigationObjectTests { + @Suite("Parsing") struct Parsing { + @Test("minimal JSON with only textref") + func minimalTextref() throws { + let sut = try GuidedNavigationObject(json: [ + "textref": "chapter1.html", + ]) + #expect(sut == GuidedNavigationObject( + refs: .init(text: AnyURL(string: "chapter1.html")) + )) + } + + @Test("full JSON with all properties") + func fullJSON() throws { + let sut = try GuidedNavigationObject(json: [ + "id": "obj1", + "audioref": "audio.mp3#t=0,20", + "imgref": "page1.jpg", + "textref": "chapter1.html", + "videoref": "video.mp4#t=10,30", + "text": ["plain": "Hello", "ssml": "Hello", "language": "en"], + "role": ["chapter", "heading2"], + "children": [ + ["textref": "child.html"], + ], + "description": ["textref": "desc.html"], + ]) + + #expect(try sut == GuidedNavigationObject( + id: "obj1", + refs: .init( + text: AnyURL(string: "chapter1.html"), + img: AnyURL(string: "page1.jpg"), + audio: AnyURL(string: "audio.mp3#t=0,20"), + video: AnyURL(string: "video.mp4#t=10,30") + ), + text: .init(plain: "Hello", ssml: "Hello", language: Language(code: .bcp47("en"))), + roles: [.chapter, .heading2], + description: .init(refs: .init(text: AnyURL(string: "desc.html"))), + children: [ + #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "child.html")))), + ] + )) + } + + @Test("text as bare string normalizes to Text(plain:)") + func textBareString() throws { + let sut = try GuidedNavigationObject(json: [ + "text": "Hello world", + ]) + #expect(sut?.text == GuidedNavigationObject.Text(plain: "Hello world")) + } + + @Test("text as object with plain, ssml, and language") + func textObject() throws { + let sut = try GuidedNavigationObject(json: [ + "text": ["plain": "Hello", "ssml": "Hello", "language": "en"], + ]) + #expect(sut?.text == GuidedNavigationObject.Text( + plain: "Hello", + ssml: "Hello", + language: Language(code: .bcp47("en")) + )) + } + + @Test("requires at least one ref, text, or children") + func requiresContent() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationObject(json: [ + "id": "empty", + "role": ["chapter"], + ]) + } + } + + @Test("nested children parse correctly") + func nestedChildren() throws { + let sut = try GuidedNavigationObject(json: [ + "children": [ + [ + "textref": "a.html", + "children": [ + ["textref": "b.html"], + ], + ], + ], + ]) + + #expect(sut?.children.count == 1) + #expect(sut?.children.first?.children.count == 1) + #expect(sut?.children.first?.children.first?.refs?.text == AnyURL(string: "b.html")!) + } + + @Test("recursive description parses correctly") + func recursiveDescription() throws { + let sut = try GuidedNavigationObject(json: [ + "textref": "main.html", + "description": [ + "text": "A description", + ], + ]) + + #expect(sut?.description == GuidedNavigationObject.Description( + text: .init(plain: "A description") + )) + } + + @Test("unknown roles are preserved") + func unknownRoles() throws { + let sut = try GuidedNavigationObject(json: [ + "textref": "c.html", + "role": ["chapter", "custom-role"], + ]) + #expect(sut?.roles == [.chapter, GuidedNavigationObject.Role("custom-role")]) + } + + @Test("nil JSON returns nil") + func nilJSON() throws { + let sut = try GuidedNavigationObject(json: nil) + #expect(sut == nil) + } + } + + @Suite("Refs") struct RefsTests { + @Test("parses all ref types from JSON") + func parsesAllRefs() throws { + let sut = try GuidedNavigationObject.Refs(json: [ + "textref": "chapter.html", + "imgref": "page.jpg", + "audioref": "track.mp3", + "videoref": "clip.mp4", + ]) + #expect(sut == GuidedNavigationObject.Refs( + text: AnyURL(string: "chapter.html"), + img: AnyURL(string: "page.jpg"), + audio: AnyURL(string: "track.mp3"), + video: AnyURL(string: "clip.mp4") + )) + } + + @Test("returns nil when no refs present") + func nilWhenNoRefs() throws { + let sut = try GuidedNavigationObject.Refs(json: [ + "id": "test", + ]) + #expect(sut == nil) + } + + @Test("preserves URI fragments") + func preservesFragments() throws { + let sut = try GuidedNavigationObject.Refs(json: [ + "audioref": "audio.mp3#t=0,20", + ]) + #expect(sut?.audio == AnyURL(string: "audio.mp3#t=0,20")!) + } + } + + @Suite("Description") struct DescriptionTests { + @Test("parses description with text") + func withText() throws { + let sut = try GuidedNavigationObject.Description(json: [ + "text": "A description", + ]) + #expect(sut == GuidedNavigationObject.Description( + text: .init(plain: "A description") + )) + } + + @Test("parses description with refs") + func withRefs() throws { + let sut = try GuidedNavigationObject.Description(json: [ + "imgref": "desc.jpg", + ]) + #expect(sut == GuidedNavigationObject.Description( + refs: .init(img: AnyURL(string: "desc.jpg")) + )) + } + + @Test("throws when empty") + func throwsWhenEmpty() throws { + #expect(throws: JSONError.self) { + try GuidedNavigationObject.Description(json: [ + "id": "nothing", + ]) + } + } + } + + @Suite("Text") struct TextTests { + @Test("returns nil when both plain and ssml are nil") + func nilWhenBothNil() { + let text = GuidedNavigationObject.Text() + #expect(text == nil) + } + + @Test("returns nil when plain is empty and ssml is nil") + func nilWhenPlainEmpty() { + let text = GuidedNavigationObject.Text(plain: "") + #expect(text == nil) + } + + @Test("returns nil when ssml is empty and plain is nil") + func nilWhenSsmlEmpty() { + let text = GuidedNavigationObject.Text(ssml: "") + #expect(text == nil) + } + + @Test("returns nil when both plain and ssml are empty strings") + func nilWhenBothEmpty() { + let text = GuidedNavigationObject.Text(plain: "", ssml: "") + #expect(text == nil) + } + + @Test("succeeds when only plain is non-empty") + func succeedsWithPlainOnly() { + let text = GuidedNavigationObject.Text(plain: "Hello") + #expect(text != nil) + #expect(text?.plain == "Hello") + } + + @Test("succeeds when only ssml is non-empty") + func succeedsWithSsmlOnly() { + let text = GuidedNavigationObject.Text(ssml: "Hi") + #expect(text != nil) + #expect(text?.ssml == "Hi") + } + + @Test("JSON object with empty plain and ssml returns nil") + func jsonObjectEmptyStringsReturnsNil() throws { + let sut = try GuidedNavigationObject.Text(json: ["plain": "", "ssml": ""]) + #expect(sut == nil) + } + } +} diff --git a/Tests/SharedTests/Publication/HREFNormalizerTests.swift b/Tests/SharedTests/Publication/HREFNormalizerTests.swift index f77e0213a2..116f594171 100644 --- a/Tests/SharedTests/Publication/HREFNormalizerTests.swift +++ b/Tests/SharedTests/Publication/HREFNormalizerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -85,7 +85,7 @@ class HREFNormalizerTests: XCTestCase { func testNormalizeManifestHREFsToBaseURL() throws { var sut = manifest - try sut.normalizeHREFs(to: AnyURL(string: "https://other/dir/")!) + try sut.normalizeHREFs(to: XCTUnwrap(AnyURL(string: "https://other/dir/"))) XCTAssertEqual( sut, @@ -149,7 +149,7 @@ class HREFNormalizerTests: XCTestCase { ), ] ) - try sut.normalizeHREFs(to: AnyURL(string: "https://other/dir/")!) + try sut.normalizeHREFs(to: XCTUnwrap(AnyURL(string: "https://other/dir/"))) XCTAssertEqual( sut, diff --git a/Tests/SharedTests/Publication/LinkArrayTests.swift b/Tests/SharedTests/Publication/LinkArrayTests.swift index 011fea654e..9ea0cbbe73 100644 --- a/Tests/SharedTests/Publication/LinkArrayTests.swift +++ b/Tests/SharedTests/Publication/LinkArrayTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -49,37 +49,37 @@ class LinkArrayTests: XCTestCase { } /// Finds the first `Link` with given `href`. - func testFirstWithHREF() { + func testFirstWithHREF() throws { let links = [ Link(href: "l1"), Link(href: "l2"), Link(href: "l2", rel: "test"), ] - XCTAssertEqual(links.firstWithHREF(AnyURL(string: "l2")!), Link(href: "l2")) + XCTAssertEqual(try links.firstWithHREF(XCTUnwrap(AnyURL(string: "l2"))), Link(href: "l2")) } /// Finds the first `Link` with given `href` when none is found. - func testFirstWithHREFNotFound() { + func testFirstWithHREFNotFound() throws { let links = [Link(href: "l1")] - XCTAssertNil(links.firstWithHREF(AnyURL(string: "unknown")!)) + XCTAssertNil(try links.firstWithHREF(XCTUnwrap(AnyURL(string: "unknown")))) } /// Finds the index of the first `Link` with given `href`. - func testFirstIndexWithHREF() { + func testFirstIndexWithHREF() throws { let links = [ Link(href: "l1"), Link(href: "l2"), Link(href: "l2", rel: "test"), ] - XCTAssertEqual(links.firstIndexWithHREF(AnyURL(string: "l2")!), 1) + XCTAssertEqual(try links.firstIndexWithHREF(XCTUnwrap(AnyURL(string: "l2"))), 1) } /// Finds the index of the first `Link` with given `href` when none is found. - func testFirstIndexWithHREFNotFound() { + func testFirstIndexWithHREFNotFound() throws { let links = [Link(href: "l1")] - XCTAssertNil(links.firstIndexWithHREF(AnyURL(string: "unknown")!)) + XCTAssertNil(try links.firstIndexWithHREF(XCTUnwrap(AnyURL(string: "unknown")))) } /// Finds the first `Link` with a `type` matching the given `mediaType`. @@ -95,9 +95,9 @@ class LinkArrayTests: XCTestCase { /// Finds the first `Link` with a `type` matching the given `mediaType`, even if the `type` has /// extra parameters. - func testFirstWithMediaTypeWithExtraParameter() { - let links = [ - Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), + func testFirstWithMediaTypeWithExtraParameter() throws { + let links = try [ + Link(href: "l1", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), ] XCTAssertEqual(links.firstWithMediaType(.html)?.href, "l1") @@ -125,16 +125,16 @@ class LinkArrayTests: XCTestCase { /// Finds all the `Link` with a `type` matching the given `mediaType`, even if the `type` has /// extra parameters. - func testFilterByMediaTypeWithExtraParameter() { - let links = [ + func testFilterByMediaTypeWithExtraParameter() throws { + let links = try [ Link(href: "l1", mediaType: .css), Link(href: "l2", mediaType: .html), - Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l1", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), ] - XCTAssertEqual(links.filterByMediaType(.html), [ + XCTAssertEqual(links.filterByMediaType(.html), try [ Link(href: "l2", mediaType: .html), - Link(href: "l1", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l1", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), ]) } @@ -145,15 +145,15 @@ class LinkArrayTests: XCTestCase { } /// Finds all the `Link` with a `type` matching any of the given `mediaTypes`. - func testFilterByMediaTypes() { - let links = [ + func testFilterByMediaTypes() throws { + let links = try [ Link(href: "l1", mediaType: .css), - Link(href: "l2", mediaType: MediaType("text/html;charset=utf-8")!), + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), Link(href: "l3", mediaType: .xml), ] - XCTAssertEqual(links.filterByMediaTypes([.html, .xml]), [ - Link(href: "l2", mediaType: MediaType("text/html;charset=utf-8")!), + XCTAssertEqual(links.filterByMediaTypes([.html, .xml]), try [ + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), Link(href: "l3", mediaType: .xml), ]) } @@ -199,9 +199,9 @@ class LinkArrayTests: XCTestCase { } /// Checks if all the links are video clips. - func testAllAreVideo() { - let links = [ - Link(href: "l1", mediaType: MediaType("video/mp4")!), + func testAllAreVideo() throws { + let links = try [ + Link(href: "l1", mediaType: XCTUnwrap(MediaType("video/mp4"))), Link(href: "l2", mediaType: .webmVideo), ] @@ -238,11 +238,36 @@ class LinkArrayTests: XCTestCase { XCTAssertFalse(links.allAreHTML) } - /// Checks if all the links match the given media type. - func testAllMatchesMediaType() { + /// Checks if any link matches the given media type. + func testAnyMatchingMediaType() throws { + let links = try [ + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/html;charset=utf-8"))), + ] + + XCTAssertTrue(links.anyMatchingMediaType(.html)) + } + + /// Checks if any link matches the given media type, when it's not the case. + func testAnyMatchingMediaTypeFalse() { let links = [ Link(href: "l1", mediaType: .css), - Link(href: "l2", mediaType: MediaType("text/css;charset=utf-8")!), + Link(href: "l2", mediaType: .text), + ] + + XCTAssertFalse(links.anyMatchingMediaType(.html)) + } + + /// Checks if any link matches the given media type in an empty collection. + func testAnyMatchingMediaTypeEmpty() { + XCTAssertFalse([Link]().anyMatchingMediaType(.html)) + } + + /// Checks if all the links match the given media type. + func testAllMatchesMediaType() throws { + let links = try [ + Link(href: "l1", mediaType: .css), + Link(href: "l2", mediaType: XCTUnwrap(MediaType("text/css;charset=utf-8"))), ] XCTAssertTrue(links.allMatchingMediaType(.css)) diff --git a/Tests/SharedTests/Publication/LinkTests.swift b/Tests/SharedTests/Publication/LinkTests.swift index ee6dac7bb0..611426d7f7 100644 --- a/Tests/SharedTests/Publication/LinkTests.swift +++ b/Tests/SharedTests/Publication/LinkTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -224,33 +224,33 @@ class LinkTests: XCTestCase { func testURLRelativeToBaseURL() throws { XCTAssertEqual( - Link(href: "folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), - AnyURL(string: "http://host/folder/file.html")! + try Link(href: "folder/file.html").url(relativeTo: XCTUnwrap(AnyURL(string: "http://host/"))), + AnyURL(string: "http://host/folder/file.html") ) } func testURLRelativeToBaseURLWithRootPrefix() throws { XCTAssertEqual( - Link(href: "file.html").url(relativeTo: AnyURL(string: "http://host/folder/")!), - AnyURL(string: "http://host/folder/file.html")! + try Link(href: "file.html").url(relativeTo: XCTUnwrap(AnyURL(string: "http://host/folder/"))), + AnyURL(string: "http://host/folder/file.html") ) } - func testURLRelativeToNil() throws { + func testURLRelativeToNil() { XCTAssertEqual( Link(href: "http://example.com/folder/file.html").url(), - AnyURL(string: "http://example.com/folder/file.html")! + AnyURL(string: "http://example.com/folder/file.html") ) XCTAssertEqual( Link(href: "folder/file.html").url(), - AnyURL(string: "folder/file.html")! + AnyURL(string: "folder/file.html") ) } func testURLWithAbsoluteHREF() throws { XCTAssertEqual( - Link(href: "http://test.com/folder/file.html").url(relativeTo: AnyURL(string: "http://host/")!), - AnyURL(string: "http://test.com/folder/file.html")! + try Link(href: "http://test.com/folder/file.html").url(relativeTo: XCTUnwrap(AnyURL(string: "http://host/"))), + AnyURL(string: "http://test.com/folder/file.html") ) } diff --git a/Tests/SharedTests/Publication/LocalizedStringTests.swift b/Tests/SharedTests/Publication/LocalizedStringTests.swift index 6c5103864c..64f79f81b8 100644 --- a/Tests/SharedTests/Publication/LocalizedStringTests.swift +++ b/Tests/SharedTests/Publication/LocalizedStringTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/LocatorTests.swift b/Tests/SharedTests/Publication/LocatorTests.swift index be4764f5ac..34a4d24099 100644 --- a/Tests/SharedTests/Publication/LocatorTests.swift +++ b/Tests/SharedTests/Publication/LocatorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -340,7 +340,7 @@ class LocatorTextTests: XCTestCase { ) } - func testSubstringFromRange() { + func testSubstringFromRange() throws { let highlight = "highlight" let text = Locator.Text( after: "after", @@ -349,7 +349,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "h")!], + try text[XCTUnwrap(highlight.range(of: "h"))], Locator.Text( after: "ighlightafter", before: "before", @@ -358,7 +358,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "lig")!], + try text[XCTUnwrap(highlight.range(of: "lig"))], Locator.Text( after: "htafter", before: "beforehigh", @@ -367,7 +367,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "highlight")!], + try text[XCTUnwrap(highlight.range(of: "highlight"))], Locator.Text( after: "after", before: "before", @@ -376,7 +376,7 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - text[highlight.range(of: "ght")!], + try text[XCTUnwrap(highlight.range(of: "ght"))], Locator.Text( after: "after", before: "beforehighli", @@ -405,15 +405,15 @@ class LocatorTextTests: XCTestCase { ) } - func testSubstringFromARangeWithNilComponents() { + func testSubstringFromARangeWithNilComponents() throws { let highlight = "highlight" XCTAssertEqual( - Locator.Text( + try Locator.Text( after: nil, before: nil, highlight: highlight - )[highlight.range(of: "ghl")!], + )[XCTUnwrap(highlight.range(of: "ghl"))], Locator.Text( after: "ight", before: "hi", @@ -422,11 +422,11 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - Locator.Text( + try Locator.Text( after: "after", before: nil, highlight: highlight - )[highlight.range(of: "hig")!], + )[XCTUnwrap(highlight.range(of: "hig"))], Locator.Text( after: "hlightafter", before: nil, @@ -435,11 +435,11 @@ class LocatorTextTests: XCTestCase { ) XCTAssertEqual( - Locator.Text( + try Locator.Text( after: nil, before: "before", highlight: highlight - )[highlight.range(of: "light")!], + )[XCTUnwrap(highlight.range(of: "light"))], Locator.Text( after: nil, before: "beforehigh", @@ -457,7 +457,7 @@ class LocatorCollectionTests: XCTestCase { ) } - func testParseFullJSON() { + func testParseFullJSON() throws { XCTAssertEqual( LocatorCollection(json: [ "metadata": [ @@ -505,7 +505,7 @@ class LocatorCollectionTests: XCTestCase { ], ], ] as [String: Any]), - LocatorCollection( + try LocatorCollection( metadata: LocatorCollection.Metadata( title: LocalizedString.localized([ "en": "Searching in Alice in Wonderlands - Page 1", @@ -517,8 +517,8 @@ class LocatorCollectionTests: XCTestCase { ] ), links: [ - Link(href: "/978-1503222687/search?query=apple", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "self"), - Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "next"), + Link(href: "/978-1503222687/search?query=apple", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "self"), + Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "next"), ], locators: [ Locator( @@ -576,8 +576,8 @@ class LocatorCollectionTests: XCTestCase { ) } - func testGetFullJSON() { - AssertJSONEqual( + func testGetFullJSON() throws { + try AssertJSONEqual( LocatorCollection( metadata: LocatorCollection.Metadata( title: LocalizedString.localized([ @@ -590,8 +590,8 @@ class LocatorCollectionTests: XCTestCase { ] ), links: [ - Link(href: "/978-1503222687/search?query=apple", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "self"), - Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: MediaType("application/vnd.readium.locators+json")!, rel: "next"), + Link(href: "/978-1503222687/search?query=apple", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "self"), + Link(href: "/978-1503222687/search?query=apple&page=2", mediaType: XCTUnwrap(MediaType("application/vnd.readium.locators+json")), rel: "next"), ], locators: [ Locator( diff --git a/Tests/SharedTests/Publication/ManifestTests.swift b/Tests/SharedTests/Publication/ManifestTests.swift index 04ff656567..044dc3c8b5 100644 --- a/Tests/SharedTests/Publication/ManifestTests.swift +++ b/Tests/SharedTests/Publication/ManifestTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/MetadataTests.swift b/Tests/SharedTests/Publication/MetadataTests.swift index 11636594e7..856506efb7 100644 --- a/Tests/SharedTests/Publication/MetadataTests.swift +++ b/Tests/SharedTests/Publication/MetadataTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/PropertiesTests.swift b/Tests/SharedTests/Publication/PropertiesTests.swift index c83d103246..2a9f00dc50 100644 --- a/Tests/SharedTests/Publication/PropertiesTests.swift +++ b/Tests/SharedTests/Publication/PropertiesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/PublicationCollectionTests.swift b/Tests/SharedTests/Publication/PublicationCollectionTests.swift index 1a8c534052..381207b070 100644 --- a/Tests/SharedTests/Publication/PublicationCollectionTests.swift +++ b/Tests/SharedTests/Publication/PublicationCollectionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index 8672465445..efbeef12ba 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -91,80 +91,80 @@ class PublicationTests: XCTestCase { ) } - func testLinkWithHREFInReadingOrder() { + func testLinkWithHREFInReadingOrder() throws { XCTAssertEqual( - makePublication(readingOrder: [ + try makePublication(readingOrder: [ Link(href: "l1"), Link(href: "l2"), - ]).linkWithHREF(AnyURL(string: "l2")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, "l2" ) } - func testLinkWithHREFInLinks() { + func testLinkWithHREFInLinks() throws { XCTAssertEqual( - makePublication(links: [ + try makePublication(links: [ Link(href: "l1"), Link(href: "l2"), - ]).linkWithHREF(AnyURL(string: "l2")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, "l2" ) } - func testLinkWithHREFInResources() { + func testLinkWithHREFInResources() throws { XCTAssertEqual( - makePublication(resources: [ + try makePublication(resources: [ Link(href: "l1"), Link(href: "l2"), - ]).linkWithHREF(AnyURL(string: "l2")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, "l2" ) } - func testLinkWithHREFInAlternate() { + func testLinkWithHREFInAlternate() throws { XCTAssertEqual( - makePublication(resources: [ + try makePublication(resources: [ Link(href: "l1", alternates: [ Link(href: "l2", alternates: [ Link(href: "l3"), ]), ]), - ]).linkWithHREF(AnyURL(string: "l3")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l3")))?.href, "l3" ) } - func testLinkWithHREFInChildren() { + func testLinkWithHREFInChildren() throws { XCTAssertEqual( - makePublication(resources: [ + try makePublication(resources: [ Link(href: "l1", children: [ Link(href: "l2", children: [ Link(href: "l3"), ]), ]), - ]).linkWithHREF(AnyURL(string: "l3")!)?.href, + ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l3")))?.href, "l3" ) } - func testLinkWithHREFIgnoresQuery() { + func testLinkWithHREFIgnoresQuery() throws { let publication = makePublication(links: [ Link(href: "l1?q=a"), Link(href: "l2"), ]) - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l1?q=a")!)?.href, "l1?q=a") - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l2?q=b")!)?.href, "l2") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l1?q=a")))?.href, "l1?q=a") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l2?q=b")))?.href, "l2") } - func testLinkWithHREFIgnoresAnchor() { + func testLinkWithHREFIgnoresAnchor() throws { let publication = makePublication(links: [ Link(href: "l1#a"), Link(href: "l2"), ]) - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l1#a")!)?.href, "l1#a") - XCTAssertEqual(publication.linkWithHREF(AnyURL(string: "l2#b")!)?.href, "l2") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l1#a")))?.href, "l1#a") + XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l2#b")))?.href, "l2") } func testLinkWithRelInReadingOrder() { @@ -241,7 +241,7 @@ class PublicationTests: XCTestCase { container: SingleResourceContainer(resource: DataResource(string: "hello"), at: link.url()) ) - let result = try await publication.get(link)?.readAsString().get() + let result = try await publication.get(link)?.read().asString().get() XCTAssertEqual(result, "hello") } diff --git a/Tests/SharedTests/Publication/ReadingProgressionTests.swift b/Tests/SharedTests/Publication/ReadingProgressionTests.swift index 0e9cd87b3e..ba0dfdac37 100644 --- a/Tests/SharedTests/Publication/ReadingProgressionTests.swift +++ b/Tests/SharedTests/Publication/ReadingProgressionTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift index ff2b0b10b9..ef5b48022a 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,17 +8,17 @@ import XCTest class ContentProtectionServiceTests: XCTestCase { - func testGetUnknown() { + func testGetUnknown() throws { let service = TestContentProtectionService() - let resource = service.get(AnyURL(string: "/unknown")!) + let resource = try service.get(XCTUnwrap(AnyURL(string: "/unknown"))) XCTAssertNil(resource) } /// The Publication helpers will use the `ContentProtectionService` if there's one. - func testPublicationHelpers() async { - let scheme = ContentProtectionScheme(rawValue: HTTPURL(string: "https://domain.com/drm")!) + func testPublicationHelpers() async throws { + let scheme = try ContentProtectionScheme(rawValue: XCTUnwrap(HTTPURL(string: "https://domain.com/drm"))) let publication = makePublication(service: { _ in TestContentProtectionService( scheme: scheme, @@ -87,10 +87,10 @@ class ContentProtectionServiceTests: XCTestCase { struct TestContentProtectionService: ContentProtectionService { var scheme: ContentProtectionScheme = .init(rawValue: HTTPURL(string: "https://domain.com/drm")!) var isRestricted: Bool = false - var error: Error? = nil - var credentials: String? = nil + var error: Error? + var credentials: String? var rights: UserRights = UnrestrictedUserRights() - var name: LocalizedString? = nil + var name: LocalizedString? func getCopy(text: String, peek: Bool) throws -> Resource { try XCTUnwrap(get(AnyURL(string: "~readium/rights/copy?text=\(text)&peek=\(peek)")!)) diff --git a/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift index a41929230d..c6b9cdea1d 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/UserRightsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index c8380d6a6a..acef594086 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -277,7 +277,7 @@ class HTMLResourceContentIteratorTest: XCTestCase { """ - let iter = iterator(nbspHtml, start: locator(selector: ":root > :nth-child(2) > :nth-child(2)")) + let iter = iterator(nbspHtml, start: locator(selector: ":root > :nth-child(1) > :nth-child(2)")) let expectedElement = TextContentElement( locator: locator( @@ -440,7 +440,7 @@ class HTMLResourceContentIteratorTest: XCTestCase { """ - let expectedElements: [AnyEquatableContentElement] = [ + let expectedElements: [AnyEquatableContentElement] = try [ VideoContentElement( locator: locator(progression: 0.0, selector: "html > body > video:nth-child(1)"), embeddedLink: Link(href: "dir/video.mp4"), @@ -450,8 +450,8 @@ class HTMLResourceContentIteratorTest: XCTestCase { locator: locator(progression: 0.5, selector: "html > body > video:nth-child(2)"), embeddedLink: Link( href: "dir/video.mp4", - mediaType: MediaType("video/mp4")!, - alternates: [Link(href: "dir/video.m4v", mediaType: MediaType("video/x-m4v")!)] + mediaType: XCTUnwrap(MediaType("video/mp4")), + alternates: [Link(href: "dir/video.m4v", mediaType: XCTUnwrap(MediaType("video/x-m4v")))] ), attributes: [] ).equatable(), diff --git a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift index 9aab141c26..f61bebe6c1 100644 --- a/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/CoverServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -14,54 +14,125 @@ class CoverServiceTests: XCTestCase { lazy var cover = UIImage(contentsOfFile: coverURL.path)! lazy var cover2 = UIImage(data: fixtures.data(at: "cover2.jpg"))! - /// `Publication.cover` will use the `CoverService` if there's one. - func testCoverHelperUsesCoverService() async { + /// `Publication.cover` will use a custom `CoverService` if provided. + func testCoverHelperUsesCustomCoverService() async { let publication = makePublication { _ in TestCoverService(cover: self.cover2) } let result = await publication.cover() AssertImageEqual(result, .success(cover2)) } - /// `Publication.cover` will try to fetch the cover from a manifest link with rel `cover`, if - /// no `CoverService` is provided. - func testCoverHelperFallsBackOnManifest() async { + /// `Publication.cover` uses `ResourceCoverService` by default. + func testCoverHelperUsesResourceCoverServiceByDefault() async { let publication = makePublication() let result = await publication.cover() AssertImageEqual(result, .success(cover)) } - /// `Publication.coverFitting` will use the `CoverService` if there's one. - func testCoverFittingHelperUsesCoverService() async { + /// `Publication.coverFitting` will use a custom `CoverService` if provided. + func testCoverFittingHelperUsesCustomCoverService() async { let size = CGSize(width: 100, height: 100) let publication = makePublication { _ in TestCoverService(cover: self.cover2) } let result = await publication.coverFitting(maxSize: size) AssertImageEqual(result, .success(cover2.scaleToFit(maxSize: size))) } - /// `Publication.coverFitting` will try to fetch the cover from a manifest link with rel `cover`, if - /// no `CoverService` is provided. - func testCoverFittingHelperFallsBackOnManifest() async { + /// `Publication.coverFitting` uses `ResourceCoverService` by default. + func testCoverFittingHelperUsesResourceCoverServiceByDefault() async { let size = CGSize(width: 100, height: 100) let publication = makePublication() let result = await publication.coverFitting(maxSize: size) AssertImageEqual(result, .success(cover.scaleToFit(maxSize: size))) } - private func makePublication(cover: CoverServiceFactory? = nil) -> Publication { - let coverPath = "cover.jpg" - return Publication( - manifest: Manifest( - metadata: Metadata( - title: "title" + /// `ResourceCoverService` uses the first bitmap reading order item when no explicit `.cover` + /// link is declared. + func testResourceCoverServiceUsesFirstBitmapReadingOrderItem() async { + let publication = makePublication( + readingOrder: [ + Link(href: "cover.jpg", mediaType: .jpeg), + Link(href: "page2.jpg", mediaType: .jpeg), + ], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover)) + } + + /// `ResourceCoverService` uses the first bitmap alternate of the first reading order item + /// when that item is not a bitmap. + func testResourceCoverServiceUsesFirstReadingOrderBitmapAlternate() async { + let publication = makePublication( + readingOrder: [ + Link( + href: "chapter1.xhtml", + mediaType: .xhtml, + alternates: [ + Link(href: "cover.jpg", mediaType: .jpeg), + ] ), + ], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover)) + } + + /// `ResourceCoverService` returns nil when no explicit `.cover` link is declared and no bitmap + /// is available. + func testResourceCoverServiceReturnsNilWhenNoBitmapAvailable() async { + let publication = makePublication( + readingOrder: [Link(href: "chapter1.xhtml", mediaType: .xhtml)], + resources: [] + ) + let result = await publication.cover() + AssertImageEqual(result, .success(nil)) + } + + /// `ResourceCoverService` prioritizes explicit `.cover` links over first reading order item. + func testResourceCoverServicePrioritizesExplicitCoverLink() async throws { + let publication = try Publication( + manifest: Manifest( + metadata: Metadata(title: "title"), readingOrder: [ - Link(href: "titlepage.xhtml", rels: [.cover]), + Link(href: "page1.jpg", mediaType: .jpeg), ], resources: [ - Link(href: coverPath, rels: [.cover]), + Link(href: "cover2.jpg", rels: [.cover]), ] ), - container: FileContainer(href: RelativeURL(path: coverPath)!, file: coverURL), - servicesBuilder: PublicationServicesBuilder(cover: cover) + container: CompositeContainer( + SingleResourceContainer( + resource: FileResource(file: fixtures.url(for: "cover.jpg")), + at: XCTUnwrap(AnyURL(string: "page1.jpg")) + ), + SingleResourceContainer( + resource: FileResource(file: fixtures.url(for: "cover2.jpg")), + at: XCTUnwrap(AnyURL(string: "cover2.jpg")) + ) + ) + ) + let result = await publication.cover() + AssertImageEqual(result, .success(cover2)) + } + + private func makePublication( + readingOrder: [Link] = [], + resources: [Link] = [Link(href: "cover.jpg", rels: [.cover])], + cover: CoverServiceFactory? = nil + ) -> Publication { + var builder = PublicationServicesBuilder() + if let cover { builder.setCoverServiceFactory(cover) } + return Publication( + manifest: Manifest( + metadata: Metadata(title: "title"), + readingOrder: readingOrder, + resources: resources + ), + container: SingleResourceContainer( + resource: FileResource(file: coverURL), + at: AnyURL(string: "cover.jpg")! + ), + servicesBuilder: builder ) } } diff --git a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift index 6b22bef465..53edec3049 100644 --- a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -29,7 +29,7 @@ class GeneratedCoverServiceTests: XCTestCase { GeneratedCoverService(cover: cover), GeneratedCoverService(makeCover: { .success(self.cover) }), ] { - let resource = try XCTUnwrap(service.get(AnyURL(string: "~readium/cover")!)) + let resource = try XCTUnwrap(try service.get(XCTUnwrap(AnyURL(string: "~readium/cover")))) let result = await resource.read().map(UIImage.init) AssertImageEqual(result, .success(cover)) } diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index 683738322c..f7a7a19883 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -8,7 +8,7 @@ import XCTest class DefaultLocatorServiceTests: XCTestCase { - // locate(Locator) checks that the href exists. + /// locate(Locator) checks that the href exists. func testFromLocator() async { let service = makeService(readingOrder: [ Link(href: "chap1", mediaType: .xml), @@ -164,12 +164,12 @@ class DefaultLocatorServiceTests: XCTestCase { ) } - func testFromLinkWithFragment() async { + func testFromLinkWithFragment() async throws { let service = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) - let result = await service.locate(Link(href: "/href#page=42", mediaType: MediaType("text/xml")!, title: "My link")) + let result = try await service.locate(Link(href: "/href#page=42", mediaType: XCTUnwrap(MediaType("text/xml")), title: "My link")) XCTAssertEqual( result, Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(fragments: ["page=42"])) diff --git a/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift index 64efb13866..e076662e3b 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PerResourcePositionsServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -72,17 +72,17 @@ class PerResourcePositionsServiceTests: XCTestCase { ])) } - func testFallsBackOnGivenMediaType() async { - let services = PerResourcePositionsService( + func testFallsBackOnGivenMediaType() async throws { + let services = try PerResourcePositionsService( readingOrder: [Link(href: "res")], - fallbackMediaType: MediaType("image/*")! + fallbackMediaType: XCTUnwrap(MediaType("image/*")) ) let result = await services.positionsByReadingOrder() - XCTAssertEqual(result, .success([[ + XCTAssertEqual(result, try .success([[ Locator( href: "res", - mediaType: MediaType("image/*")!, + mediaType: XCTUnwrap(MediaType("image/*")), locations: Locator.Locations( totalProgression: 0.0, position: 1 diff --git a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift index e6ee531f92..66e4c27525 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -123,9 +123,9 @@ class PositionsServiceTests: XCTestCase { func testGetPositions() async throws { let service = TestPositionsService(positions) - let resource = service.get(AnyURL(string: "~readium/positions")!) + let resource = try service.get(XCTUnwrap(AnyURL(string: "~readium/positions"))) - let result = try await resource?.readAsString().get() + let result = try await resource?.read().asString().get() XCTAssertEqual( result, """ @@ -134,10 +134,10 @@ class PositionsServiceTests: XCTestCase { ) } - func testGetUnknown() { + func testGetUnknown() throws { let service = TestPositionsService(positions) - let resource = service.get(AnyURL(string: "/unknown")!) + let resource = try service.get(XCTUnwrap(AnyURL(string: "/unknown"))) XCTAssertNil(resource) } diff --git a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift index bbf8c52726..845c9b6928 100644 --- a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift +++ b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -42,7 +42,7 @@ class PublicationServicesBuilderTests: XCTestCase { let services = builder.build(context: context) - XCTAssert(services.count == 3) + XCTAssert(services.count == 4) XCTAssert(services.contains { $0 is FooServiceA }) XCTAssert(services.contains { $0 is BarServiceA }) } @@ -50,8 +50,9 @@ class PublicationServicesBuilderTests: XCTestCase { func testBuildDefault() { let builder = PublicationServicesBuilder() let services = builder.build(context: context) - XCTAssertEqual(services.count, 1) + XCTAssertEqual(services.count, 2) XCTAssert(services.contains { $0 is DefaultLocatorService }) + XCTAssert(services.contains { $0 is ResourceCoverService }) } func testSetOverwrite() { diff --git a/Tests/SharedTests/Publication/SubjectTests.swift b/Tests/SharedTests/Publication/SubjectTests.swift index 9526c31d16..a9959d257c 100644 --- a/Tests/SharedTests/Publication/SubjectTests.swift +++ b/Tests/SharedTests/Publication/SubjectTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Publication/TDMTests.swift b/Tests/SharedTests/Publication/TDMTests.swift index 3679095f68..31421d5cda 100644 --- a/Tests/SharedTests/Publication/TDMTests.swift +++ b/Tests/SharedTests/Publication/TDMTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift b/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift index 9254b002d0..5776c420fc 100644 --- a/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Asset/AssetRetrieverTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Toolkit/Data/ReadResultTests.swift b/Tests/SharedTests/Toolkit/Data/ReadResultTests.swift new file mode 100644 index 0000000000..354afdacec --- /dev/null +++ b/Tests/SharedTests/Toolkit/Data/ReadResultTests.swift @@ -0,0 +1,154 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumShared +import Testing + +@Suite enum ReadResultDataTests { + static let accessError: ReadError = .access(.fileSystem(.fileNotFound(nil))) + + @Suite("decode") struct Decode { + @Test("success") func success() { + let result: ReadResult = .success(Data([0x41, 0x42])) + let decoded: ReadResult = result.decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == .success("AB")) + } + + @Test("decoding failure wraps in ReadError.decoding") + func decodingFailure() { + let result: ReadResult = .success(Data([0xFF])) + let decoded: ReadResult = result.decode { _ in throw DebugError("bad") } + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding, got \(decoded)") + return + } + } + + @Test("read error is preserved unchanged") + func readErrorPreserved() { + let decoded: ReadResult = accessError.asResult().decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == accessError.asResult()) + } + } + + @Suite("asString") struct AsString { + @Test("UTF-8 success") func utf8() throws { + let result: ReadResult = try .success(#require("hello".data(using: .utf8))) + #expect(result.asString() == .success("hello")) + } + + @Test("custom encoding") func customEncoding() throws { + let result: ReadResult = try .success(#require("cafΓ©".data(using: .isoLatin1))) + #expect(result.asString(encoding: .isoLatin1) == .success("cafΓ©")) + } + + @Test("invalid encoding produces ReadError.decoding") + func invalidEncoding() { + // 0x80 alone is invalid UTF-8 + let result: ReadResult = .success(Data([0x80])) + guard case .failure(.decoding) = result.asString() else { + Issue.record("Expected ReadError.decoding for invalid UTF-8") + return + } + } + } + + @Suite("asJSONObject") struct AsJSONObject { + @Test("valid JSON object") func valid() throws { + let result: ReadResult = try .success(#require(#"{"key":"value"}"#.data(using: .utf8))) + let decoded: ReadResult<[String: Any]> = result.asJSONObject() + #expect(try decoded.get()["key"] as? String == "value") + } + + @Test("invalid JSON produces ReadError.decoding") + func invalidJSON() throws { + let result: ReadResult = try .success(#require("not json".data(using: .utf8))) + let decoded: ReadResult<[String: Any]> = result.asJSONObject() + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding for invalid JSON") + return + } + } + + @Test("JSON array root produces ReadError.decoding") + func wrongType() throws { + let result: ReadResult = try .success(#require("[1,2,3]".data(using: .utf8))) + let decoded: ReadResult<[String: Any]> = result.asJSONObject() + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding when JSON root is not an object") + return + } + } + } +} + +@Suite enum ReadResultOptionalDataTests { + static let accessError: ReadError = .access(.fileSystem(.fileNotFound(nil))) + + @Suite("decode") struct Decode { + @Test("nil data passes through as success(nil)") + func nilPassthrough() { + let result: ReadResult = .success(nil) + let decoded: ReadResult = result.decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == .success(nil)) + } + + @Test("present data is decoded") func dataPresent() { + let result: ReadResult = .success("hello".data(using: .utf8)) + let decoded: ReadResult = result.decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == .success("hello")) + } + + @Test("decoding failure wraps in ReadError.decoding") + func decodingFailure() { + let result: ReadResult = .success(Data([0xFF])) + let decoded: ReadResult = result.decode { _ in throw DebugError("bad") } + guard case .failure(.decoding) = decoded else { + Issue.record("Expected ReadError.decoding, got \(decoded)") + return + } + } + + @Test("read error is preserved unchanged") + func readErrorPreserved() { + let decoded: ReadResult = accessError.asResult().decode { String(data: $0, encoding: .utf8)! } + #expect(decoded == accessError.asResult()) + } + } + + @Suite("asString") struct AsString { + @Test("nil passthrough") func nilPassthrough() { + let result: ReadResult = .success(nil) + #expect(result.asString() == .success(nil)) + } + + @Test("present data is decoded") func dataPresent() { + let result: ReadResult = .success("world".data(using: .utf8)) + #expect(result.asString() == .success("world")) + } + } + + @Suite("asJSONObject") struct AsJSONObject { + @Test("nil passthrough") func nilPassthrough() throws { + let result: ReadResult = .success(nil) + let decoded: ReadResult<[String: Any]?> = result.asJSONObject() + #expect(try decoded.get() == nil) + } + + @Test("present data is decoded") func dataPresent() throws { + let result: ReadResult = .success(#"{"k":1}"#.data(using: .utf8)) + let decoded: ReadResult<[String: Any]?> = result.asJSONObject() + #expect(try decoded.get()?["k"] as? Int == 1) + } + } +} + +private extension ReadError { + func asResult() -> ReadResult { + .failure(self) + } +} diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index 6bd82269fe..b0bdd9a759 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -1,10 +1,11 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @testable import ReadiumShared +import TestPublications import XCTest class BufferingResourceTests: XCTestCase { @@ -99,7 +100,7 @@ class BufferingResourceTests: XCTestCase { } } - private let file = Fixtures(path: "Fetcher").url(for: "epub.epub") + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file) diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index 8dbe5b7a4e..515d4dab5e 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -1,10 +1,11 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @testable import ReadiumShared +import TestPublications import XCTest class TailCachingResourceTests: XCTestCase { @@ -50,7 +51,7 @@ class TailCachingResourceTests: XCTestCase { } } - private let file = Fixtures(path: "Fetcher").url(for: "epub.epub") + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file) diff --git a/Tests/SharedTests/Toolkit/DocumentTypesTests.swift b/Tests/SharedTests/Toolkit/DocumentTypesTests.swift index 4b615abcf7..895cc4c86d 100644 --- a/Tests/SharedTests/Toolkit/DocumentTypesTests.swift +++ b/Tests/SharedTests/Toolkit/DocumentTypesTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -19,31 +19,31 @@ class DocumentTypesTests: XCTestCase { let all = sut.all XCTAssertEqual(all.count, 3) - XCTAssertEqual(all[0], DocumentType( + XCTAssertEqual(all[0], try DocumentType( name: "Foo Format", utis: [], - preferredMediaType: MediaType("application/vnd.bar")!, + preferredMediaType: XCTUnwrap(MediaType("application/vnd.bar")), mediaTypes: [ - MediaType("application/vnd.bar")!, - MediaType("application/vnd.bar2")!, + XCTUnwrap(MediaType("application/vnd.bar")), + XCTUnwrap(MediaType("application/vnd.bar2")), ], fileExtensions: ["foo", "foo2"] )) - XCTAssertEqual(all[1], DocumentType( + XCTAssertEqual(all[1], try DocumentType( name: "PDF Publication", utis: [], - preferredMediaType: MediaType("application/pdf")!, + preferredMediaType: XCTUnwrap(MediaType("application/pdf")), mediaTypes: [ - MediaType("application/pdf")!, + XCTUnwrap(MediaType("application/pdf")), ], fileExtensions: ["pdff"] )) - XCTAssertEqual(all[2], DocumentType( + XCTAssertEqual(all[2], try DocumentType( name: "EPUB Publication", utis: ["org.idpf.epub-container"], - preferredMediaType: MediaType("application/epub+zip")!, + preferredMediaType: XCTUnwrap(MediaType("application/epub+zip")), mediaTypes: [ - MediaType("application/epub+zip")!, + XCTUnwrap(MediaType("application/epub+zip")), ], fileExtensions: ["epub", "epub2"] )) @@ -53,12 +53,12 @@ class DocumentTypesTests: XCTestCase { XCTAssertEqual(sut.supportedUTIs, ["org.idpf.epub-container"]) } - func testSupportedMediaTypes() { - XCTAssertEqual(sut.supportedMediaTypes, [ - MediaType("application/epub+zip")!, - MediaType("application/vnd.bar")!, - MediaType("application/vnd.bar2")!, - MediaType("application/pdf")!, + func testSupportedMediaTypes() throws { + XCTAssertEqual(sut.supportedMediaTypes, try [ + XCTUnwrap(MediaType("application/epub+zip")), + XCTUnwrap(MediaType("application/vnd.bar")), + XCTUnwrap(MediaType("application/vnd.bar2")), + XCTUnwrap(MediaType("application/pdf")), ]) } diff --git a/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift b/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift index e129dfca50..0892c6d1f2 100644 --- a/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift +++ b/Tests/SharedTests/Toolkit/Extensions/UIImageTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift b/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift index ac262b9b86..d0555604a8 100644 --- a/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift +++ b/Tests/SharedTests/Toolkit/File/DirectoryContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -31,25 +31,25 @@ class DirectoryContainerTests: XCTestCase { func testGetNonExistingEntry() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - XCTAssertNil(container[AnyURL(path: "unknown")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "unknown"))]) } func testEntries() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - XCTAssertEqual(container.entries, Set([ - AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!, - AnyURL(path: "A folder/Sub.folder%/file.txt")!, - AnyURL(path: "A folder/wasteland-cover.jpg")!, - AnyURL(path: "root.txt")!, - AnyURL(path: "uncompressed.jpg")!, - AnyURL(path: "uncompressed.txt")!, + XCTAssertEqual(container.entries, try Set([ + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt")), + XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg")), + XCTUnwrap(AnyURL(path: "root.txt")), + XCTUnwrap(AnyURL(path: "uncompressed.jpg")), + XCTUnwrap(AnyURL(path: "uncompressed.txt")), ])) } func testHiddenEntries() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded"), options: []) - XCTAssertTrue(container.entries.contains(AnyURL(path: ".hidden")!)) + XCTAssertTrue(try container.entries.contains(XCTUnwrap(AnyURL(path: ".hidden")))) } func testResources() async throws { @@ -65,12 +65,12 @@ class DirectoryContainerTests: XCTestCase { func testCantGetEntryOutsideRoot() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - XCTAssertNil(container[AnyURL(path: "../test.zip")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "../test.zip"))]) } func testReadFullEntry() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read().get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -80,7 +80,7 @@ class DirectoryContainerTests: XCTestCase { func testReadRange() async throws { let container = try await DirectoryContainer(directory: fixtures.url(for: "exploded")) - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), diff --git a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift index 62e8b75807..a83c6b45d4 100644 --- a/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift +++ b/Tests/SharedTests/Toolkit/Format/FormatSniffersTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,9 +11,9 @@ class FormatSniffersTests: XCTestCase { let fixtures = Fixtures(path: "Format") let sut = DefaultFormatSniffer() - func testSniffHintsUnknown() { + func testSniffHintsUnknown() throws { XCTAssertNil(sut.sniffHints(fileExtension: "unknown")) - XCTAssertNil(sut.sniffHints(mediaType: MediaType("application/unknown+zip")!)) + XCTAssertNil(try sut.sniffHints(mediaType: XCTUnwrap(MediaType("application/unknown+zip")))) } func testSniffHintsIgnoresExtensionCase() { @@ -157,6 +157,11 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(sut.sniffHints(fileExtension: "jfi"), jpeg) XCTAssertEqual(sut.sniffHints(mediaType: "image/jpeg"), jpeg) + // JXL + let jxl = Format(specifications: .jxl, mediaType: .jxl, fileExtension: "jxl") + XCTAssertEqual(sut.sniffHints(fileExtension: "jxl"), jxl) + XCTAssertEqual(sut.sniffHints(mediaType: "image/jxl"), jxl) + // PNG let png = Format(specifications: .png, mediaType: .png, fileExtension: "png") XCTAssertEqual(sut.sniffHints(fileExtension: "png"), png) @@ -175,7 +180,7 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(sut.sniffHints(mediaType: "image/webp"), webp) } - func testSniffCBR() async { + func testSniffCBR() { let cbr = Format(specifications: .rar, .informalComic, mediaType: .cbr, fileExtension: "cbr") XCTAssertEqual(sut.sniffHints(mediaType: "application/vnd.comicbook-rar"), cbr) XCTAssertEqual(sut.sniffHints(mediaType: "application/x-cbr"), cbr) @@ -369,12 +374,12 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(result, .success(.rwpmAudiobook)) } - func testSniffRAR() async { + func testSniffRAR() throws { let rar = Format(specifications: .rar, mediaType: .rar, fileExtension: "rar") XCTAssertEqual(sut.sniffHints(mediaType: .rar), rar) - XCTAssertEqual(sut.sniffHints(mediaType: MediaType("application/x-rar")!), rar) - XCTAssertEqual(sut.sniffHints(mediaType: MediaType("application/x-rar-compressed")!), rar) + XCTAssertEqual(try sut.sniffHints(mediaType: XCTUnwrap(MediaType("application/x-rar"))), rar) + XCTAssertEqual(try sut.sniffHints(mediaType: XCTUnwrap(MediaType("application/x-rar-compressed"))), rar) XCTAssertEqual(sut.sniffHints(fileExtension: "rar"), rar) } @@ -387,7 +392,7 @@ class FormatSniffersTests: XCTestCase { XCTAssertEqual(result, .success(.xhtml)) } - func testSniffXML() async { + func testSniffXML() { XCTAssertEqual(sut.sniffHints(mediaType: .xml), .xml) XCTAssertEqual(sut.sniffHints(fileExtension: "xml"), .xml) } diff --git a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift index 2cbfdde547..b542fff76e 100644 --- a/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift +++ b/Tests/SharedTests/Toolkit/Format/MediaTypeTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -15,46 +15,46 @@ class MediaTypeTests: XCTestCase { func testGetString() { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!.string, + MediaType("application/atom+xml;profile=opds-catalog")?.string, "application/atom+xml;profile=opds-catalog" ) } func testGetStringNormalizes() { XCTAssertEqual( - MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG ; a=0")!.string, + MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG ; a=0")?.string, "application/atom+xml;a=0;profile=OPDS-CATALOG" ) // Parameters are sorted by name XCTAssertEqual( - MediaType("application/atom+xml;a=0;b=1")!.string, + MediaType("application/atom+xml;a=0;b=1")?.string, "application/atom+xml;a=0;b=1" ) XCTAssertEqual( - MediaType("application/atom+xml;b=1;a=0")!.string, + MediaType("application/atom+xml;b=1;a=0")?.string, "application/atom+xml;a=0;b=1" ) } func testGetType() { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!.type, + MediaType("application/atom+xml;profile=opds-catalog")?.type, "application" ) - XCTAssertEqual(MediaType("*/jpeg")!.type, "*") + XCTAssertEqual(MediaType("*/jpeg")?.type, "*") } func testGetSubtype() { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!.subtype, + MediaType("application/atom+xml;profile=opds-catalog")?.subtype, "atom+xml" ) - XCTAssertEqual(MediaType("image/*")!.subtype, "*") + XCTAssertEqual(MediaType("image/*")?.subtype, "*") } func testGetParameters() { XCTAssertEqual( - MediaType("application/atom+xml;type=entry;profile=opds-catalog")!.parameters, + MediaType("application/atom+xml;type=entry;profile=opds-catalog")?.parameters, [ "type": "entry", "profile": "opds-catalog", @@ -62,13 +62,13 @@ class MediaTypeTests: XCTestCase { ) } - func testGetEmptyParameters() { - XCTAssertTrue(MediaType("application/atom+xml")!.parameters.isEmpty) + func testGetEmptyParameters() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("application/atom+xml")?.parameters.isEmpty)) } func testGetParametersWithWhitespaces() { XCTAssertEqual( - MediaType("application/atom+xml ; type=entry ; profile=opds-catalog ")!.parameters, + MediaType("application/atom+xml ; type=entry ; profile=opds-catalog ")?.parameters, [ "type": "entry", "profile": "opds-catalog", @@ -77,144 +77,144 @@ class MediaTypeTests: XCTestCase { } func testGetStructuredSyntaxSuffix() { - XCTAssertNil(MediaType("foo/bar")!.structuredSyntaxSuffix) - XCTAssertNil(MediaType("application/zip")!.structuredSyntaxSuffix) - XCTAssertEqual(MediaType("application/epub+zip")!.structuredSyntaxSuffix, "+zip") - XCTAssertEqual(MediaType("foo/bar+json+zip")!.structuredSyntaxSuffix, "+zip") + XCTAssertNil(MediaType("foo/bar")?.structuredSyntaxSuffix) + XCTAssertNil(MediaType("application/zip")?.structuredSyntaxSuffix) + XCTAssertEqual(MediaType("application/epub+zip")?.structuredSyntaxSuffix, "+zip") + XCTAssertEqual(MediaType("foo/bar+json+zip")?.structuredSyntaxSuffix, "+zip") } func testGetEncoding() { - XCTAssertNil(MediaType("text/html")!.encoding) - XCTAssertEqual(MediaType("text/html;charset=utf-8")!.encoding, .utf8) + XCTAssertNil(MediaType("text/html")?.encoding) + XCTAssertEqual(MediaType("text/html;charset=utf-8")?.encoding, .utf8) } - func testTypeSubtypeAndParameterNamesAreLowercased() { - let mediaType = MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")! + func testTypeSubtypeAndParameterNamesAreLowercased() throws { + let mediaType = try XCTUnwrap(MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")) XCTAssertEqual(mediaType.type, "application") XCTAssertEqual(mediaType.subtype, "atom+xml") XCTAssertEqual(mediaType.parameters, ["profile": "OPDS-CATALOG"]) } func testCharsetValueIsUppercased() { - XCTAssertEqual(MediaType("text/html;charset=utf-8")!.parameters["charset"], "UTF-8") + XCTAssertEqual(MediaType("text/html;charset=utf-8")?.parameters["charset"], "UTF-8") } - func testEquals() { - XCTAssertEqual(MediaType("application/atom+xml")!, MediaType("application/atom+xml")!) - XCTAssertEqual(MediaType("application/atom+xml;profile=opds-catalog")!, MediaType("application/atom+xml;profile=opds-catalog")!) - XCTAssertNotEqual(MediaType("application/atom+xml")!, MediaType("application/atom")!) - XCTAssertNotEqual(MediaType("application/atom+xml")!, MediaType("text/atom+xml")!) - XCTAssertNotEqual(MediaType("application/atom+xml;profile=opds-catalog")!, MediaType("application/atom+xml")!) + func testEquals() throws { + XCTAssertEqual(MediaType("application/atom+xml"), MediaType("application/atom+xml")) + XCTAssertEqual(MediaType("application/atom+xml;profile=opds-catalog"), MediaType("application/atom+xml;profile=opds-catalog")) + XCTAssertNotEqual(try XCTUnwrap(MediaType("application/atom+xml")), try XCTUnwrap(MediaType("application/atom"))) + XCTAssertNotEqual(try XCTUnwrap(MediaType("application/atom+xml")), try XCTUnwrap(MediaType("text/atom+xml"))) + XCTAssertNotEqual(try XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog")), try XCTUnwrap(MediaType("application/atom+xml"))) } - func testEqualsIgnoresCaseOfTypeSubtypeAndParameterNames() { + func testEqualsIgnoresCaseOfTypeSubtypeAndParameterNames() throws { XCTAssertEqual( - MediaType("application/atom+xml;profile=opds-catalog")!, - MediaType("APPLICATION/ATOM+XML;PROFILE=opds-catalog")! + MediaType("application/atom+xml;profile=opds-catalog"), + MediaType("APPLICATION/ATOM+XML;PROFILE=opds-catalog") ) XCTAssertNotEqual( - MediaType("application/atom+xml;profile=opds-catalog")!, - MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")! + try XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog")), + try XCTUnwrap(MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")) ) } func testEqualsIgnoresParametersOrder() { XCTAssertEqual( - MediaType("application/atom+xml;type=entry;profile=opds-catalog")!, - MediaType("application/atom+xml;profile=opds-catalog;type=entry")! + MediaType("application/atom+xml;type=entry;profile=opds-catalog"), + MediaType("application/atom+xml;profile=opds-catalog;type=entry") ) } func testEqualsIgnoresCharsetCase() { XCTAssertEqual( - MediaType("application/atom+xml;charset=utf-8")!, + MediaType("application/atom+xml;charset=utf-8"), MediaType("application/atom+xml;charset=UTF-8") ) } - func testContainsEqualMediaType() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .contains(MediaType("text/html;charset=utf-8")!)) + func testContainsEqualMediaType() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testContainsMustMatchParameters() { - XCTAssertFalse(MediaType("text/html;charset=utf-8")! - .contains(MediaType("text/html;charset=ascii")!)) - XCTAssertFalse(MediaType("text/html;charset=utf-8")! - .contains(MediaType("text/html")!)) + func testContainsMustMatchParameters() throws { + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .contains(XCTUnwrap(MediaType("text/html;charset=ascii"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .contains(XCTUnwrap(MediaType("text/html"))))) } - func testContainsIgnoresParametersOrder() { - XCTAssertTrue(MediaType("text/html;charset=utf-8;type=entry")! - .contains(MediaType("text/html;type=entry;charset=utf-8")!)) + func testContainsIgnoresParametersOrder() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8;type=entry")? + .contains(XCTUnwrap(MediaType("text/html;type=entry;charset=utf-8"))))) } - func testContainsIgnoresExtraParameters() { - XCTAssertTrue(MediaType("text/html")! - .contains(MediaType("text/html;charset=utf-8")!)) + func testContainsIgnoresExtraParameters() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testContainsSupportsWildcards() { - XCTAssertTrue(MediaType("*/*")! - .contains(MediaType("text/html;charset=utf-8")!)) - XCTAssertTrue(MediaType("text/*")! - .contains(MediaType("text/html;charset=utf-8")!)) - XCTAssertFalse(MediaType("text/*")! - .contains(MediaType("application/zip")!)) + func testContainsSupportsWildcards() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("*/*")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/*")? + .contains(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/*")? + .contains(XCTUnwrap(MediaType("application/zip"))))) } - func testContainsFromString() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .contains("text/html;charset=utf-8")) + func testContainsFromString() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")? + .contains("text/html;charset=utf-8"))) } - func testMatchesEqualMediaType() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .matches(MediaType("text/html;charset=utf-8")!)) + func testMatchesEqualMediaType() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testMatchesMustMatchParameters() { - XCTAssertFalse(MediaType("text/html;charset=ascii")! - .matches(MediaType("text/html;charset=utf-8")!)) + func testMatchesMustMatchParameters() throws { + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html;charset=ascii")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testMatchesIgnoresParametersOrder() { - XCTAssertTrue(MediaType("text/html;charset=utf-8;type=entry")! - .matches(MediaType("text/html;type=entry;charset=utf-8")!)) + func testMatchesIgnoresParametersOrder() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8;type=entry")? + .matches(XCTUnwrap(MediaType("text/html;type=entry;charset=utf-8"))))) } - func testMatchesIgnoresExtraParameters() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! - .matches(MediaType("text/html;charset=utf-8;extra=param")!)) - XCTAssertTrue(MediaType("text/html;charset=utf-8;extra=param")! - .matches(MediaType("text/html;charset=utf-8")!)) + func testMatchesIgnoresExtraParameters() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8;extra=param"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8;extra=param")? + .matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) } - func testMatchesSupportsWildcards() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.matches(MediaType("*/*")!)) - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.matches(MediaType("text/*")!)) - XCTAssertFalse(MediaType("application/zip")!.matches(MediaType("text/*")!)) - XCTAssertTrue(MediaType("*/*")!.matches(MediaType("text/html;charset=utf-8")!)) - XCTAssertTrue(MediaType("text/*")!.matches(MediaType("text/html;charset=utf-8")!)) - XCTAssertFalse(MediaType("text/*")!.matches(MediaType("application/zip")!)) + func testMatchesSupportsWildcards() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")?.matches(XCTUnwrap(MediaType("*/*"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html;charset=utf-8")?.matches(XCTUnwrap(MediaType("text/*"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("application/zip")?.matches(XCTUnwrap(MediaType("text/*"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("*/*")?.matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertTrue(try XCTUnwrap(try MediaType("text/*")?.matches(XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/*")?.matches(XCTUnwrap(MediaType("application/zip"))))) } - func testMatchesFromString() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.matches("text/html;charset=utf-8")) + func testMatchesFromString() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")?.matches("text/html;charset=utf-8"))) } - func testMatchesAnyMediaTypes() { - XCTAssertTrue(MediaType("text/html")! - .matchesAny(MediaType("application/zip")!, MediaType("text/html;charset=utf-8")!)) - XCTAssertFalse(MediaType("text/html")! - .matchesAny(MediaType("application/zip")!, MediaType("text/plain;charset=utf-8")!)) - XCTAssertTrue(MediaType("text/html")! - .matchesAny("application/zip", "text/html;charset=utf-8")) - XCTAssertFalse(MediaType("text/html")! - .matchesAny("application/zip", "text/plain;charset=utf-8")) + func testMatchesAnyMediaTypes() throws { + XCTAssertTrue(try XCTUnwrap(try MediaType("text/html")? + .matchesAny(XCTUnwrap(MediaType("application/zip")), XCTUnwrap(MediaType("text/html;charset=utf-8"))))) + XCTAssertFalse(try XCTUnwrap(try MediaType("text/html")? + .matchesAny(XCTUnwrap(MediaType("application/zip")), XCTUnwrap(MediaType("text/plain;charset=utf-8"))))) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html")? + .matchesAny("application/zip", "text/html;charset=utf-8"))) + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")? + .matchesAny("application/zip", "text/plain;charset=utf-8"))) } - func testPatternMatch() { + func testPatternMatch() throws { let mediaType: MediaType? = .json XCTAssertTrue(.json ~= mediaType) XCTAssertTrue(.json ~= MediaType("application/json")!) @@ -222,107 +222,108 @@ class MediaTypeTests: XCTestCase { XCTAssertFalse(.json ~= MediaType("application/opds+json")!) XCTAssertFalse(MediaType.json ~= nil) XCTAssertTrue(mediaType ~= .json) - XCTAssertTrue(MediaType("application/json")! ~= .json) - XCTAssertTrue(MediaType("application/json;charset=utf-8")! ~= .json) - XCTAssertFalse(MediaType("application/opds+json")! ~= .json) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json")) ~= .json) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json;charset=utf-8")) ~= .json) + XCTAssertFalse(try XCTUnwrap(MediaType("application/opds+json") ~= .json)) } - func testPatternMatchEqualMediaType() { - XCTAssertTrue(MediaType("text/html;charset=utf-8")! + func testPatternMatchEqualMediaType() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html;charset=utf-8")!) } - func testPatternMatchNil() { - XCTAssertFalse(MediaType("text/html;charset=utf-8")! ~= nil) + func testPatternMatchNil() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= nil) } - func testPatternMatchMustMatchParameters() { - XCTAssertFalse(MediaType("text/html;charset=utf-8")! + func testPatternMatchMustMatchParameters() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html;charset=ascii")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("text/html;charset=utf-8")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html;charset=utf-8")!) } - func testPatternMatchIgnoresParametersOrder() { - XCTAssertTrue(MediaType("text/html;charset=utf-8;type=entry")! + func testPatternMatchIgnoresParametersOrder() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8;type=entry")) ~= MediaType("text/html;type=entry;charset=utf-8")!) } - func testPatternMatchIgnoresExtraParameters() { - XCTAssertTrue(MediaType("text/html")! ~= MediaType("text/html;charset=utf-8")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("text/html")!) + func testPatternMatchIgnoresExtraParameters() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("text/html")) ~= MediaType("text/html;charset=utf-8")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/html")!) } - func testPatternMatchSupportsWildcards() { - XCTAssertTrue(MediaType("*/*")! ~= MediaType("text/html;charset=utf-8")!) - XCTAssertTrue(MediaType("text/*")! ~= MediaType("text/html;charset=utf-8")!) - XCTAssertFalse(MediaType("text/*")! ~= MediaType("application/zip")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("*/*")!) - XCTAssertTrue(MediaType("text/html;charset=utf-8")! ~= MediaType("text/*")!) - XCTAssertFalse(MediaType("application/zip")! ~= MediaType("text/*")!) + func testPatternMatchSupportsWildcards() throws { + XCTAssertTrue(try XCTUnwrap(MediaType("*/*")) ~= MediaType("text/html;charset=utf-8")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/*")) ~= MediaType("text/html;charset=utf-8")!) + XCTAssertFalse(try XCTUnwrap(MediaType("text/*")) ~= MediaType("application/zip")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("*/*")!) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")) ~= MediaType("text/*")!) + XCTAssertFalse(try XCTUnwrap(MediaType("application/zip")) ~= MediaType("text/*")!) } - func testIsZIP() { - XCTAssertFalse(MediaType("text/plain")!.isZIP) - XCTAssertTrue(MediaType("application/zip")!.isZIP) - XCTAssertTrue(MediaType("application/zip;charset=utf-8")!.isZIP) - XCTAssertTrue(MediaType("application/epub+zip")!.isZIP) + func testIsZIP() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/plain")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/zip")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/zip;charset=utf-8")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/epub+zip")?.isZIP)) // These media types must be explicitely matched since they don't have any ZIP hint - XCTAssertTrue(MediaType("application/audiobook+lcp")!.isZIP) - XCTAssertTrue(MediaType("application/pdf+lcp")!.isZIP) + XCTAssertTrue(try XCTUnwrap(MediaType("application/audiobook+lcp")?.isZIP)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/pdf+lcp")?.isZIP)) } - func testIsJSON() { - XCTAssertFalse(MediaType("text/plain")!.isJSON) - XCTAssertTrue(MediaType("application/json")!.isJSON) - XCTAssertTrue(MediaType("application/json;charset=utf-8")!.isJSON) - XCTAssertTrue(MediaType("application/opds+json")!.isJSON) + func testIsJSON() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/plain")?.isJSON)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json")?.isJSON)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/json;charset=utf-8")?.isJSON)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds+json")?.isJSON)) } - func testIsOPDS() { - XCTAssertFalse(MediaType("text/html")!.isOPDS) - XCTAssertTrue(MediaType("application/atom+xml;profile=opds-catalog")!.isOPDS) - XCTAssertTrue(MediaType("application/atom+xml;type=entry;profile=opds-catalog")!.isOPDS) - XCTAssertTrue(MediaType("application/opds+json")!.isOPDS) - XCTAssertTrue(MediaType("application/opds-publication+json")!.isOPDS) - XCTAssertTrue(MediaType("application/opds+json;charset=utf-8")!.isOPDS) - XCTAssertTrue(MediaType("application/opds-authentication+json")!.isOPDS) + func testIsOPDS() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/atom+xml;profile=opds-catalog")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/atom+xml;type=entry;profile=opds-catalog")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds+json")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds-publication+json")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds+json;charset=utf-8")?.isOPDS)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/opds-authentication+json")?.isOPDS)) } - func testIsHTML() { - XCTAssertFalse(MediaType("application/opds+json")!.isHTML) - XCTAssertTrue(MediaType("text/html")!.isHTML) - XCTAssertTrue(MediaType("application/xhtml+xml")!.isHTML) - XCTAssertTrue(MediaType("text/html;charset=utf-8")!.isHTML) + func testIsHTML() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("application/opds+json")?.isHTML)) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html")?.isHTML)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/xhtml+xml")?.isHTML)) + XCTAssertTrue(try XCTUnwrap(MediaType("text/html;charset=utf-8")?.isHTML)) } - func testIsBitmap() { - XCTAssertFalse(MediaType("text/html")!.isBitmap) - XCTAssertTrue(MediaType("image/bmp")!.isBitmap) - XCTAssertTrue(MediaType("image/gif")!.isBitmap) - XCTAssertTrue(MediaType("image/jpeg")!.isBitmap) - XCTAssertTrue(MediaType("image/png")!.isBitmap) - XCTAssertTrue(MediaType("image/tiff")!.isBitmap) - XCTAssertTrue(MediaType("image/tiff")!.isBitmap) - XCTAssertTrue(MediaType("image/tiff;charset=utf-8")!.isBitmap) + func testIsBitmap() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/bmp")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/gif")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/jpeg")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/jxl")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/png")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/tiff")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/webp")?.isBitmap)) + XCTAssertTrue(try XCTUnwrap(MediaType("image/tiff;charset=utf-8")?.isBitmap)) } - func testIsAudio() { - XCTAssertFalse(MediaType("text/html")!.isAudio) - XCTAssertTrue(MediaType("audio/unknown")!.isAudio) - XCTAssertTrue(MediaType("audio/mpeg;param=value")!.isAudio) + func testIsAudio() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isAudio)) + XCTAssertTrue(try XCTUnwrap(MediaType("audio/unknown")?.isAudio)) + XCTAssertTrue(try XCTUnwrap(MediaType("audio/mpeg;param=value")?.isAudio)) } - func testIsVideo() { - XCTAssertFalse(MediaType("text/html")!.isVideo) - XCTAssertTrue(MediaType("video/unknown")!.isVideo) - XCTAssertTrue(MediaType("video/mpeg;param=value")!.isVideo) + func testIsVideo() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isVideo)) + XCTAssertTrue(try XCTUnwrap(MediaType("video/unknown")?.isVideo)) + XCTAssertTrue(try XCTUnwrap(MediaType("video/mpeg;param=value")?.isVideo)) } - func testIsRWPM() { - XCTAssertFalse(MediaType("text/html")!.isRWPM) - XCTAssertTrue(MediaType("application/audiobook+json")!.isRWPM) - XCTAssertTrue(MediaType("application/divina+json")!.isRWPM) - XCTAssertTrue(MediaType("application/webpub+json")!.isRWPM) - XCTAssertTrue(MediaType("application/webpub+json;charset=utf-8")!.isRWPM) + func testIsRWPM() throws { + XCTAssertFalse(try XCTUnwrap(MediaType("text/html")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/audiobook+json")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/divina+json")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/webpub+json")?.isRWPM)) + XCTAssertTrue(try XCTUnwrap(MediaType("application/webpub+json;charset=utf-8")?.isRWPM)) } } diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift index aaf9222cc2..a2a639a3b9 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Toolkit/JSONValueTests.swift b/Tests/SharedTests/Toolkit/JSONValueTests.swift new file mode 100644 index 0000000000..943f47688e --- /dev/null +++ b/Tests/SharedTests/Toolkit/JSONValueTests.swift @@ -0,0 +1,238 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +@testable import ReadiumShared +import Testing + +@Suite struct JSONValueTests { + @Suite struct Initialization { + @Test func fromNil() { + #expect(JSONValue(nil as Any?) == nil) + } + + @Test func fromBool() { + #expect(JSONValue(true) == .bool(true)) + #expect(JSONValue(false) == .bool(false)) + } + + @Test func fromString() { + #expect(JSONValue("hello") == .string("hello")) + } + + @Test func fromInt() { + #expect(JSONValue(42) == .integer(42)) + #expect(JSONValue(-42) == .integer(-42)) + } + + @Test func fromUInt64() { + #expect(JSONValue(UInt64(42)) == .integer(42)) + #expect(JSONValue(UInt64.max) == .integer(Int.max)) + } + + @Test func fromDouble() { + #expect(JSONValue(3.14) == .double(3.14)) + } + + @Test func fromNSNull() { + #expect(JSONValue(NSNull()) == .null) + } + + @Test func fromNSNumber() { + #expect(JSONValue(NSNumber(value: true)) == .bool(true)) + #expect(JSONValue(NSNumber(value: 42)) == .integer(42)) + #expect(JSONValue(NSNumber(value: -42)) == .integer(-42)) + #expect(JSONValue(NSNumber(value: 3.14)) == .double(3.14)) + } + + @Test func fromNSNumberClamping() { + #expect(JSONValue(NSNumber(value: UInt64.max)) == .integer(Int.max)) + #expect(JSONValue(NSNumber(value: Int64.min)) == .integer(Int.min)) + } + + @Test func fromArray() { + let array: [Any] = ["hello", 42, true] + #expect(JSONValue(array) == .array([.string("hello"), .integer(42), .bool(true)])) + } + + @Test func fromObject() { + let dict: [String: Any] = ["key": "value", "count": 1] + #expect(JSONValue(dict) == .object(["key": .string("value"), "count": .integer(1)])) + } + + @Test func fromNestedCollections() { + let dict: [String: Any] = [ + "nested": [ + "array": [1, 2, 3] as [Any], + ] as [String: Any], + ] + #expect(JSONValue(dict) == .object([ + "nested": .object([ + "array": .array([.integer(1), .integer(2), .integer(3)]), + ]), + ])) + } + + @Test func fastPath() { + let original: JSONValue = .string("test") + #expect(JSONValue(original) == original) + + let object: [String: JSONValue] = ["k": .integer(1)] + #expect(JSONValue(object) == .object(object)) + + let array: [JSONValue] = [.bool(true)] + #expect(JSONValue(array) == .array(array)) + } + } + + @Suite struct Accessors { + @Test func integerAccessors() { + let val: JSONValue = .integer(42) + #expect(val.integer == 42) + #expect(val.double == 42.0) + #expect(val.string == nil) + } + + @Test func stringAccessors() { + let val: JSONValue = .string("test") + #expect(val.string == "test") + #expect(val.integer == nil) + } + } + + @Suite struct AnyConversion { + @Test func null() { + #expect(JSONValue.null.any is NSNull) + } + + @Test func bool() { + #expect(JSONValue.bool(true).any as? Bool == true) + } + + @Test func string() { + #expect(JSONValue.string("hello").any as? String == "hello") + } + + @Test func integer() { + #expect(JSONValue.integer(42).any as? Int == 42) + } + + @Test func double() { + #expect(JSONValue.double(3.14).any as? Double == 3.14) + } + + @Test func array() { + #expect((JSONValue.array([.integer(1)]).any as? [Int])?[0] == 1) + } + + @Test func object() { + #expect((JSONValue.object(["k": .integer(1)]).any as? [String: Int])?["k"] == 1) + } + } + + @Suite struct LiteralConformance { + @Test func nilLiteral() { + let val: JSONValue = nil + #expect(val == .null) + } + + @Test func boolLiteral() { + let val: JSONValue = true + #expect(val == .bool(true)) + } + + @Test func stringLiteral() { + let val: JSONValue = "hello" + #expect(val == .string("hello")) + } + + @Test func integerLiteral() { + let val: JSONValue = 42 + #expect(val == .integer(42)) + } + + @Test func floatLiteral() { + let val: JSONValue = 3.14 + #expect(val == .double(3.14)) + } + + @Test func arrayLiteral() { + let val: JSONValue = ["a", 1] + #expect(val == .array([.string("a"), .integer(1)])) + } + + @Test func dictionaryLiteral() { + let val: JSONValue = ["k": "v"] + #expect(val == .object(["k": .string("v")])) + } + } + + @Suite struct Codable { + @Test func roundTrip() throws { + let original: JSONValue = [ + "string": "value", + "int": 42, + "bool": true, + "null": nil, + "array": [1, 2, 3], + "object": ["k": "v"], + ] + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(original == decoded) + } + + @Test func decodesIntegerNotBool() throws { + let data = #"{"zero": 0, "one": 1, "two": 2}"#.data(using: .utf8)! + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == .object([ + "zero": .integer(0), + "one": .integer(1), + "two": .integer(2), + ])) + } + } + + @Suite struct ReadResultExtensions { + @Suite struct NonOptionalData { + @Test func asJSONValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONValue() == .success(.object(["foo": .string("bar")]))) + } + + @Test func asJSONObjectValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONObjectValue() == .success(["foo": .string("bar")])) + } + } + + @Suite struct OptionalData { + @Test func asJSONValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONValue() == .success(.object(["foo": .string("bar")]))) + } + + @Test func asJSONValueWithNilData() { + let result: ReadResult = .success(nil) + #expect(result.asJSONValue() == .success(nil)) + } + + @Test func asJSONObjectValue() { + let data = #"{"foo": "bar"}"#.data(using: .utf8)! + let result: ReadResult = .success(data) + #expect(result.asJSONObjectValue() == .success(["foo": .string("bar")])) + } + + @Test func asJSONObjectValueWithNilData() { + let result: ReadResult = .success(nil) + #expect(result.asJSONObjectValue() == .success(nil)) + } + } + } +} diff --git a/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift b/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift index 2b69c59d7b..be0cecf4e6 100644 --- a/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift +++ b/Tests/SharedTests/Toolkit/Tokenizer/TextTokenizerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Toolkit/URITemplateTests.swift b/Tests/SharedTests/Toolkit/URITemplateTests.swift index 7f1bb2b634..f3210897d9 100644 --- a/Tests/SharedTests/Toolkit/URITemplateTests.swift +++ b/Tests/SharedTests/Toolkit/URITemplateTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift index b033592c04..5ab8993eb9 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/FileURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,34 +9,34 @@ import Foundation import XCTest class FileURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar")! + FileURL(string: "file:///foo/bar"), + FileURL(string: "file:///foo/bar") ) // Fragments are ignored. XCTAssertEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar#fragment")! + FileURL(string: "file:///foo/bar"), + FileURL(string: "file:///foo/bar#fragment") ) XCTAssertNotEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/baz")! + try XCTUnwrap(FileURL(string: "file:///foo/bar")), + try XCTUnwrap(FileURL(string: "file:///foo/baz")) ) XCTAssertNotEqual( - FileURL(string: "file:///foo/bar")!, - FileURL(string: "file:///foo/bar/")! + try XCTUnwrap(FileURL(string: "file:///foo/bar")), + try XCTUnwrap(FileURL(string: "file:///foo/bar/")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertEqual(FileURL(url: URL(string: "file:///foo/bar")!)?.string, "file:///foo/bar") + func testCreateFromURL() throws { + XCTAssertEqual(try FileURL(url: XCTUnwrap(URL(string: "file:///foo/bar")))?.string, "file:///foo/bar") // Only valid for scheme `file`. - XCTAssertNil(FileURL(url: URL(string: "http://domain.com")!)) - XCTAssertNil(FileURL(url: URL(string: "opds://domain.com")!)) + XCTAssertNil(try FileURL(url: XCTUnwrap(URL(string: "http://domain.com")))) + XCTAssertNil(try FileURL(url: XCTUnwrap(URL(string: "opds://domain.com")))) } func testCreateFromString() { @@ -75,7 +75,7 @@ class FileURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")?.url, URL(string: "file:///foo/bar")!) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.url, URL(string: "file:///foo/bar")) } func testString() { @@ -88,8 +88,8 @@ class FileURLTests: XCTestCase { XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")?.path, "/foo/bar baz/") } - func testAppendingPath() { - var base = FileURL(string: "file:///foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(FileURL(string: "file:///foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "file:///foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "file:///foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "file:///foo/bar/baz/quz") @@ -103,42 +103,42 @@ class FileURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "file:///foo/bar/baz/quz") // With trailing slash. - base = FileURL(string: "file:///foo/bar/")! + base = try XCTUnwrap(FileURL(string: "file:///foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "file:///foo/bar/baz/quz") } func testPathSegments() { - XCTAssertEqual(FileURL(string: "file:///foo")!.pathSegments, ["foo"]) - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")!.pathSegments, ["foo", "bar baz"]) - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")!.pathSegments, ["foo", "bar baz"]) - XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")!.pathSegments, ["foo", "bar"]) + XCTAssertEqual(FileURL(string: "file:///foo")?.pathSegments, ["foo"]) + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")?.pathSegments, ["foo", "bar baz"]) + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")?.pathSegments, ["foo", "bar baz"]) + XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")?.pathSegments, ["foo", "bar"]) } func testLastPathSegment() { - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")!.lastPathSegment, "bar baz") - XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")!.lastPathSegment, "bar baz") - XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")!.lastPathSegment, "bar") + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz")?.lastPathSegment, "bar baz") + XCTAssertEqual(FileURL(string: "file:///foo/bar%20baz/")?.lastPathSegment, "bar baz") + XCTAssertEqual(FileURL(string: "file:///foo/bar?query#fragment")?.lastPathSegment, "bar") } func testRemovingLastPathSegment() { - XCTAssertEqual(FileURL(string: "file:///")!.removingLastPathSegment().string, "file:///") - XCTAssertEqual(FileURL(string: "file:///foo")!.removingLastPathSegment().string, "file:///") - XCTAssertEqual(FileURL(string: "file:///foo/bar")!.removingLastPathSegment().string, "file:///foo/") + XCTAssertEqual(FileURL(string: "file:///")?.removingLastPathSegment().string, "file:///") + XCTAssertEqual(FileURL(string: "file:///foo")?.removingLastPathSegment().string, "file:///") + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingLastPathSegment().string, "file:///foo/") } func testPathExtension() { - XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")!.pathExtension, "txt") - XCTAssertNil(FileURL(string: "file:///foo/bar")!.pathExtension) - XCTAssertNil(FileURL(string: "file:///foo/bar/")!.pathExtension) - XCTAssertNil(FileURL(string: "file:///foo/.hidden")!.pathExtension) + XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")?.pathExtension, "txt") + XCTAssertNil(FileURL(string: "file:///foo/bar")?.pathExtension) + XCTAssertNil(FileURL(string: "file:///foo/bar/")?.pathExtension) + XCTAssertNil(FileURL(string: "file:///foo/.hidden")?.pathExtension) } func testReplacingPathExtension() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")!.replacingPathExtension("xml").string, "file:///foo/bar.xml") - XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")!.replacingPathExtension("xml").string, "file:///foo/bar.xml") - XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")!.replacingPathExtension(nil).string, "file:///foo/bar") - XCTAssertEqual(FileURL(string: "file:///foo/bar/")!.replacingPathExtension("xml").string, "file:///foo/bar/") - XCTAssertEqual(FileURL(string: "file:///foo/bar/")!.replacingPathExtension(nil).string, "file:///foo/bar/") + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.replacingPathExtension("xml").string, "file:///foo/bar.xml") + XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")?.replacingPathExtension("xml").string, "file:///foo/bar.xml") + XCTAssertEqual(FileURL(string: "file:///foo/bar.txt")?.replacingPathExtension(nil).string, "file:///foo/bar") + XCTAssertEqual(FileURL(string: "file:///foo/bar/")?.replacingPathExtension("xml").string, "file:///foo/bar/") + XCTAssertEqual(FileURL(string: "file:///foo/bar/")?.replacingPathExtension(nil).string, "file:///foo/bar/") } func testQuery() { @@ -148,8 +148,8 @@ class FileURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingQuery(), FileURL(string: "file:///foo/bar")!) - XCTAssertEqual(FileURL(string: "file:///foo/bar?param=quz%20baz")?.removingQuery(), FileURL(string: "file:///foo/bar")!) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingQuery(), FileURL(string: "file:///foo/bar")) + XCTAssertEqual(FileURL(string: "file:///foo/bar?param=quz%20baz")?.removingQuery(), FileURL(string: "file:///foo/bar")) } func testFragment() { @@ -159,81 +159,81 @@ class FileURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingFragment(), FileURL(string: "file:///foo/bar")!) - XCTAssertEqual(FileURL(string: "file:///foo/bar#quz%20baz")?.removingFragment(), FileURL(string: "file:///foo/bar")!) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.removingFragment(), FileURL(string: "file:///foo/bar")) + XCTAssertEqual(FileURL(string: "file:///foo/bar#quz%20baz")?.removingFragment(), FileURL(string: "file:///foo/bar")) } // MARK: - AbsoluteURL func testScheme() { - XCTAssertEqual(FileURL(string: "file:///foo/bar")!.scheme, .file) - XCTAssertEqual(FileURL(string: "FILE:///foo/bar")!.scheme, .file) + XCTAssertEqual(FileURL(string: "file:///foo/bar")?.scheme, .file) + XCTAssertEqual(FileURL(string: "FILE:///foo/bar")?.scheme, .file) } func testHost() { - XCTAssertNil(FileURL(string: "file:///foo/bar")!.host) + XCTAssertNil(FileURL(string: "file:///foo/bar")?.host) } func testOrigin() { // Always null for a file URL. - XCTAssertNil(FileURL(string: "file:///foo/bar")!.origin) + XCTAssertNil(FileURL(string: "file:///foo/bar")?.origin) } - func testResolveAbsoluteURL() { - let base = FileURL(string: "file:///foo/bar")! - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(UnknownAbsoluteURL(string: "opds://other")!)!.string, "opds://other") + func testResolveAbsoluteURL() throws { + let base = try XCTUnwrap(FileURL(string: "file:///foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(UnknownAbsoluteURL(string: "opds://other")))?.string, "opds://other") } - func testResolveRelativeURL() { - var base = FileURL(string: "file:///foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, FileURL(string: "file:///foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, FileURL(string: "file:///quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, FileURL(string: "file:///quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, FileURL(string: "file:///foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(FileURL(string: "file:///foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), FileURL(string: "file:///foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), FileURL(string: "file:///quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), FileURL(string: "file:///quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), FileURL(string: "file:///foo/bar#fragment")) // With trailing slash - base = FileURL(string: "file:///foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, FileURL(string: "file:///foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, FileURL(string: "file:///foo/quz/baz")!) + base = try XCTUnwrap(FileURL(string: "file:///foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), FileURL(string: "file:///foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), FileURL(string: "file:///foo/quz/baz")) } - func testRelativize() { - var base = FileURL(string: "file:///foo")! + func testRelativize() throws { + var base = try XCTUnwrap(FileURL(string: "file:///foo")) - XCTAssertNil(base.relativize(AnyURL(string: "file:///foo")!)) - XCTAssertEqual(base.relativize(AnyURL(string: "file:///foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertNil(base.relativize(AnyURL(string: "file:///quz/baz")!)) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "file:///foo")))) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "file:///quz/baz")))) // With trailing slash - base = FileURL(string: "file:///foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "file:///foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(FileURL(string: "file:///foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///foo/quz/baz"))), RelativeURL(string: "quz/baz")) } - func testRelativizeRelativeURL() { - let base = FileURL(string: "file:///foo")! - XCTAssertNil(base.relativize(RelativeURL(string: "foo/bar")!)) + func testRelativizeRelativeURL() throws { + let base = try XCTUnwrap(FileURL(string: "file:///foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(RelativeURL(string: "foo/bar")))) } - func testRelativizeAbsoluteURLWithDifferentScheme() { - let base = FileURL(string: "file:///foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "https://host/foo/bar")!)) - XCTAssertNil(base.relativize(UnknownAbsoluteURL(string: "opds://host/foo/bar")!)) + func testRelativizeAbsoluteURLWithDifferentScheme() throws { + let base = try XCTUnwrap(FileURL(string: "file:///foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "https://host/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")))) } - func testIsRelative() { + func testIsRelative() throws { // Always relative if same scheme. - let url = FileURL(string: "file:///foo/bar")! - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///foo")!)) - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///foo/bar")!)) - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///foo/bar/baz")!)) - XCTAssertTrue(url.isRelative(to: FileURL(string: "file:///bar")!)) + let url = try XCTUnwrap(FileURL(string: "file:///foo/bar")) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///foo")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///foo/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///foo/bar/baz")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(FileURL(string: "file:///bar")))) // Different scheme - XCTAssertFalse(url.isRelative(to: UnknownAbsoluteURL(string: "other://host/foo")!)) - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "http://foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "other://host/foo")))) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://foo")))) // Relative path - XCTAssertFalse(url.isRelative(to: RelativeURL(path: "foo/bar")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(RelativeURL(path: "foo/bar")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift index cdbd8d7712..a615972b3e 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/HTTPURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,26 +9,26 @@ import Foundation import XCTest class HTTPURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - HTTPURL(string: "http://domain.com")!, - HTTPURL(string: "http://domain.com")! + HTTPURL(string: "http://domain.com"), + HTTPURL(string: "http://domain.com") ) XCTAssertNotEqual( - HTTPURL(string: "http://domain.com")!, - HTTPURL(string: "http://domain.com#fragment")! + try XCTUnwrap(HTTPURL(string: "http://domain.com")), + try XCTUnwrap(HTTPURL(string: "http://domain.com#fragment")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertEqual(HTTPURL(url: URL(string: "http://domain.com")!)?.string, "http://domain.com") - XCTAssertEqual(HTTPURL(url: URL(string: "https://domain.com")!)?.string, "https://domain.com") + func testCreateFromURL() throws { + XCTAssertEqual(try HTTPURL(url: XCTUnwrap(URL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try HTTPURL(url: XCTUnwrap(URL(string: "https://domain.com")))?.string, "https://domain.com") // Only valid for schemes `http` or `https`. - XCTAssertNil(HTTPURL(url: URL(string: "file://domain.com")!)) - XCTAssertNil(HTTPURL(url: URL(string: "opds://domain.com")!)) + XCTAssertNil(try HTTPURL(url: XCTUnwrap(URL(string: "file://domain.com")))) + XCTAssertNil(try HTTPURL(url: XCTUnwrap(URL(string: "opds://domain.com")))) } func testCreateFromString() { @@ -44,7 +44,7 @@ class HTTPURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar?query#fragment")?.url, URL(string: "http://foo/bar?query#fragment")!) + XCTAssertEqual(HTTPURL(string: "http://foo/bar?query#fragment")?.url, URL(string: "http://foo/bar?query#fragment")) } func testString() { @@ -60,8 +60,8 @@ class HTTPURLTests: XCTestCase { XCTAssertEqual(HTTPURL(string: "http://host?query")?.path, "") } - func testAppendingPath() { - var base = HTTPURL(string: "http://foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(HTTPURL(string: "http://foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "http://foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "http://foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "http://foo/bar/baz/quz") @@ -75,7 +75,7 @@ class HTTPURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "http://foo/bar/baz/quz") // With trailing slash. - base = HTTPURL(string: "http://foo/bar/")! + base = try XCTUnwrap(HTTPURL(string: "http://foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "http://foo/bar/baz/quz") } @@ -98,10 +98,10 @@ class HTTPURLTests: XCTestCase { } func testRemovingLastPathSegment() { - XCTAssertEqual(HTTPURL(string: "http://")!.removingLastPathSegment().string, "http://") - XCTAssertEqual(HTTPURL(string: "http://foo")!.removingLastPathSegment().string, "http://foo") - XCTAssertEqual(HTTPURL(string: "http://foo/bar")!.removingLastPathSegment().string, "http://foo/") - XCTAssertEqual(HTTPURL(string: "http://foo/bar/baz")!.removingLastPathSegment().string, "http://foo/bar/") + XCTAssertEqual(HTTPURL(string: "http://")?.removingLastPathSegment().string, "http://") + XCTAssertEqual(HTTPURL(string: "http://foo")?.removingLastPathSegment().string, "http://foo") + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingLastPathSegment().string, "http://foo/") + XCTAssertEqual(HTTPURL(string: "http://foo/bar/baz")?.removingLastPathSegment().string, "http://foo/bar/") } func testPathExtension() { @@ -112,12 +112,12 @@ class HTTPURLTests: XCTestCase { } func testReplacingPathExtension() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar")!.replacingPathExtension("xml").string, "http://foo/bar.xml") - XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")!.replacingPathExtension("xml").string, "http://foo/bar.xml") - XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")!.replacingPathExtension(nil).string, "http://foo/bar") - XCTAssertEqual(HTTPURL(string: "http://foo/bar/")!.replacingPathExtension("xml").string, "http://foo/bar/") - XCTAssertEqual(HTTPURL(string: "http://foo/bar/")!.replacingPathExtension(nil).string, "http://foo/bar/") - XCTAssertEqual(HTTPURL(string: "http://foo")!.replacingPathExtension("xml").string, "http://foo") + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.replacingPathExtension("xml").string, "http://foo/bar.xml") + XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")?.replacingPathExtension("xml").string, "http://foo/bar.xml") + XCTAssertEqual(HTTPURL(string: "http://foo/bar.txt")?.replacingPathExtension(nil).string, "http://foo/bar") + XCTAssertEqual(HTTPURL(string: "http://foo/bar/")?.replacingPathExtension("xml").string, "http://foo/bar/") + XCTAssertEqual(HTTPURL(string: "http://foo/bar/")?.replacingPathExtension(nil).string, "http://foo/bar/") + XCTAssertEqual(HTTPURL(string: "http://foo")?.replacingPathExtension("xml").string, "http://foo") } func testQuery() { @@ -129,8 +129,8 @@ class HTTPURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingQuery(), HTTPURL(string: "http://foo/bar")!) - XCTAssertEqual(HTTPURL(string: "http://foo/bar?param=quz%20baz")?.removingQuery(), HTTPURL(string: "http://foo/bar")!) + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingQuery(), HTTPURL(string: "http://foo/bar")) + XCTAssertEqual(HTTPURL(string: "http://foo/bar?param=quz%20baz")?.removingQuery(), HTTPURL(string: "http://foo/bar")) } func testFragment() { @@ -139,8 +139,8 @@ class HTTPURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingFragment(), HTTPURL(string: "http://foo/bar")!) - XCTAssertEqual(HTTPURL(string: "http://foo/bar#quz%20baz")?.removingFragment(), HTTPURL(string: "http://foo/bar")!) + XCTAssertEqual(HTTPURL(string: "http://foo/bar")?.removingFragment(), HTTPURL(string: "http://foo/bar")) + XCTAssertEqual(HTTPURL(string: "http://foo/bar#quz%20baz")?.removingFragment(), HTTPURL(string: "http://foo/bar")) } // MARK: - AbsoluteURL @@ -152,76 +152,76 @@ class HTTPURLTests: XCTestCase { } func testHost() { - XCTAssertNil(HTTPURL(string: "http://")!.host) - XCTAssertNil(HTTPURL(string: "http:///")!.host) - XCTAssertEqual(HTTPURL(string: "http://domain")!.host, "domain") - XCTAssertEqual(HTTPURL(string: "http://domain/path")!.host, "domain") + XCTAssertNil(HTTPURL(string: "http://")?.host) + XCTAssertNil(HTTPURL(string: "http:///")?.host) + XCTAssertEqual(HTTPURL(string: "http://domain")?.host, "domain") + XCTAssertEqual(HTTPURL(string: "http://domain/path")?.host, "domain") } func testOrigin() { - XCTAssertEqual(HTTPURL(string: "HTTP://foo/bar")!.origin, "http://foo") - XCTAssertEqual(HTTPURL(string: "https://foo:443/bar")!.origin, "https://foo:443") + XCTAssertEqual(HTTPURL(string: "HTTP://foo/bar")?.origin, "http://foo") + XCTAssertEqual(HTTPURL(string: "https://foo:443/bar")?.origin, "https://foo:443") } - func testResolveAbsoluteURL() { - let base = HTTPURL(string: "http://host/foo/bar")! - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(UnknownAbsoluteURL(string: "opds://other")!)!.string, "opds://other") - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") + func testResolveAbsoluteURL() throws { + let base = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(UnknownAbsoluteURL(string: "opds://other")))?.string, "opds://other") + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") } - func testResolveRelativeURL() { - var base = HTTPURL(string: "http://host/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, HTTPURL(string: "http://host/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, HTTPURL(string: "http://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, HTTPURL(string: "http://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, HTTPURL(string: "http://host/foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), HTTPURL(string: "http://host/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), HTTPURL(string: "http://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), HTTPURL(string: "http://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), HTTPURL(string: "http://host/foo/bar#fragment")) // With trailing slash - base = HTTPURL(string: "http://host/foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, HTTPURL(string: "http://host/foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, HTTPURL(string: "http://host/foo/quz/baz")!) + base = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), HTTPURL(string: "http://host/foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), HTTPURL(string: "http://host/foo/quz/baz")) } - func testRelativize() { - var base = HTTPURL(string: "http://host/foo")! + func testRelativize() throws { + var base = try XCTUnwrap(HTTPURL(string: "http://host/foo")) - XCTAssertNil(base.relativize(AnyURL(string: "http://host/foo")!)) - XCTAssertEqual(base.relativize(AnyURL(string: "http://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.relativize(AnyURL(string: "http://host/foo#fragment")!)!, RelativeURL(string: "#fragment")!) - XCTAssertNil(base.relativize(AnyURL(string: "http://host/quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "http://host//foo/bar")!)) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo")))) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo#fragment"))), RelativeURL(string: "#fragment")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://host//foo/bar")))) // With trailing slash - base = HTTPURL(string: "http://host/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "http://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(HTTPURL(string: "http://host/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) } - func testRelativizeRelativeURL() { - let base = HTTPURL(string: "http://host/foo")! - XCTAssertNil(base.relativize(RelativeURL(string: "host/foo/bar")!)) + func testRelativizeRelativeURL() throws { + let base = try XCTUnwrap(HTTPURL(string: "http://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(RelativeURL(string: "host/foo/bar")))) } - func testRelativizeAbsoluteURLWithDifferentScheme() { - let base = HTTPURL(string: "http://host/foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "https://host/foo/bar")!)) - XCTAssertNil(base.relativize(FileURL(string: "file://host/foo/bar")!)) + func testRelativizeAbsoluteURLWithDifferentScheme() throws { + let base = try XCTUnwrap(HTTPURL(string: "http://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "https://host/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(FileURL(string: "file://host/foo/bar")))) } - func testIsRelative() { + func testIsRelative() throws { // Only relative with the same origin. - let url = HTTPURL(string: "http://host/foo/bar")! - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/foo")!)) - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/foo/bar")!)) - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/foo/bar/baz")!)) - XCTAssertTrue(url.isRelative(to: HTTPURL(string: "http://host/bar")!)) + let url = try XCTUnwrap(HTTPURL(string: "http://host/foo/bar")) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/foo")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/foo/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/foo/bar/baz")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://host/bar")))) // Different scheme - XCTAssertFalse(url.isRelative(to: UnknownAbsoluteURL(string: "other://host/foo")!)) - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "https://host/foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "other://host/foo")))) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "https://host/foo")))) // Different host - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "http://foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://foo")))) // Relative path - XCTAssertFalse(url.isRelative(to: RelativeURL(path: "foo/bar")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(RelativeURL(path: "foo/bar")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift index 7484281fc0..9c440f979a 100644 --- a/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/Absolute URL/UnknownAbsoluteURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,21 +9,21 @@ import Foundation import XCTest class UnknownAbsoluteURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - UnknownAbsoluteURL(string: "opds://domain.com")!, - UnknownAbsoluteURL(string: "opds://domain.com")! + UnknownAbsoluteURL(string: "opds://domain.com"), + UnknownAbsoluteURL(string: "opds://domain.com") ) XCTAssertNotEqual( - UnknownAbsoluteURL(string: "opds://domain.com")!, - UnknownAbsoluteURL(string: "opds://domain.com#fragment")! + try XCTUnwrap(UnknownAbsoluteURL(string: "opds://domain.com")), + try XCTUnwrap(UnknownAbsoluteURL(string: "opds://domain.com#fragment")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertEqual(UnknownAbsoluteURL(url: URL(string: "opds://callback")!)?.string, "opds://callback") + func testCreateFromURL() throws { + XCTAssertEqual(try UnknownAbsoluteURL(url: XCTUnwrap(URL(string: "opds://callback")))?.string, "opds://callback") } func testCreateFromString() { @@ -36,7 +36,7 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?query#fragment")?.url, URL(string: "opds://foo/bar?query#fragment")!) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?query#fragment")?.url, URL(string: "opds://foo/bar?query#fragment")) } func testString() { @@ -52,8 +52,8 @@ class UnknownAbsoluteURLTests: XCTestCase { XCTAssertEqual(UnknownAbsoluteURL(string: "opds://host?query")?.path, "") } - func testAppendingPath() { - var base = UnknownAbsoluteURL(string: "opds://foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "opds://foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "opds://foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "opds://foo/bar/baz/quz") @@ -67,7 +67,7 @@ class UnknownAbsoluteURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "opds://foo/bar/baz/quz") // With trailing slash. - base = UnknownAbsoluteURL(string: "opds://foo/bar/")! + base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "opds://foo/bar/baz/quz") } @@ -90,10 +90,10 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testRemovingLastPathSegment() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://")!.removingLastPathSegment().string, "opds://") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")!.removingLastPathSegment().string, "opds://foo") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")!.removingLastPathSegment().string, "opds://foo/") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/baz")!.removingLastPathSegment().string, "opds://foo/bar/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://")?.removingLastPathSegment().string, "opds://") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")?.removingLastPathSegment().string, "opds://foo") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingLastPathSegment().string, "opds://foo/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/baz")?.removingLastPathSegment().string, "opds://foo/bar/") } func testPathExtension() { @@ -104,12 +104,12 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testReplacingPathExtension() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")!.replacingPathExtension("xml").string, "opds://foo/bar.xml") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")!.replacingPathExtension("xml").string, "opds://foo/bar.xml") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")!.replacingPathExtension(nil).string, "opds://foo/bar") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")!.replacingPathExtension("xml").string, "opds://foo/bar/") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")!.replacingPathExtension(nil).string, "opds://foo/bar/") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")!.replacingPathExtension("xml").string, "opds://foo") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.replacingPathExtension("xml").string, "opds://foo/bar.xml") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")?.replacingPathExtension("xml").string, "opds://foo/bar.xml") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar.txt")?.replacingPathExtension(nil).string, "opds://foo/bar") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")?.replacingPathExtension("xml").string, "opds://foo/bar/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar/")?.replacingPathExtension(nil).string, "opds://foo/bar/") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo")?.replacingPathExtension("xml").string, "opds://foo") } func testQuery() { @@ -121,8 +121,8 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")!) - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?param=quz%20baz")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")!) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar?param=quz%20baz")?.removingQuery(), UnknownAbsoluteURL(string: "opds://foo/bar")) } func testFragment() { @@ -131,8 +131,8 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")!) - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar#quz%20baz")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")!) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://foo/bar#quz%20baz")?.removingFragment(), UnknownAbsoluteURL(string: "opds://foo/bar")) } // MARK: - AbsoluteURL @@ -143,74 +143,74 @@ class UnknownAbsoluteURLTests: XCTestCase { } func testHost() { - XCTAssertNil(UnknownAbsoluteURL(string: "opds://")!.host) - XCTAssertNil(UnknownAbsoluteURL(string: "opds:///")!.host) - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain")!.host, "domain") - XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain/path")!.host, "domain") + XCTAssertNil(UnknownAbsoluteURL(string: "opds://")?.host) + XCTAssertNil(UnknownAbsoluteURL(string: "opds:///")?.host) + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain")?.host, "domain") + XCTAssertEqual(UnknownAbsoluteURL(string: "opds://domain/path")?.host, "domain") } func testOrigin() { - XCTAssertNil(UnknownAbsoluteURL(string: "opds://foo/bar")!.origin) + XCTAssertNil(UnknownAbsoluteURL(string: "opds://foo/bar")?.origin) } - func testResolveAbsoluteURL() { - let base = UnknownAbsoluteURL(string: "opds://host/foo/bar")! - XCTAssertEqual(base.resolve(UnknownAbsoluteURL(string: "opds://other")!)!.string, "opds://other") - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") + func testResolveAbsoluteURL() throws { + let base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(UnknownAbsoluteURL(string: "opds://other")))?.string, "opds://other") + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") } - func testResolveRelativeURL() { - var base = UnknownAbsoluteURL(string: "opds://host/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, UnknownAbsoluteURL(string: "opds://host/foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), UnknownAbsoluteURL(string: "opds://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), UnknownAbsoluteURL(string: "opds://host/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), UnknownAbsoluteURL(string: "opds://host/foo/bar#fragment")) // With trailing slash - base = UnknownAbsoluteURL(string: "opds://host/foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")!) + base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), UnknownAbsoluteURL(string: "opds://host/foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), UnknownAbsoluteURL(string: "opds://host/foo/quz/baz")) } - func testRelativize() { - var base = UnknownAbsoluteURL(string: "opds://host/foo")! + func testRelativize() throws { + var base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")) - XCTAssertNil(base.relativize(AnyURL(string: "opds://host/foo")!)) - XCTAssertEqual(base.relativize(AnyURL(string: "opds://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.relativize(AnyURL(string: "opds://host/foo#fragment")!)!, RelativeURL(string: "#fragment")!) - XCTAssertNil(base.relativize(AnyURL(string: "opds://host/quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "opds://host//foo/bar")!)) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo")))) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo#fragment"))), RelativeURL(string: "#fragment")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host//foo/bar")))) // With trailing slash - base = UnknownAbsoluteURL(string: "opds://host/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "opds://host/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "opds://host/foo/quz/baz"))), RelativeURL(string: "quz/baz")) } - func testRelativizeRelativeURL() { - let base = UnknownAbsoluteURL(string: "opds://host/foo")! - XCTAssertNil(base.relativize(RelativeURL(string: "host/foo/bar")!)) + func testRelativizeRelativeURL() throws { + let base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(RelativeURL(string: "host/foo/bar")))) } - func testRelativizeAbsoluteURLWithDifferentScheme() { - let base = UnknownAbsoluteURL(string: "opds://host/foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "http://host/foo/bar")!)) - XCTAssertNil(base.relativize(FileURL(string: "file://host/foo/bar")!)) + func testRelativizeAbsoluteURLWithDifferentScheme() throws { + let base = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "http://host/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(FileURL(string: "file://host/foo/bar")))) } - func testIsRelative() { + func testIsRelative() throws { // Always relative if same scheme. - let url = UnknownAbsoluteURL(string: "opds://host/foo/bar")! - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/foo")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/foo/bar")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/foo/bar/baz")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://host/bar")!)) - XCTAssertTrue(url.isRelative(to: UnknownAbsoluteURL(string: "opds://other-host")!)) + let url = try XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar/baz")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/bar")))) + XCTAssertTrue(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "opds://other-host")))) // Different scheme - XCTAssertFalse(url.isRelative(to: UnknownAbsoluteURL(string: "other://host/foo")!)) - XCTAssertFalse(url.isRelative(to: HTTPURL(string: "http://foo")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(UnknownAbsoluteURL(string: "other://host/foo")))) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(HTTPURL(string: "http://foo")))) // Relative path - XCTAssertFalse(url.isRelative(to: RelativeURL(path: "foo/bar")!)) + XCTAssertFalse(try url.isRelative(to: XCTUnwrap(RelativeURL(path: "foo/bar")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift index ed7881e3d7..1a7b58ab3d 100644 --- a/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/AnyURLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -9,23 +9,23 @@ import Foundation import XCTest class AnyURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - AnyURL(string: "opds://domain.com")!, - AnyURL(string: "opds://domain.com")! + AnyURL(string: "opds://domain.com"), + AnyURL(string: "opds://domain.com") ) XCTAssertNotEqual( - AnyURL(string: "opds://domain.com")!, - AnyURL(string: "https://domain.com")! + try XCTUnwrap(AnyURL(string: "opds://domain.com")), + try XCTUnwrap(AnyURL(string: "https://domain.com")) ) XCTAssertEqual( - AnyURL(string: "dir/file")!, - AnyURL(string: "dir/file")! + AnyURL(string: "dir/file"), + AnyURL(string: "dir/file") ) XCTAssertNotEqual( - AnyURL(string: "dir/file")!, - AnyURL(string: "dir/file#fragment")! + try XCTUnwrap(AnyURL(string: "dir/file")), + try XCTUnwrap(AnyURL(string: "dir/file#fragment")) ) } @@ -35,163 +35,163 @@ class AnyURLTests: XCTestCase { XCTAssertNil(AnyURL(string: "invalid character")) } - func testCreateFromRelativePath() { - XCTAssertEqual(AnyURL(string: "/foo/bar"), .relative(RelativeURL(string: "/foo/bar")!)) - XCTAssertEqual(AnyURL(string: "foo/bar"), .relative(RelativeURL(string: "foo/bar")!)) - XCTAssertEqual(AnyURL(string: "../bar"), .relative(RelativeURL(string: "../bar")!)) + func testCreateFromRelativePath() throws { + XCTAssertEqual(AnyURL(string: "/foo/bar"), try .relative(XCTUnwrap(RelativeURL(string: "/foo/bar")))) + XCTAssertEqual(AnyURL(string: "foo/bar"), try .relative(XCTUnwrap(RelativeURL(string: "foo/bar")))) + XCTAssertEqual(AnyURL(string: "../bar"), try .relative(XCTUnwrap(RelativeURL(string: "../bar")))) } - func testCreateFromAbsoluteURLs() { - XCTAssertEqual(AnyURL(string: "file:///foo/bar"), .absolute(FileURL(string: "file:///foo/bar")!)) - XCTAssertEqual(AnyURL(string: "http://host/foo/bar"), .absolute(HTTPURL(string: "http://host/foo/bar")!)) - XCTAssertEqual(AnyURL(string: "opds://host/foo/bar"), .absolute(UnknownAbsoluteURL(string: "opds://host/foo/bar")!)) + func testCreateFromAbsoluteURLs() throws { + XCTAssertEqual(AnyURL(string: "file:///foo/bar"), try .absolute(XCTUnwrap(FileURL(string: "file:///foo/bar")))) + XCTAssertEqual(AnyURL(string: "http://host/foo/bar"), try .absolute(XCTUnwrap(HTTPURL(string: "http://host/foo/bar")))) + XCTAssertEqual(AnyURL(string: "opds://host/foo/bar"), try .absolute(XCTUnwrap(UnknownAbsoluteURL(string: "opds://host/foo/bar")))) } - func testCreateFromLegacyHREF() { - XCTAssertEqual(AnyURL(legacyHREF: "dir/chapter.xhtml"), .relative(RelativeURL(string: "dir/chapter.xhtml")!)) + func testCreateFromLegacyHREF() throws { + XCTAssertEqual(AnyURL(legacyHREF: "dir/chapter.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "dir/chapter.xhtml")))) // Starting slash is removed. - XCTAssertEqual(AnyURL(legacyHREF: "/dir/chapter.xhtml"), .relative(RelativeURL(string: "dir/chapter.xhtml")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/dir/chapter.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "dir/chapter.xhtml")))) // Special characters are percent-encoded. - XCTAssertEqual(AnyURL(legacyHREF: "/dir/per%cent.xhtml"), .relative(RelativeURL(string: "dir/per%25cent.xhtml")!)) - XCTAssertEqual(AnyURL(legacyHREF: "/barrΓ©.xhtml"), .relative(RelativeURL(string: "barr%C3%A9.xhtml")!)) - XCTAssertEqual(AnyURL(legacyHREF: "/spa ce.xhtml"), .relative(RelativeURL(string: "spa%20ce.xhtml")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/dir/per%cent.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "dir/per%25cent.xhtml")))) + XCTAssertEqual(AnyURL(legacyHREF: "/barrΓ©.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "barr%C3%A9.xhtml")))) + XCTAssertEqual(AnyURL(legacyHREF: "/spa ce.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "spa%20ce.xhtml")))) // We assume that a relative path is percent-decoded. - XCTAssertEqual(AnyURL(legacyHREF: "/spa%20ce.xhtml"), .relative(RelativeURL(string: "spa%2520ce.xhtml")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/spa%20ce.xhtml"), try .relative(XCTUnwrap(RelativeURL(string: "spa%2520ce.xhtml")))) // Some special characters are authorized in a path. - XCTAssertEqual(AnyURL(legacyHREF: "/$&+,/=@"), .relative(RelativeURL(string: "$&+,/=@")!)) + XCTAssertEqual(AnyURL(legacyHREF: "/$&+,/=@"), try .relative(XCTUnwrap(RelativeURL(string: "$&+,/=@")))) // Valid absolute URL are left untouched. XCTAssertEqual( AnyURL(legacyHREF: "http://domain.com/a%20book?page=3"), - .absolute(HTTPURL(string: "http://domain.com/a%20book?page=3")!) + try .absolute(XCTUnwrap(HTTPURL(string: "http://domain.com/a%20book?page=3"))) ) } - func testResolveHTTPURL() { - var base = AnyURL(string: "http://example.com/foo/bar")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "http://example.com/foo/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz/baz")!)!.string, "http://example.com/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "/quz/baz")!)!.string, "http://example.com/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "#fragment")!)!.string, "http://example.com/foo/bar#fragment") - XCTAssertEqual(base.resolve(AnyURL(string: "file:///foo/bar")!)!.string, "file:///foo/bar") + func testResolveHTTPURL() throws { + var base = try XCTUnwrap(AnyURL(string: "http://example.com/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "http://example.com/foo/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz/baz")))?.string, "http://example.com/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "/quz/baz")))?.string, "http://example.com/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "#fragment")))?.string, "http://example.com/foo/bar#fragment") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "file:///foo/bar")))?.string, "file:///foo/bar") // With trailing slash - base = AnyURL(string: "http://example.com/foo/bar/")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "http://example.com/foo/bar/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz/baz")!)!.string, "http://example.com/foo/quz/baz") + base = try XCTUnwrap(AnyURL(string: "http://example.com/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "http://example.com/foo/bar/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz/baz")))?.string, "http://example.com/foo/quz/baz") } - func testResolveFileURL() { - var base = AnyURL(string: "file:///root/foo/bar")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz")!)!.string, "file:///root/foo/quz") - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "file:///root/foo/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz")!)!.string, "file:///root/quz") + func testResolveFileURL() throws { + var base = try XCTUnwrap(AnyURL(string: "file:///root/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz")))?.string, "file:///root/foo/quz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "file:///root/foo/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz")))?.string, "file:///root/quz") // With trailing slash - base = AnyURL(string: "file:///root/foo/bar/")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz/baz")!)!.string, "file:///root/foo/bar/quz/baz") - XCTAssertEqual(base.resolve(AnyURL(string: "../quz")!)!.string, "file:///root/foo/quz") + base = try XCTUnwrap(AnyURL(string: "file:///root/foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz/baz")))?.string, "file:///root/foo/bar/quz/baz") + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "../quz")))?.string, "file:///root/foo/quz") } - func testResolveTwoRelativeURLs() { - var base = RelativeURL(string: "foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, RelativeURL(string: "foo/bar#fragment")!) + func testResolveTwoRelativeURLs() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), RelativeURL(string: "foo/bar#fragment")) // With trailing slash - base = RelativeURL(string: "foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "foo/quz/baz")) // With starting slash - base = RelativeURL(string: "/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) } - func testRelativizeHTTPURL() { - var base = AnyURL(string: "http://example.com/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "http://example.com/foo/quz/baz")!)!.string, "quz/baz") - XCTAssertEqual(base.relativize(AnyURL(string: "http://example.com/foo#fragment")!)!.string, "#fragment") + func testRelativizeHTTPURL() throws { + var base = try XCTUnwrap(AnyURL(string: "http://example.com/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/quz/baz")))?.string, "quz/baz") + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo#fragment")))?.string, "#fragment") // With trailing slash - base = AnyURL(string: "http://example.com/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "http://example.com/foo/quz/baz")!)!.string, "quz/baz") + base = try XCTUnwrap(AnyURL(string: "http://example.com/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/quz/baz")))?.string, "quz/baz") } - func testRelativizeFileURL() { - var base = AnyURL(string: "file:///root/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "file:///root/foo/quz/baz")!)!.string, "quz/baz") - XCTAssertNil(base.relativize(AnyURL(string: "http://example.com/foo/bar")!)) + func testRelativizeFileURL() throws { + var base = try XCTUnwrap(AnyURL(string: "file:///root/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///root/foo/quz/baz")))?.string, "quz/baz") + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/bar")))) // With trailing slash - base = AnyURL(string: "file:///root/foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "file:///root/foo/quz/baz")!)!.string, "quz/baz") + base = try XCTUnwrap(AnyURL(string: "file:///root/foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "file:///root/foo/quz/baz")))?.string, "quz/baz") } - func testRelativizeTwoRelativeURLs() { - var base = AnyURL(string: "foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!.string, "quz/baz") - XCTAssertEqual(base.relativize(AnyURL(string: "foo#fragment")!)!.string, "#fragment") - XCTAssertNil(base.relativize(AnyURL(string: "quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "http://example.com/foo/bar")!)) + func testRelativizeTwoRelativeURLs() throws { + var base = try XCTUnwrap(AnyURL(string: "foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz")))?.string, "quz/baz") + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo#fragment")))?.string, "#fragment") + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "http://example.com/foo/bar")))) // With trailing slash - base = AnyURL(string: "foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!.string, "quz/baz") + base = try XCTUnwrap(AnyURL(string: "foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz")))?.string, "quz/baz") // With starting slash - base = AnyURL(string: "/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "/foo/quz/baz")!)!.string, "quz/baz") - XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) + base = try XCTUnwrap(AnyURL(string: "/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "/foo/quz/baz")))?.string, "quz/baz") + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/quz/baz")))) } func testNormalized() { // Scheme is lower case. XCTAssertEqual( - AnyURL(string: "HTTP://example.com")!.normalized.string, + AnyURL(string: "HTTP://example.com")?.normalized.string, "http://example.com" ) // Path is percent-decoded. XCTAssertEqual( - AnyURL(string: "HTTP://example.com/c%27est%20valide")!.normalized.string, + AnyURL(string: "HTTP://example.com/c%27est%20valide")?.normalized.string, "http://example.com/c'est%20valide" ) XCTAssertEqual( - AnyURL(string: "c%27est%20valide")!.normalized.string, + AnyURL(string: "c%27est%20valide")?.normalized.string, "c'est%20valide" ) // Relative paths are resolved. XCTAssertEqual( - AnyURL(string: "http://example.com/foo/./bar/../baz")!.normalized.string, + AnyURL(string: "http://example.com/foo/./bar/../baz")?.normalized.string, "http://example.com/foo/baz" ) XCTAssertEqual( - AnyURL(string: "foo/./bar/../baz")!.normalized.string, + AnyURL(string: "foo/./bar/../baz")?.normalized.string, "foo/baz" ) XCTAssertEqual( - AnyURL(string: "foo/./bar/../../../baz")!.normalized.string, + AnyURL(string: "foo/./bar/../../../baz")?.normalized.string, "../baz" ) // Trailing slash is kept. XCTAssertEqual( - AnyURL(string: "http://example.com/foo/")!.normalized.string, + AnyURL(string: "http://example.com/foo/")?.normalized.string, "http://example.com/foo/" ) XCTAssertEqual( - AnyURL(string: "foo/")!.normalized.string, + AnyURL(string: "foo/")?.normalized.string, "foo/" ) // The other components are left as-is. XCTAssertEqual( - AnyURL(string: "http://user:password@example.com:443/foo?b=b&a=a#fragment")!.normalized.string, + AnyURL(string: "http://user:password@example.com:443/foo?b=b&a=a#fragment")?.normalized.string, "http://user:password@example.com:443/foo?b=b&a=a#fragment" ) } diff --git a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift index 2dc3225de1..f6a982ff03 100644 --- a/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift +++ b/Tests/SharedTests/Toolkit/URL/RelativeURLTests.swift @@ -1,37 +1,35 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // -import Foundation - import Foundation @testable import ReadiumShared import XCTest class RelativeURLTests: XCTestCase { - func testEquality() { + func testEquality() throws { XCTAssertEqual( - RelativeURL(string: "dir/file")!, - RelativeURL(string: "dir/file")! + RelativeURL(string: "dir/file"), + RelativeURL(string: "dir/file") ) XCTAssertNotEqual( - RelativeURL(string: "dir/file/")!, - RelativeURL(string: "dir/file")! + try XCTUnwrap(RelativeURL(string: "dir/file/")), + try XCTUnwrap(RelativeURL(string: "dir/file")) ) XCTAssertNotEqual( - RelativeURL(string: "dir")!, - RelativeURL(string: "dir/file")! + try XCTUnwrap(RelativeURL(string: "dir")), + try XCTUnwrap(RelativeURL(string: "dir/file")) ) } // MARK: - URLProtocol - func testCreateFromURL() { - XCTAssertNil(RelativeURL(url: URL(string: "https://domain.com")!)) + func testCreateFromURL() throws { + XCTAssertNil(try RelativeURL(url: XCTUnwrap(URL(string: "https://domain.com")))) XCTAssertNil(RelativeURL(url: URL(fileURLWithPath: "/dir/file"))) - XCTAssertEqual(RelativeURL(url: URL(string: "/dir/file")!)?.string, "/dir/file") + XCTAssertEqual(try RelativeURL(url: XCTUnwrap(URL(string: "/dir/file")))?.string, "/dir/file") } func testCreateFromPath() { @@ -70,7 +68,7 @@ class RelativeURLTests: XCTestCase { } func testURL() { - XCTAssertEqual(RelativeURL(string: "foo/bar?query#fragment")?.url, URL(string: "foo/bar?query#fragment")!) + XCTAssertEqual(RelativeURL(string: "foo/bar?query#fragment")?.url, URL(string: "foo/bar?query#fragment")) } func testString() { @@ -87,8 +85,8 @@ class RelativeURLTests: XCTestCase { XCTAssertEqual(RelativeURL(string: "?query")?.path, "") } - func testAppendingPath() { - var base = RelativeURL(string: "foo/bar")! + func testAppendingPath() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo/bar")) XCTAssertEqual(base.appendingPath("", isDirectory: false).string, "foo/bar") XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "foo/bar/baz/quz") XCTAssertEqual(base.appendingPath("/baz/quz", isDirectory: false).string, "foo/bar/baz/quz") @@ -102,7 +100,7 @@ class RelativeURLTests: XCTestCase { XCTAssertEqual(base.appendingPath("baz/quz/", isDirectory: false).string, "foo/bar/baz/quz") // With trailing slash. - base = RelativeURL(string: "foo/bar/")! + base = try XCTUnwrap(RelativeURL(string: "foo/bar/")) XCTAssertEqual(base.appendingPath("baz/quz", isDirectory: false).string, "foo/bar/baz/quz") } @@ -126,12 +124,12 @@ class RelativeURLTests: XCTestCase { } func testRemovingLastPathSegment() { - XCTAssertEqual(RelativeURL(string: "foo")!.removingLastPathSegment().string, "./") - XCTAssertEqual(RelativeURL(string: "foo/bar")!.removingLastPathSegment().string, "foo/") - XCTAssertEqual(RelativeURL(string: "foo/bar/")!.removingLastPathSegment().string, "foo/") - XCTAssertEqual(RelativeURL(string: "/foo")!.removingLastPathSegment().string, "/") - XCTAssertEqual(RelativeURL(string: "/foo/bar")!.removingLastPathSegment().string, "/foo/") - XCTAssertEqual(RelativeURL(string: "/foo/bar/")!.removingLastPathSegment().string, "/foo/") + XCTAssertEqual(RelativeURL(string: "foo")?.removingLastPathSegment().string, "./") + XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingLastPathSegment().string, "foo/") + XCTAssertEqual(RelativeURL(string: "foo/bar/")?.removingLastPathSegment().string, "foo/") + XCTAssertEqual(RelativeURL(string: "/foo")?.removingLastPathSegment().string, "/") + XCTAssertEqual(RelativeURL(string: "/foo/bar")?.removingLastPathSegment().string, "/foo/") + XCTAssertEqual(RelativeURL(string: "/foo/bar/")?.removingLastPathSegment().string, "/foo/") } func testPathExtension() { @@ -142,11 +140,11 @@ class RelativeURLTests: XCTestCase { } func testReplacingPathExtension() { - XCTAssertEqual(RelativeURL(string: "/foo/bar")!.replacingPathExtension("xml").string, "/foo/bar.xml") - XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")!.replacingPathExtension("xml").string, "/foo/bar.xml") - XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")!.replacingPathExtension(nil).string, "/foo/bar") - XCTAssertEqual(RelativeURL(string: "/foo/bar/")!.replacingPathExtension("xml").string, "/foo/bar/") - XCTAssertEqual(RelativeURL(string: "/foo/bar/")!.replacingPathExtension(nil).string, "/foo/bar/") + XCTAssertEqual(RelativeURL(string: "/foo/bar")?.replacingPathExtension("xml").string, "/foo/bar.xml") + XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")?.replacingPathExtension("xml").string, "/foo/bar.xml") + XCTAssertEqual(RelativeURL(string: "/foo/bar.txt")?.replacingPathExtension(nil).string, "/foo/bar") + XCTAssertEqual(RelativeURL(string: "/foo/bar/")?.replacingPathExtension("xml").string, "/foo/bar/") + XCTAssertEqual(RelativeURL(string: "/foo/bar/")?.replacingPathExtension(nil).string, "/foo/bar/") } func testQuery() { @@ -158,8 +156,8 @@ class RelativeURLTests: XCTestCase { } func testRemovingQuery() { - XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingQuery(), RelativeURL(string: "foo/bar")!) - XCTAssertEqual(RelativeURL(string: "foo/bar?param=quz%20baz")?.removingQuery(), RelativeURL(string: "foo/bar")!) + XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingQuery(), RelativeURL(string: "foo/bar")) + XCTAssertEqual(RelativeURL(string: "foo/bar?param=quz%20baz")?.removingQuery(), RelativeURL(string: "foo/bar")) } func testFragment() { @@ -168,59 +166,59 @@ class RelativeURLTests: XCTestCase { } func testRemovingFragment() { - XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingFragment(), RelativeURL(string: "foo/bar")!) - XCTAssertEqual(RelativeURL(string: "foo/bar#quz%20baz")?.removingFragment(), RelativeURL(string: "foo/bar")!) + XCTAssertEqual(RelativeURL(string: "foo/bar")?.removingFragment(), RelativeURL(string: "foo/bar")) + XCTAssertEqual(RelativeURL(string: "foo/bar#quz%20baz")?.removingFragment(), RelativeURL(string: "foo/bar")) } // MARK: - RelativeURL - func testResolveURLConvertible() { - let base = RelativeURL(string: "foo/bar")! - XCTAssertEqual(base.resolve(AnyURL(string: "quz")!)?.string, "foo/quz") - XCTAssertEqual(base.resolve(HTTPURL(string: "http://domain.com")!)!.string, "http://domain.com") - XCTAssertEqual(base.resolve(FileURL(string: "file:///foo")!)!.string, "file:///foo") + func testResolveURLConvertible() throws { + let base = try XCTUnwrap(RelativeURL(string: "foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(AnyURL(string: "quz")))?.string, "foo/quz") + XCTAssertEqual(try base.resolve(XCTUnwrap(HTTPURL(string: "http://domain.com")))?.string, "http://domain.com") + XCTAssertEqual(try base.resolve(XCTUnwrap(FileURL(string: "file:///foo")))?.string, "file:///foo") } - func testResolveRelativeURL() { - var base = RelativeURL(string: "foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "#fragment")!)!, RelativeURL(string: "foo/bar#fragment")!) + func testResolveRelativeURL() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "#fragment"))), RelativeURL(string: "foo/bar#fragment")) // With trailing slash - base = RelativeURL(string: "foo/bar/")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "foo/bar/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "../quz/baz")!)!, RelativeURL(string: "foo/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "foo/bar/")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "foo/bar/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "../quz/baz"))), RelativeURL(string: "foo/quz/baz")) // With starting slash - base = RelativeURL(string: "/foo/bar")! - XCTAssertEqual(base.resolve(RelativeURL(string: "quz/baz")!)!, RelativeURL(string: "/foo/quz/baz")!) - XCTAssertEqual(base.resolve(RelativeURL(string: "/quz/baz")!)!, RelativeURL(string: "/quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "/foo/bar")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "quz/baz"))), RelativeURL(string: "/foo/quz/baz")) + XCTAssertEqual(try base.resolve(XCTUnwrap(RelativeURL(string: "/quz/baz"))), RelativeURL(string: "/quz/baz")) } - func testRelativize() { - var base = RelativeURL(string: "foo")! + func testRelativize() throws { + var base = try XCTUnwrap(RelativeURL(string: "foo")) - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertEqual(base.relativize(AnyURL(string: "foo#fragment")!)!, RelativeURL(string: "#fragment")!) - XCTAssertNil(base.relativize(AnyURL(string: "quz/baz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "/foo/bar")!)) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo#fragment"))), RelativeURL(string: "#fragment")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "quz/baz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/foo/bar")))) // With trailing slash - base = RelativeURL(string: "foo/")! - XCTAssertEqual(base.relativize(AnyURL(string: "foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) + base = try XCTUnwrap(RelativeURL(string: "foo/")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz/baz"))), RelativeURL(string: "quz/baz")) // With starting slash - base = RelativeURL(string: "/foo")! - XCTAssertEqual(base.relativize(AnyURL(string: "/foo/quz/baz")!)!, RelativeURL(string: "quz/baz")!) - XCTAssertNil(base.relativize(AnyURL(string: "foo/quz")!)) - XCTAssertNil(base.relativize(AnyURL(string: "/quz/baz")!)) + base = try XCTUnwrap(RelativeURL(string: "/foo")) + XCTAssertEqual(try base.relativize(XCTUnwrap(AnyURL(string: "/foo/quz/baz"))), RelativeURL(string: "quz/baz")) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "foo/quz")))) + XCTAssertNil(try base.relativize(XCTUnwrap(AnyURL(string: "/quz/baz")))) } - func testRelativizeAbsoluteURL() { - let base = RelativeURL(string: "foo")! - XCTAssertNil(base.relativize(HTTPURL(string: "http://example.com/foo/bar")!)) - XCTAssertNil(base.relativize(FileURL(string: "file:///foo")!)) + func testRelativizeAbsoluteURL() throws { + let base = try XCTUnwrap(RelativeURL(string: "foo")) + XCTAssertNil(try base.relativize(XCTUnwrap(HTTPURL(string: "http://example.com/foo/bar")))) + XCTAssertNil(try base.relativize(XCTUnwrap(FileURL(string: "file:///foo")))) } } diff --git a/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift b/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift index 4acfade51c..51701c35f1 100644 --- a/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift +++ b/Tests/SharedTests/Toolkit/URL/URLQueryTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -10,13 +10,13 @@ import XCTest class URLQueryTests: XCTestCase { func testParseEmptyQuery() throws { - let query = URLQuery(url: URL(string: "foo")!) + let query = try URLQuery(url: XCTUnwrap(URL(string: "foo"))) XCTAssertNil(query) } func testGetFirstQueryParameterNamedX() throws { - let query = try XCTUnwrap(URLQuery( - url: URL(string: "foo?query=param&fruit=banana&query=other&empty")! + let query = try XCTUnwrap(try URLQuery( + url: XCTUnwrap(URL(string: "foo?query=param&fruit=banana&query=other&empty")) )) XCTAssertEqual(query.first(named: "query"), "param") @@ -26,8 +26,8 @@ class URLQueryTests: XCTestCase { } func testGetAllQueryParametersNamedX() throws { - let query = try XCTUnwrap(URLQuery( - url: URL(string: "foo?query=param&fruit=banana&query=other&empty")! + let query = try XCTUnwrap(try URLQuery( + url: XCTUnwrap(URL(string: "foo?query=param&fruit=banana&query=other&empty")) )) XCTAssertEqual(query.all(named: "query"), ["param", "other"]) @@ -37,8 +37,8 @@ class URLQueryTests: XCTestCase { } func testQueryParameterArePercentDecoded() throws { - let query = try XCTUnwrap(URLQuery( - url: URL(string: "foo?query=hello%20world")! + let query = try XCTUnwrap(try URLQuery( + url: XCTUnwrap(URL(string: "foo?query=hello%20world")) )) XCTAssertEqual(query.first(named: "query"), "hello world") } diff --git a/Tests/SharedTests/Toolkit/XML/XMLTests.swift b/Tests/SharedTests/Toolkit/XML/XMLTests.swift index dd59e4409d..5a1653d6a5 100644 --- a/Tests/SharedTests/Toolkit/XML/XMLTests.swift +++ b/Tests/SharedTests/Toolkit/XML/XMLTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -96,12 +96,35 @@ class FuziTests: XCTestCase { try FuziXMLDocument(string: xml, namespaces: namespaces) } - func testParseInvalidXML() { tester.testParseValidXML() } - func testParseValidXML() { tester.testParseValidXML() } - func testParseHTML5() { tester.testParseHTML5() } - func testDocumentElement() throws { try tester.testDocumentElement() } - func testFirstElement() throws { try tester.testFirstElement() } - func testAllElements() throws { try tester.testAllElements() } - func testLocalName() throws { try tester.testLocalName() } - func testAttribute() throws { try tester.testAttribute() } + func testParseInvalidXML() { + tester.testParseInvalidXML() + } + + func testParseValidXML() { + tester.testParseValidXML() + } + + func testParseHTML5() { + tester.testParseHTML5() + } + + func testDocumentElement() throws { + try tester.testDocumentElement() + } + + func testFirstElement() throws { + try tester.testFirstElement() + } + + func testAllElements() throws { + try tester.testAllElements() + } + + func testLocalName() throws { + try tester.testLocalName() + } + + func testAttribute() throws { + try tester.testAttribute() + } } diff --git a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift index 1891c8320e..34b26ca6c4 100644 --- a/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/MinizipContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -34,7 +34,7 @@ class MinizipContainerTests: XCTestCase { func testGetNonExistingEntry() async throws { let container = try await container(for: "test.zip") - XCTAssertNil(container[AnyURL(path: "unknown")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "unknown"))]) } func testEntries() async throws { @@ -42,14 +42,14 @@ class MinizipContainerTests: XCTestCase { XCTAssertEqual( container.entries, - Set([ - AnyURL(path: ".hidden")!, - AnyURL(path: "A folder/Sub.folder%/file.txt")!, - AnyURL(path: "A folder/wasteland-cover.jpg")!, - AnyURL(path: "root.txt")!, - AnyURL(path: "uncompressed.jpg")!, - AnyURL(path: "uncompressed.txt")!, - AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!, + try Set([ + XCTUnwrap(AnyURL(path: ".hidden")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt")), + XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg")), + XCTUnwrap(AnyURL(path: "root.txt")), + XCTUnwrap(AnyURL(path: "uncompressed.jpg")), + XCTUnwrap(AnyURL(path: "uncompressed.txt")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")), ]) ) } @@ -68,16 +68,16 @@ class MinizipContainerTests: XCTestCase { func testReadCompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read().get() - let string = String(data: data, encoding: .utf8)! + let string = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(string.count, 29609) XCTAssertTrue(string.hasPrefix("I'm inside\nthe ZIP.")) } func testReadUncompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read().get() XCTAssertNotNil(data) XCTAssertEqual( @@ -89,7 +89,7 @@ class MinizipContainerTests: XCTestCase { func testReadUncompressedRange() async throws { // FIXME: It looks like unzseek64 starts from the beginning of the file header, instead of the content. Reading a first byte solves this but then Minizip crashes randomly... Note that this only fails in the test case. I didn't see actual issues in LCPDF or videos embedded in EPUBs. let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -99,7 +99,7 @@ class MinizipContainerTests: XCTestCase { func testReadCompressedRange() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -110,7 +110,7 @@ class MinizipContainerTests: XCTestCase { func testRandomCompressedRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/wasteland-cover.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg"))]) let length: UInt64 = 103_477 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) @@ -122,7 +122,7 @@ class MinizipContainerTests: XCTestCase { func testRandomStoredRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "uncompressed.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "uncompressed.jpg"))]) let length: UInt64 = 279_551 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) diff --git a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift index db1b72e8d5..bebf759ee0 100644 --- a/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift +++ b/Tests/SharedTests/Toolkit/ZIP/ZIPFoundationContainerTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -34,7 +34,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testGetNonExistingEntry() async throws { let container = try await container(for: "test.zip") - XCTAssertNil(container[AnyURL(path: "unknown")!]) + XCTAssertNil(try container[XCTUnwrap(AnyURL(path: "unknown"))]) } func testEntries() async throws { @@ -42,14 +42,14 @@ class ZIPFoundationContainerTests: XCTestCase { XCTAssertEqual( container.entries, - Set([ - AnyURL(path: ".hidden")!, - AnyURL(path: "A folder/Sub.folder%/file.txt")!, - AnyURL(path: "A folder/wasteland-cover.jpg")!, - AnyURL(path: "root.txt")!, - AnyURL(path: "uncompressed.jpg")!, - AnyURL(path: "uncompressed.txt")!, - AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!, + try Set([ + XCTUnwrap(AnyURL(path: ".hidden")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt")), + XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg")), + XCTUnwrap(AnyURL(path: "root.txt")), + XCTUnwrap(AnyURL(path: "uncompressed.jpg")), + XCTUnwrap(AnyURL(path: "uncompressed.txt")), + XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")), ]) ) } @@ -68,16 +68,16 @@ class ZIPFoundationContainerTests: XCTestCase { func testReadCompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read().get() - let string = String(data: data, encoding: .utf8)! + let string = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(string.count, 29609) XCTAssertTrue(string.hasPrefix("I'm inside\nthe ZIP.")) } func testReadUncompressedEntry() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read().get() XCTAssertNotNil(data) XCTAssertEqual( @@ -88,7 +88,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testReadUncompressedRange() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -98,7 +98,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testReadCompressedRange() async throws { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/Sub.folder%/file-compressed.txt")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/Sub.folder%/file-compressed.txt"))]) let data = try await entry.read(range: 14 ..< 20).get() XCTAssertEqual( String(data: data, encoding: .utf8), @@ -109,7 +109,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testRandomCompressedRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "A folder/wasteland-cover.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "A folder/wasteland-cover.jpg"))]) let length: UInt64 = 103_477 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) @@ -121,7 +121,7 @@ class ZIPFoundationContainerTests: XCTestCase { func testRandomStoredRead() async throws { for _ in 0 ..< 100 { let container = try await container(for: "test.zip") - let entry = try XCTUnwrap(container[AnyURL(path: "uncompressed.jpg")!]) + let entry = try XCTUnwrap(try container[XCTUnwrap(AnyURL(path: "uncompressed.jpg"))]) let length: UInt64 = 279_551 let lower = UInt64.random(in: 0 ..< length - 100) let upper = UInt64.random(in: lower ..< length) diff --git a/Tests/StreamerTests/Asserts.swift b/Tests/StreamerTests/Asserts.swift index 66a2378f8c..57bb5ebce2 100644 --- a/Tests/StreamerTests/Asserts.swift +++ b/Tests/StreamerTests/Asserts.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/EquatableError.swift b/Tests/StreamerTests/EquatableError.swift index fcaa6754a7..0b9a356c68 100644 --- a/Tests/StreamerTests/EquatableError.swift +++ b/Tests/StreamerTests/EquatableError.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Extensions.swift b/Tests/StreamerTests/Extensions.swift index dbbd600041..6b6c1221f3 100644 --- a/Tests/StreamerTests/Extensions.swift +++ b/Tests/StreamerTests/Extensions.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Fixtures.swift b/Tests/StreamerTests/Fixtures.swift index bf41940f81..78cb22d894 100644 --- a/Tests/StreamerTests/Fixtures.swift +++ b/Tests/StreamerTests/Fixtures.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf b/Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf new file mode 100644 index 0000000000..79c8b40746 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/all-images-in-spine.opf @@ -0,0 +1,18 @@ + + + + All Images in Spine Test + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-general.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-general.opf new file mode 100644 index 0000000000..3e8bee182e --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-general.opf @@ -0,0 +1,16 @@ + + + + General Fallback Test + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf new file mode 100644 index 0000000000..d023eae0b5 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-html-in-spine.opf @@ -0,0 +1,16 @@ + + + + HTML in Spine with Image Fallback Test + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf new file mode 100644 index 0000000000..958da78c75 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-image-html-mixed.opf @@ -0,0 +1,15 @@ + + + + Image and XHTML in Spine Test + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf b/Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf new file mode 100644 index 0000000000..d01bb3427a --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/fallback-image-in-spine.opf @@ -0,0 +1,16 @@ + + + + Image in Spine Test + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/OPF/links.opf b/Tests/StreamerTests/Fixtures/OPF/links.opf index f8e6fd96a8..eb71e91460 100644 --- a/Tests/StreamerTests/Fixtures/OPF/links.opf +++ b/Tests/StreamerTests/Fixtures/OPF/links.opf @@ -21,7 +21,7 @@ - + diff --git a/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf b/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf new file mode 100644 index 0000000000..7ccc16b506 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/OPF/media-overlays.opf @@ -0,0 +1,21 @@ + + + + Alice's Adventures in Wonderland + 1:32:29 + 0:23:45 + 0:08:44 + -epub-media-overlay-active + -epub-media-overlay-playing + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil b/Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil new file mode 100644 index 0000000000..adc843a6f6 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/audio-clip-times.smil @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/basic.smil b/Tests/StreamerTests/Fixtures/SMIL/basic.smil new file mode 100644 index 0000000000..6d7132fa10 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/basic.smil @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil b/Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil new file mode 100644 index 0000000000..254d4eca00 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/empty-seq.smil @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/nested.smil b/Tests/StreamerTests/Fixtures/SMIL/nested.smil new file mode 100644 index 0000000000..798a1a8e69 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/nested.smil @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil b/Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil new file mode 100644 index 0000000000..dd92e80e00 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/par-audio-video.smil @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/par-image.smil b/Tests/StreamerTests/Fixtures/SMIL/par-image.smil new file mode 100644 index 0000000000..64fcd99cd1 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/par-image.smil @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil b/Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil new file mode 100644 index 0000000000..264e845d4f --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/par-without-text.smil @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil b/Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil new file mode 100644 index 0000000000..11f70ac929 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/seq-textref.smil @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/seq-types.smil b/Tests/StreamerTests/Fixtures/SMIL/seq-types.smil new file mode 100644 index 0000000000..94678cd68e --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/seq-types.smil @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil b/Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil new file mode 100644 index 0000000000..5783c69eb7 --- /dev/null +++ b/Tests/StreamerTests/Fixtures/SMIL/video-clip-times.smil @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamerTests/Fixtures/test-comicinfo.cbz b/Tests/StreamerTests/Fixtures/test-comicinfo.cbz new file mode 100644 index 0000000000..35c30b2a6a Binary files /dev/null and b/Tests/StreamerTests/Fixtures/test-comicinfo.cbz differ diff --git a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift index 6f8cf1991f..2d7cda1116 100644 --- a/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift +++ b/Tests/StreamerTests/Parser/Audio/AudioParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -77,11 +77,6 @@ class AudioParserTests: XCTestCase { XCTAssertNil(publication.linkWithRel(.cover)) } - func testComputeTitleFromArchiveRootDirectory() async throws { - let publication = try await parser.parse(asset: zabAsset, warnings: nil).get().build() - XCTAssertEqual(publication.metadata.title, "Test Audiobook") - } - func testHasNoPositions() async throws { let publication = try await parser.parse(asset: zabAsset, warnings: nil).get().build() let result = try await publication.positions().get() diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index fff5170180..aba1465b6a 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -69,16 +69,16 @@ class AudioLocatorServiceTests: XCTestCase { ) } - func testLocateLocatorUsingTotalProgressionKeepsTitleAndText() async { + func testLocateLocatorUsingTotalProgressionKeepsTitleAndText() async throws { let service = makeService(readingOrder: [ Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) - let result = await service.locate( + let result = try await service.locate( Locator( href: "wrong", - mediaType: MediaType("text/plain")!, + mediaType: XCTUnwrap(MediaType("text/plain")), title: "Title", locations: .init( fragments: ["ignored"], diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift index 0a1e537293..99a19e1411 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBContainerParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift index 540f4ca597..c91f95849a 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBEncryptionParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -11,18 +11,18 @@ import XCTest class EPUBEncryptionParserTests: XCTestCase { let fixtures = Fixtures(path: "Encryption") - func testParseLCPEncryption() { + func testParseLCPEncryption() throws { let sut = parseEncryptions("encryption-lcp") - XCTAssertEqual(sut, [ - RelativeURL(path: "chapter01.xhtml")!: Encryption( + XCTAssertEqual(sut, try [ + XCTUnwrap(RelativeURL(path: "chapter01.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "deflate", originalLength: 13291, profile: nil, scheme: "http://readium.org/2014/01/lcp" ), - RelativeURL(path: "dir/chapter02.xhtml")!: Encryption( + XCTUnwrap(RelativeURL(path: "dir/chapter02.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "none", originalLength: 12914, @@ -32,18 +32,18 @@ class EPUBEncryptionParserTests: XCTestCase { ]) } - func testParseEncryptionWithNamespaces() { + func testParseEncryptionWithNamespaces() throws { let sut = parseEncryptions("encryption-lcp-namespaces") - XCTAssertEqual(sut, [ - RelativeURL(path: "chapter01.xhtml")!: Encryption( + XCTAssertEqual(sut, try [ + XCTUnwrap(RelativeURL(path: "chapter01.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "deflate", originalLength: 13291, profile: nil, scheme: "http://readium.org/2014/01/lcp" ), - RelativeURL(path: "dir/chapter02.xhtml")!: Encryption( + XCTUnwrap(RelativeURL(path: "dir/chapter02.xhtml")): Encryption( algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc", compression: "none", originalLength: 12914, diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index 3ad62840e0..bbea011fdc 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,7 +21,7 @@ class EPUBManifestParserTests: XCTestCase { XCTAssertEqual( manifest, - Manifest( + try Manifest( metadata: Metadata( identifier: "urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41", conformsTo: [.epub], @@ -68,17 +68,17 @@ class EPUBManifestParserTests: XCTestCase { ] ), readingOrder: [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml", mediaType: .xhtml), - link(id: "toc", href: "EPUB/toc.xhtml", mediaType: .xhtml), - link(id: "chapter01", href: "EPUB/chapter01.xhtml", mediaType: .xhtml), - link(id: "chapter02", href: "EPUB/chapter02.xhtml", mediaType: .xhtml), + link(href: "EPUB/titlepage.xhtml", mediaType: .xhtml), + link(href: "EPUB/toc.xhtml", mediaType: .xhtml), + link(href: "EPUB/chapter01.xhtml", mediaType: .xhtml), + link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), ], resources: [ - link(id: "font0", href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), - link(id: "nav", href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), - link(id: "css", href: "EPUB/style.css", mediaType: .css), - link(id: "img01a", href: "EPUB/images/alice01a.gif", mediaType: .gif, rels: [.cover]), - link(id: "img02a", href: "EPUB/images/alice02a.gif", mediaType: .gif), + link(href: "EPUB/fonts/MinionPro.otf", mediaType: XCTUnwrap(MediaType("application/vnd.ms-opentype"))), + link(href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), + link(href: "EPUB/style.css", mediaType: .css), + link(href: "EPUB/images/alice01a.gif", mediaType: .gif, rels: [.cover]), + link(href: "EPUB/images/alice02a.gif", mediaType: .gif), ] ) ) @@ -96,10 +96,10 @@ class EPUBManifestParserTests: XCTestCase { XCTAssertEqual( manifest.readingOrder, [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml", mediaType: .xhtml, rels: [.cover]), - link(id: "toc", href: "EPUB/toc.xhtml", mediaType: .xhtml, rels: [.contents]), - link(id: "chapter01", href: "EPUB/chapter01.xhtml", mediaType: .xhtml, rels: [.start]), - link(id: "chapter02", href: "EPUB/chapter02.xhtml", mediaType: .xhtml), + link(href: "EPUB/titlepage.xhtml", mediaType: .xhtml, rels: [.cover]), + link(href: "EPUB/toc.xhtml", mediaType: .xhtml, rels: [.contents]), + link(href: "EPUB/chapter01.xhtml", mediaType: .xhtml, rels: [.start]), + link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), ] ) } @@ -124,12 +124,14 @@ class EPUBManifestParserTests: XCTestCase { XCTAssertEqual( manifest.readingOrder, [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml", mediaType: .xhtml), - link(id: "beginpage", href: "EPUB/beginpage.xhtml", mediaType: .xhtml, rels: [.start]), + link(href: "EPUB/titlepage.xhtml", mediaType: .xhtml), + link(href: "EPUB/beginpage.xhtml", mediaType: .xhtml, rels: [.start]), ] ) } + // MARK: - Helpers + private func parser(files: [String: String]) -> EPUBManifestParser { EPUBManifestParser( container: FileContainer(files: files.reduce(into: [:]) { files, item in @@ -140,7 +142,6 @@ class EPUBManifestParserTests: XCTestCase { } private func link( - id: String? = nil, href: String, mediaType: MediaType? = nil, templated: Bool = false, @@ -149,10 +150,6 @@ class EPUBManifestParserTests: XCTestCase { properties: Properties = .init(), children: [Link] = [] ) -> Link { - var properties = properties.otherProperties - if let id = id { - properties["id"] = id - } - return Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: Properties(properties), children: children) + Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: properties, children: children) } } diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index 14920fa893..fa4c7f9c3c 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -321,9 +321,9 @@ class EPUBMetadataParserTests: XCTestCase { let sut = try parseMetadata("tdm-epub2") XCTAssertEqual( sut.tdm, - TDM( + try TDM( reservation: .all, - policy: HTTPURL(string: "https://provider.com/policies/policy.json")! + policy: XCTUnwrap(HTTPURL(string: "https://provider.com/policies/policy.json")) ) ) } @@ -332,13 +332,42 @@ class EPUBMetadataParserTests: XCTestCase { let sut = try parseMetadata("tdm-epub3") XCTAssertEqual( sut.tdm, - TDM( + try TDM( reservation: .all, - policy: HTTPURL(string: "https://provider.com/policies/policy.json")! + policy: XCTUnwrap(HTTPURL(string: "https://provider.com/policies/policy.json")) ) ) } + // MARK: - Media Overlays + + func testParseMediaOverlaysDuration() throws { + let sut = try parseMetadata("media-overlays") + // 1h 32m 29s = 3600 + 1949 = 5549 + XCTAssertEqual(sut.duration, 5549.0) + } + + func testParseMediaOverlaysActiveClass() throws { + let sut = try parseMetadata("media-overlays") + XCTAssertEqual(sut.mediaOverlay?.activeClass, "-epub-media-overlay-active") + } + + func testParseMediaOverlaysPlaybackActiveClass() throws { + let sut = try parseMetadata("media-overlays") + XCTAssertEqual(sut.mediaOverlay?.playbackActiveClass, "-epub-media-overlay-playing") + } + + func testMediaOverlayNotInRawOtherMetadata() throws { + let sut = try parseMetadata("media-overlays") + // active-class and playback-active-class should be consumed, not in otherMetadata + let mediaVocab = "http://www.idpf.org/epub/vocab/overlays/#" + XCTAssertNil(sut.otherMetadata["\(mediaVocab)active-class"]) + XCTAssertNil(sut.otherMetadata["\(mediaVocab)playback-active-class"]) + XCTAssertNil(sut.otherMetadata["\(mediaVocab)duration"]) + // The synthesized mediaOverlay key must be stored in otherMetadata + XCTAssertNotNil(sut.otherMetadata["mediaOverlay"]) + } + // MARK: - Toolkit func parseMetadata(_ name: String, displayOptions: String? = nil) throws -> Metadata { diff --git a/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift b/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift index 1e1e3f45ef..30f208f99e 100644 --- a/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/NCXParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift b/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift index fbb9f5ce3c..c422bd75bc 100644 --- a/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/NavigationDocumentParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 42193b482c..a8ac0d333e 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -21,7 +21,7 @@ class OPFParserTests: XCTestCase { layout: .reflowable ), readingOrder: [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml"), + link(href: "EPUB/titlepage.xhtml"), ] )) } @@ -46,19 +46,24 @@ class OPFParserTests: XCTestCase { XCTAssertEqual(sut.links, []) XCTAssertEqual(sut.readingOrder, [ - link(id: "titlepage", href: "titlepage.xhtml", mediaType: .xhtml), - link(id: "chapter01", href: "EPUB/chapter01.xhtml", mediaType: .xhtml), + link(href: "titlepage.xhtml", mediaType: .xhtml), + Link( + href: "EPUB/chapter01.xhtml", + mediaType: .xhtml, + alternates: [ + Link(href: "EPUB/chapter01.smil", mediaType: .smil), + ] + ), ]) - XCTAssertEqual(sut.resources, [ - link(id: "font0", href: "EPUB/fonts/MinionPro.otf", mediaType: MediaType("application/vnd.ms-opentype")!), - link(id: "nav", href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), - link(id: "css", href: "style.css", mediaType: .css), - link(id: "chapter02", href: "EPUB/chapter02.xhtml", mediaType: .xhtml), - link(id: "chapter01_smil", href: "EPUB/chapter01.smil", mediaType: .smil), - link(id: "chapter02_smil", href: "EPUB/chapter02.smil", mediaType: .smil), - link(id: "img01a", href: "EPUB/images/alice01a.png", mediaType: .png, rels: [.cover]), - link(id: "img02a", href: "EPUB/images/alice02a.gif", mediaType: .gif), - link(id: "nomediatype", href: "EPUB/nomediatype.txt"), + XCTAssertEqual(sut.resources, try [ + link(href: "EPUB/fonts/MinionPro.otf", mediaType: XCTUnwrap(MediaType("application/vnd.ms-opentype"))), + link(href: "EPUB/nav.xhtml", mediaType: .xhtml, rels: [.contents]), + link(href: "style.css", mediaType: .css), + link(href: "EPUB/chapter02.xhtml", mediaType: .xhtml), + Link(href: "EPUB/chapter02.smil", mediaType: .smil, duration: 1949.0), + link(href: "EPUB/images/alice01a.png", mediaType: .png, rels: [.cover]), + link(href: "EPUB/images/alice02a.gif", mediaType: .gif), + link(href: "EPUB/nomediatype.txt"), ]) } @@ -66,7 +71,7 @@ class OPFParserTests: XCTestCase { let sut = try parseManifest("links-spine", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.readingOrder, [ - link(id: "titlepage", href: "EPUB/titlepage.xhtml"), + link(href: "EPUB/titlepage.xhtml"), ]) } @@ -74,32 +79,32 @@ class OPFParserTests: XCTestCase { let sut = try parseManifest("links-properties", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.readingOrder.count, 8) - XCTAssertEqual(sut.readingOrder[0], link(id: "chapter01", href: "EPUB/chapter01.xhtml", rels: [.contents], properties: Properties([ + XCTAssertEqual(sut.readingOrder[0], link(href: "EPUB/chapter01.xhtml", rels: [.contents], properties: Properties([ "contains": ["mathml"], "page": "right", ]))) - XCTAssertEqual(sut.readingOrder[1], link(id: "chapter02", href: "EPUB/chapter02.xhtml", properties: Properties([ + XCTAssertEqual(sut.readingOrder[1], link(href: "EPUB/chapter02.xhtml", properties: Properties([ "contains": ["remote-resources"], "page": "left", ]))) - XCTAssertEqual(sut.readingOrder[2], link(id: "chapter03", href: "EPUB/chapter03.xhtml", properties: Properties([ + XCTAssertEqual(sut.readingOrder[2], link(href: "EPUB/chapter03.xhtml", properties: Properties([ "contains": ["js", "svg"], "page": "center", ]))) - XCTAssertEqual(sut.readingOrder[3], link(id: "chapter04", href: "EPUB/chapter04.xhtml", properties: Properties([ + XCTAssertEqual(sut.readingOrder[3], link(href: "EPUB/chapter04.xhtml", properties: Properties([ "contains": ["onix", "xmp"], ]))) - XCTAssertEqual(sut.readingOrder[4], link(id: "chapter05", href: "EPUB/chapter05.xhtml", properties: Properties())) - XCTAssertEqual(sut.readingOrder[5], link(id: "chapter06", href: "EPUB/chapter06.xhtml", properties: Properties())) - XCTAssertEqual(sut.readingOrder[6], link(id: "chapter07", href: "EPUB/chapter07.xhtml", properties: Properties())) - XCTAssertEqual(sut.readingOrder[7], link(id: "chapter08", href: "EPUB/chapter08.xhtml", properties: Properties())) + XCTAssertEqual(sut.readingOrder[4], link(href: "EPUB/chapter05.xhtml")) + XCTAssertEqual(sut.readingOrder[5], link(href: "EPUB/chapter06.xhtml")) + XCTAssertEqual(sut.readingOrder[6], link(href: "EPUB/chapter07.xhtml")) + XCTAssertEqual(sut.readingOrder[7], link(href: "EPUB/chapter08.xhtml")) } func testParseEPUB2Cover() throws { let sut = try parseManifest("cover-epub2", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.resources, [ - link(id: "my-cover", href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), ]) } @@ -107,11 +112,140 @@ class OPFParserTests: XCTestCase { let sut = try parseManifest("cover-epub3", at: "EPUB/content.opf").manifest XCTAssertEqual(sut.resources, [ - link(id: "my-cover", href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + link(href: "EPUB/cover.jpg", mediaType: .jpeg, rels: [.cover]), + ]) + } + + // MARK: - Fallback Handling + + /// When an image is in the spine with an HTML fallback, the image should be + /// in readingOrder and HTML should be added as an alternate. + func testParseImageInSpineWithHTMLFallback() throws { + let sut = try parseManifest("fallback-image-in-spine", at: "EPUB/content.opf").manifest + + XCTAssertEqual(sut.readingOrder.count, 2) + + // First image in spine + XCTAssertEqual(sut.readingOrder[0].href, "EPUB/page1.jpg") + XCTAssertEqual(sut.readingOrder[0].mediaType, .jpeg) + XCTAssertEqual(sut.readingOrder[0].alternates, [ + Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), + ]) + + // Second image in spine + XCTAssertEqual(sut.readingOrder[1].href, "EPUB/page2.png") + XCTAssertEqual(sut.readingOrder[1].mediaType, .png) + XCTAssertEqual(sut.readingOrder[1].alternates, [ + Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), + ]) + + // HTML fallbacks should not be in resources + XCTAssertTrue(sut.resources.isEmpty) + } + + /// When HTML is in the spine with an image fallback, we swap: the image + /// should be in readingOrder and HTML should be added as an alternate. + func testParseHTMLInSpineWithImageFallback() throws { + let sut = try parseManifest("fallback-html-in-spine", at: "EPUB/content.opf").manifest + + XCTAssertEqual(sut.readingOrder.count, 2) + + // First item: image swapped into readingOrder, HTML as alternate + XCTAssertEqual(sut.readingOrder[0].href, "EPUB/page1.jpg") + XCTAssertEqual(sut.readingOrder[0].mediaType, .jpeg) + XCTAssertEqual(sut.readingOrder[0].alternates, [ + Link(href: "EPUB/page1.xhtml", mediaType: .xhtml), + ]) + + // Second item: image swapped into readingOrder, HTML as alternate + XCTAssertEqual(sut.readingOrder[1].href, "EPUB/page2.png") + XCTAssertEqual(sut.readingOrder[1].mediaType, .png) + XCTAssertEqual(sut.readingOrder[1].alternates, [ + Link(href: "EPUB/page2.xhtml", mediaType: .xhtml), + ]) + + // Fallback images should not be in resources + XCTAssertTrue(sut.resources.isEmpty) + } + + /// General fallback handling: any fallback should be translated to an + /// alternate. + func testParseGeneralFallbackAsAlternate() throws { + let sut = try parseManifest("fallback-general", at: "EPUB/content.opf").manifest + + XCTAssertEqual(sut.readingOrder.count, 2) + + // First item: XHTML with XHTML fallback + XCTAssertEqual(sut.readingOrder[0].href, "EPUB/chapter1.xhtml") + XCTAssertEqual(sut.readingOrder[0].mediaType, .xhtml) + XCTAssertEqual(sut.readingOrder[0].alternates, [ + Link(href: "EPUB/chapter1-alt.xhtml", mediaType: .xhtml), + ]) + + // Second item: XHTML with PDF fallback + XCTAssertEqual(sut.readingOrder[1].href, "EPUB/chapter2.xhtml") + XCTAssertEqual(sut.readingOrder[1].mediaType, .xhtml) + XCTAssertEqual(sut.readingOrder[1].alternates, [ + Link(href: "EPUB/chapter2.pdf", mediaType: .pdf), + ]) + + // Fallback resources should not be in resources + XCTAssertTrue(sut.resources.isEmpty) + } + + // MARK: - Divina Inference + + /// When all spine items are bitmaps, the metadata should have: + /// - `layout = .fixed` to use the FXL navigator + /// - `.divina` added to `conformsTo` + func testParseAllImagesInSpineSetsFixedLayoutAndDivinaProfile() throws { + let sut = try parseManifest("all-images-in-spine", at: "EPUB/content.opf").manifest + + // Should have fixed layout + XCTAssertEqual(sut.metadata.layout, .fixed) + + // Should conform to both EPUB and Divina + XCTAssertTrue(sut.metadata.conformsTo.contains(.epub)) + XCTAssertTrue(sut.metadata.conformsTo.contains(.divina)) + + // Reading order should contain all images + XCTAssertEqual(sut.readingOrder.count, 3) + XCTAssertEqual(sut.readingOrder[0].mediaType, .jpeg) + XCTAssertEqual(sut.readingOrder[1].mediaType, .png) + XCTAssertEqual(sut.readingOrder[2].mediaType, .gif) + } + + /// When not all spine items are bitmaps, the metadata should NOT have + /// `.divina` profile and layout should remain reflowable. + func testParseMixedSpineDoesNotSetDivinaProfile() throws { + let sut = try parseManifest("fallback-image-html-mixed", at: "EPUB/content.opf").manifest + + // Should have reflowable layout (default) + XCTAssertEqual(sut.metadata.layout, .reflowable) + + // Should only conform to EPUB, not Divina + XCTAssertTrue(sut.metadata.conformsTo.contains(.epub)) + XCTAssertFalse(sut.metadata.conformsTo.contains(.divina)) + } + + // MARK: - Media Overlays + + func testParseMediaOverlaysSmilAsAlternate() throws { + let sut = try parseManifest("media-overlays", at: "EPUB/content.opf").manifest + + // SMIL should be an alternate of each reading order item, not in resources + XCTAssertEqual(sut.readingOrder[0].href, "EPUB/chapter01.xhtml") + XCTAssertEqual(sut.readingOrder[0].alternates, [ + Link(href: "EPUB/chapter01.smil", mediaType: .smil, duration: 1425.0), + ]) + XCTAssertEqual(sut.readingOrder[1].href, "EPUB/chapter02.xhtml") + XCTAssertEqual(sut.readingOrder[1].alternates, [ + Link(href: "EPUB/chapter02.smil", mediaType: .smil, duration: 524.0), ]) + XCTAssertTrue(sut.resources.isEmpty) } - // MARK: - Toolkit + // MARK: - Helpers func parseManifest(_ name: String, at path: String = "EPUB/content.opf", displayOptions: String? = nil) throws -> (manifest: Manifest, version: String) { let parts = try OPFParser( @@ -128,11 +262,7 @@ class OPFParserTests: XCTestCase { ), parts.version) } - func link(id: String? = nil, href: String, mediaType: MediaType? = nil, templated: Bool = false, title: String? = nil, rels: [LinkRelation] = [], properties: Properties = .init(), children: [Link] = []) -> Link { - var properties = properties.otherProperties - if let id = id { - properties["id"] = id - } - return Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: Properties(properties), children: children) + func link(href: String, mediaType: MediaType? = nil, templated: Bool = false, title: String? = nil, rels: [LinkRelation] = [], properties: Properties = .init(), children: [Link] = []) -> Link { + Link(href: href, mediaType: mediaType, templated: templated, title: title, rels: rels, properties: properties, children: children) } } diff --git a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift index af8d1bb6ff..7d9a7c61c3 100644 --- a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -29,7 +29,7 @@ class EPUBDeobfuscatorTests: XCTestCase { XCTAssertEqual(result, .success(font)) } - // Fix for https://github.com/readium/r2-streamer-swift/issues/208 + /// Fix for https://github.com/readium/r2-streamer-swift/issues/208 func testEmptyPublicationID() async throws { let file = fixtures.data(at: "nav.xhtml") diff --git a/Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift new file mode 100644 index 0000000000..c975cc1e27 --- /dev/null +++ b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILGuidedNavigationServiceTests.swift @@ -0,0 +1,107 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared +@testable import ReadiumStreamer +import Testing + +@Suite class SMILGuidedNavigationServiceTests { + let smilData = """ + + + + + + + + + + + + + + + + """ + + let smilLink = Link( + href: "OEBPS/chapter01.smil", + mediaType: .smil + ) + + /// Reading order link with a SMIL alternate. + lazy var linkWithSMIL = Link( + href: "OEBPS/chapter01.xhtml", + mediaType: .html, + alternates: [smilLink] + ) + + /// Reading order link without any SMIL alternate. + let linkWithoutSMIL = Link( + href: "OEBPS/chapter02.xhtml", + mediaType: .html + ) + + func makeService(readingOrder: [Link], container: Container? = nil) -> SMILGuidedNavigationService { + let container = container ?? SingleResourceContainer( + resource: DataResource(string: smilData), + at: smilLink.url() + ) + + return SMILGuidedNavigationService(readingOrder: readingOrder, container: container) + } + + // MARK: - hasGuidedNavigation(for:) + + @Test func hasGuidedNavigationFalseForLinkWithoutSMIL() { + let service = makeService(readingOrder: [linkWithoutSMIL]) + #expect(service.hasGuidedNavigation(for: linkWithoutSMIL) == false) + } + + @Test func hasGuidedNavigationTrueForLinkWithSMIL() { + let service = makeService(readingOrder: [linkWithSMIL]) + #expect(service.hasGuidedNavigation(for: linkWithSMIL) == true) + } + + @Test func hasGuidedNavigationPublicationLevelTrueWhenAnyLinkHasSMIL() { + let service = makeService(readingOrder: [linkWithoutSMIL, linkWithSMIL]) + #expect(service.hasGuidedNavigation == true) + } + + @Test func hasGuidedNavigationPublicationLevelFalseWhenNoLinkHasSMIL() { + let service = makeService(readingOrder: [linkWithoutSMIL]) + #expect(service.hasGuidedNavigation == false) + } + + // MARK: - guidedNavigationDocument(for:) + + @Test func returnsNilForLinkWithoutSMIL() async throws { + let service = makeService(readingOrder: [linkWithoutSMIL]) + let result = await service.guidedNavigationDocument(for: linkWithoutSMIL) + #expect(try result.get() == nil) + } + + @Test func returnsFailureWhenSMILMissingFromContainer() async { + let service = makeService(readingOrder: [linkWithSMIL], container: EmptyContainer()) + let result = await service.guidedNavigationDocument(for: linkWithSMIL) + #expect { + try result.get() + } throws: { error in + guard let readError = error as? ReadError, case .decoding = readError else { return false } + return true + } + } + + @Test func returnsDocumentForLinkWithSMIL() async throws { + let service = makeService(readingOrder: [linkWithSMIL]) + let doc = try await service.guidedNavigationDocument(for: linkWithSMIL).get() + #expect(doc != nil) + #expect(doc?.guided.isEmpty == false) + } +} diff --git a/Tests/StreamerTests/Parser/EPUB/SMIL/SMILParserTests.swift b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILParserTests.swift new file mode 100644 index 0000000000..3d2061fe00 --- /dev/null +++ b/Tests/StreamerTests/Parser/EPUB/SMIL/SMILParserTests.swift @@ -0,0 +1,387 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared +@testable import ReadiumStreamer +import Testing + +@Suite enum SMILParserTests { + static let fixtures = Fixtures(path: "SMIL") + + /// Returns the parsed document for the given SMIL fixture filename. + /// + /// The SMIL file is assumed to be at `OEBPS/chapter01.smil` so that + /// relative HREFs like `chapter01.xhtml` resolve against `OEBPS/`. + static func parse(_ name: String) throws -> GuidedNavigationDocument? { + let data = fixtures.data(at: name) + let url = AnyURL(string: "OEBPS/chapter01.smil")! + return try SMILParser.parseGuidedNavigationDocument(smilData: data, at: url) + } + + // MARK: - par parsing + + @Suite("par parsing") struct ParParsing { + @Test func basicParWithTextAndAudio() throws { + let doc = try SMILParserTests.parse("basic.smil") + #expect(doc != nil) + + // First top-level object is the chapter seq + let chapter = doc?.guided.first + // First child of the chapter seq is p1 + let p1 = chapter?.children.first + #expect(p1?.id == "p1") + #expect(p1?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#id_p1")) + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/chapter01.mp3#t=0,5.123")) + #expect(p1?.roles == [.term]) + } + + @Test func parWithImage() throws { + let doc = try SMILParserTests.parse("par-image.smil") + let p1 = doc?.guided.first?.children.first + #expect(p1?.id == "p1") + #expect(p1?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#id1")) + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=0,5")) + #expect(p1?.refs?.img == AnyURL(string: "OEBPS/figure1.jpg")) + } + + @Test func audioWithBothClipTimes() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p1: clipBegin=0:00:00.000, clipEnd=0:00:05.123 + let p1 = doc?.guided.first?.children[0] + #expect(p1?.id == "p1") + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=0,5.123")) + } + + @Test func audioWithOnlyClipBegin() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p2: clipBegin=0:01:30.000 + let p2 = doc?.guided.first?.children[1] + #expect(p2?.id == "p2") + #expect(p2?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=90,")) + } + + @Test func audioWithOnlyClipEnd() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p3: clipEnd=0:00:11.000 + let p3 = doc?.guided.first?.children[2] + #expect(p3?.id == "p3") + #expect(p3?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=,11")) + } + + @Test func audioWithoutClipTimes() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p4: no clip attributes β†’ plain URL + let p4 = doc?.guided.first?.children[3] + #expect(p4?.id == "p4") + #expect(p4?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3")) + } + + @Test func audioClipEndTrailingZerosStripped() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p5: clipEnd=0:00:05.100 β†’ "5.100" formatted, trailing zeros stripped β†’ "5.1" + let p5 = doc?.guided.first?.children[4] + #expect(p5?.id == "p5") + #expect(p5?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=,5.1")) + } + + @Test func audioClipEndPartialTrailingZeroStripped() throws { + let doc = try SMILParserTests.parse("audio-clip-times.smil") + // s1/p6: clipEnd=0:00:05.120 β†’ "5.120" formatted, one trailing zero stripped β†’ "5.12" + let p6 = doc?.guided.first?.children[5] + #expect(p6?.id == "p6") + #expect(p6?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=,5.12")) + } + + @Test func videoWithBothClipTimes() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p1: clipBegin=0:00:00.000, clipEnd=0:00:05.123 + let p1 = doc?.guided.first?.children[0] + #expect(p1?.id == "p1") + #expect(p1?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=0,5.123")) + } + + @Test func videoWithOnlyClipBegin() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p2: clipBegin=0:01:30.000 + let p2 = doc?.guided.first?.children[1] + #expect(p2?.id == "p2") + #expect(p2?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=90,")) + } + + @Test func videoWithOnlyClipEnd() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p3: clipEnd=0:00:11.000 + let p3 = doc?.guided.first?.children[2] + #expect(p3?.id == "p3") + #expect(p3?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=,11")) + } + + @Test func videoWithoutClipTimes() throws { + let doc = try SMILParserTests.parse("video-clip-times.smil") + // s1/p4: no clip attributes β†’ plain URL + let p4 = doc?.guided.first?.children[3] + #expect(p4?.id == "p4") + #expect(p4?.refs?.video == AnyURL(string: "OEBPS/video.mp4")) + } + + @Test func parWithBothAudioAndVideo() throws { + let doc = try SMILParserTests.parse("par-audio-video.smil") + let p1 = doc?.guided.first?.children.first + #expect(p1?.id == "p1") + #expect(p1?.refs?.audio == AnyURL(string: "OEBPS/audio.mp3#t=0,5")) + #expect(p1?.refs?.video == AnyURL(string: "OEBPS/video.mp4#t=0,5")) + } + + @Test func parWithoutTextIsSkipped() throws { + let doc = try SMILParserTests.parse("par-without-text.smil") + let children = doc?.guided.first?.children + // Only the valid par survives β€” the no-text par is dropped. + #expect(children?.count == 1) + #expect(children?.first?.id == "p-valid") + } + } + + // MARK: - seq parsing + + @Suite("seq parsing") struct SeqParsing { + @Test func seqWithNoTypeGetsSequenceRole() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let noType = doc?.guided.first { $0.id == "s-no-type" } + #expect(noType?.roles == [.sequence]) + } + + @Test func seqWithKnownTypeGetsCorrectRole() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let chapter = doc?.guided.first { $0.id == "s-chapter" } + #expect(chapter?.roles == [.sequence, .chapter]) + } + + @Test func seqWithMultipleTypesGetsMultipleRoles() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let multi = doc?.guided.first { $0.id == "s-multi" } + #expect(multi?.roles == [.sequence, .chapter, .part]) + } + + @Test func seqWithTextref() throws { + let doc = try SMILParserTests.parse("seq-textref.smil") + // s1: textref without fragment + let s1 = doc?.guided.first { $0.id == "s1" } + #expect(s1?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml")) + // s2: textref with fragment + let s2 = doc?.guided.first { $0.id == "s2" } + #expect(s2?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#sec1")) + } + + @Test func emptySeqIsSkipped() throws { + let doc = try SMILParserTests.parse("empty-seq.smil") + // The empty seq must be dropped; only s-valid survives. + #expect(doc?.guided.count == 1) + #expect(doc?.guided.first?.id == "s-valid") + } + + @Test func nestedSeq() throws { + let doc = try SMILParserTests.parse("nested.smil") + let s1 = doc?.guided.first + #expect(s1?.roles == [.sequence, .part]) + let s2 = s1?.children.first + #expect(s2?.roles == [.sequence, .chapter]) + let s3 = s2?.children.first + #expect(s3?.roles == [.sequence, .table]) + let p1 = s3?.children.first + #expect(p1?.refs?.text != nil) + } + + @Test func basicSeqChildrenFromBasicFixture() throws { + let doc = try SMILParserTests.parse("basic.smil") + let chapter = doc?.guided.first + #expect(chapter?.id == "s1") + #expect(chapter?.roles == [.sequence, .chapter]) + #expect(chapter?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml")) + // Two children: p1 and s2 + #expect(chapter?.children.count == 2) + let s2 = chapter?.children[1] + #expect(s2?.id == "s2") + #expect(s2?.roles == [.sequence, .table]) + #expect(s2?.refs?.text == AnyURL(string: "OEBPS/chapter01.xhtml#sec1")) + } + } + + // MARK: - epub:type mapping + + @Suite("epub:type mapping") struct EpubTypeMapping { + @Test func pageListSpecialCase() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let pagelist = doc?.guided.first { $0.id == "s-pagelist" } + #expect(pagelist?.roles == [.sequence, .pagelist]) + } + + @Test func listItemSpecialCase() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let listitem = doc?.guided.first { $0.id == "s-listitem" } + #expect(listitem?.roles == [.sequence, .listItem]) + } + + @Test func unknownTypeGetsURIRole() throws { + let doc = try SMILParserTests.parse("seq-types.smil") + let unknown = doc?.guided.first { $0.id == "s-unknown" } + #expect(unknown?.roles == [.sequence, GuidedNavigationObject.Role("http://www.idpf.org/2007/ops/type#preamble")]) + } + } + + // MARK: - document-level + + @Suite("document") struct DocumentParsing { + @Test func bodyChildrenBecomeTopLevelGuided() throws { + let doc = try SMILParserTests.parse("basic.smil") + // basic.smil has one top-level seq in the body + #expect(doc?.guided.count == 1) + } + + @Test func returnsNilForNonSMILXML() throws { + // Fuzi parses leniently, so malformed/non-SMIL content produces no + // and the parser returns nil rather than throwing. + let badData = Data("not xml at all".utf8) + let url = try #require(AnyURL(string: "OEBPS/chapter01.smil")) + let doc = try SMILParser.parseGuidedNavigationDocument(smilData: badData, at: url) + #expect(doc == nil) + } + + @Test func returnsNilForEmptyBody() throws { + let xml = """ + + + + + """.data(using: .utf8)! + let url = try #require(AnyURL(string: "OEBPS/chapter01.smil")) + let doc = try SMILParser.parseGuidedNavigationDocument(smilData: xml, at: url) + #expect(doc == nil) + } + } + + /// https://www.w3.org/TR/SMIL/smil-timing.html#Timing-ClockValueSyntax + @Suite("parseClockValue") struct ParseClockValue { + @Suite("full clock: hh:mm:ss[.fraction]") struct FullClock { + @Test func basic() { + #expect(SMILParser.parseClockValue("1:32:29") == 5549.0) + } + + @Test func zero() { + #expect(SMILParser.parseClockValue("0:00:00") == 0.0) + } + + @Test func fractionalSeconds() { + #expect(SMILParser.parseClockValue("0:01:30.5") == 90.5) + } + + @Test func largeHours() { + #expect(SMILParser.parseClockValue("100:00:00") == 360_000.0) + } + } + + @Suite("partial clock: mm:ss[.fraction]") struct PartialClock { + @Test func basic() { + #expect(SMILParser.parseClockValue("23:45") == 1425.0) + } + + @Test func zero() { + #expect(SMILParser.parseClockValue("0:00") == 0.0) + } + + @Test func singleDigitMinutes() { + #expect(SMILParser.parseClockValue("8:44") == 524.0) + } + + @Test func fractionalSeconds() { + #expect(SMILParser.parseClockValue("0:30.5") == 30.5) + } + } + + @Suite("timecount values") struct Timecount { + @Test func hours() { + #expect(SMILParser.parseClockValue("2h") == 7200.0) + } + + @Test func fractionalHours() { + #expect(SMILParser.parseClockValue("1.5h") == 5400.0) + } + + @Test func minutes() { + #expect(SMILParser.parseClockValue("30min") == 1800.0) + } + + @Test func fractionalMinutes() { + #expect(SMILParser.parseClockValue("0.5min") == 30.0) + } + + @Test func seconds() { + #expect(SMILParser.parseClockValue("45s") == 45.0) + } + + @Test func fractionalSeconds() { + #expect(SMILParser.parseClockValue("2.5s") == 2.5) + } + + @Test func milliseconds() { + #expect(SMILParser.parseClockValue("500ms") == 0.5) + } + + @Test("milliseconds suffix takes priority over seconds suffix") + func millisecondsPriority() { + #expect(SMILParser.parseClockValue("1000ms") == 1.0) + } + + @Test func plainNumber() { + #expect(SMILParser.parseClockValue("120") == 120.0) + } + + @Test func plainFractionalNumber() { + #expect(SMILParser.parseClockValue("1.5") == 1.5) + } + } + + @Suite("whitespace handling") struct Whitespace { + @Test func leadingAndTrailingSpaces() { + #expect(SMILParser.parseClockValue(" 30s ") == 30.0) + } + + @Test func leadingAndTrailingSpacesOnClock() { + #expect(SMILParser.parseClockValue(" 1:30 ") == 90.0) + } + } + + @Suite("invalid input returns nil") struct Invalid { + @Test func empty() { + #expect(SMILParser.parseClockValue("") == nil) + } + + @Test func whitespaceOnly() { + #expect(SMILParser.parseClockValue(" ") == nil) + } + + @Test func letters() { + #expect(SMILParser.parseClockValue("abc") == nil) + } + + @Test func unknownSuffix() { + #expect(SMILParser.parseClockValue("1m") == nil) + } + + @Test func nonNumericHours() { + #expect(SMILParser.parseClockValue("x:00:00") == nil) + } + + @Test func nonNumericMinutes() { + #expect(SMILParser.parseClockValue("1:xx:00") == nil) + } + + @Test func nonNumericSeconds() { + #expect(SMILParser.parseClockValue("1:00:xx") == nil) + } + } + } +} diff --git a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift index 9f81852dde..e8deab160e 100644 --- a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -357,7 +357,9 @@ private class MockContainer: Container { let sourceURL: AbsoluteURL? = nil - var entries: Set { Set(readingOrder.map { $0.1.url() }) } + var entries: Set { + Set(readingOrder.map { $0.1.url() }) + } subscript(url: any URLConvertible) -> (any Resource)? { guard let (length, _, archiveProperties) = readingOrder.first(where: { _, link, _ in link.url().isEquivalentTo(url) }) else { diff --git a/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift b/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift new file mode 100644 index 0000000000..a30421149f --- /dev/null +++ b/Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift @@ -0,0 +1,575 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +@testable import ReadiumStreamer +import XCTest + +class ComicInfoParserTests: XCTestCase { + // MARK: - Basic Parsing + + func testParseMinimalComicInfo() throws { + let xml = """ + + + Test Issue + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.title, "Test Issue") + } + + func testParseCompleteComicInfo() throws { + let xml = """ + + + The Beginning + Batman + 1 +

The Dark Knight returns... + 2020 + 3 + 15 + Frank Miller, Bob Kane + Jim Lee + Scott Williams + Alex Sinclair + Richard Starkings + Jim Lee + Bob Harras + John Doe + DC Comics + Vertigo + Superhero, Action + en + 978-1234567890 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.title, "The Beginning") + XCTAssertEqual(result?.series, "Batman") + XCTAssertEqual(result?.number, "1") + XCTAssertEqual(result?.summary, "The Dark Knight returns...") + XCTAssertEqual(result?.year, 2020) + XCTAssertEqual(result?.month, 3) + XCTAssertEqual(result?.day, 15) + XCTAssertEqual(result?.writers, ["Frank Miller", "Bob Kane"]) + XCTAssertEqual(result?.pencillers, ["Jim Lee"]) + XCTAssertEqual(result?.inkers, ["Scott Williams"]) + XCTAssertEqual(result?.colorists, ["Alex Sinclair"]) + XCTAssertEqual(result?.letterers, ["Richard Starkings"]) + XCTAssertEqual(result?.coverArtists, ["Jim Lee"]) + XCTAssertEqual(result?.editors, ["Bob Harras"]) + XCTAssertEqual(result?.translators, ["John Doe"]) + XCTAssertEqual(result?.publisher, "DC Comics") + XCTAssertEqual(result?.imprint, "Vertigo") + XCTAssertEqual(result?.genres, ["Superhero", "Action"]) + XCTAssertEqual(result?.languageISO, "en") + XCTAssertEqual(result?.gtin, "978-1234567890") + } + + func testParseReturnsNilForInvalidXML() throws { + let xml = "not valid xml" + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNil(result) + } + + func testParseReturnsNilForWrongRootElement() throws { + let xml = """ + + + Test + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNil(result) + } + + // MARK: - Other Metadata + + func testOtherMetadataCollectsUnknownTags() throws { + let xml = """ + + + Test + 2 + Batman, Robin + Teen + Custom Value + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.otherMetadata["Volume"], "2") + XCTAssertEqual(result?.otherMetadata["Characters"], "Batman, Robin") + XCTAssertEqual(result?.otherMetadata["AgeRating"], "Teen") + XCTAssertEqual(result?.otherMetadata["CustomTag"], "Custom Value") + } + + // MARK: - Cover Page Detection + + func testFirstPageWithTypeFrontCover() throws { + let xml = """ + + + Test + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 1) + } + + func testFirstPageWithTypeReturnsNilWhenNoCover() throws { + let xml = """ + + + Test + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNil(result?.firstPageWithType(.frontCover)) + } + + func testFirstPageWithTypeReturnsNilWhenNoPagesElement() throws { + let xml = """ + + + Test + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNil(result?.firstPageWithType(.frontCover)) + } + + // MARK: - PageType Parsing + + func testPageTypeCaseInsensitiveParsing() { + XCTAssertEqual(ComicInfo.PageType(rawValue: "FrontCover"), .frontCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "frontcover"), .frontCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "FRONTCOVER"), .frontCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Story"), .story) + XCTAssertEqual(ComicInfo.PageType(rawValue: "BackCover"), .backCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "InnerCover"), .innerCover) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Roundup"), .roundup) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Advertisement"), .advertisement) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Editorial"), .editorial) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Letters"), .letters) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Preview"), .preview) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Other"), .other) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Deleted"), .deleted) + XCTAssertEqual(ComicInfo.PageType(rawValue: "Delete"), .deleted) + } + + func testPageTypeReturnsNilForUnknownValue() { + XCTAssertNil(ComicInfo.PageType(rawValue: "UnknownType")) + XCTAssertNil(ComicInfo.PageType(rawValue: "")) + } + + // MARK: - PageInfo Parsing + + func testPageInfoParsesAllAttributes() throws { + let xml = """ + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.pages.count, 1) + let page = result?.pages.first + XCTAssertEqual(page?.image, 0) + XCTAssertEqual(page?.type, .frontCover) + XCTAssertEqual(page?.doublePage, false) + XCTAssertEqual(page?.imageSize, 150_202) + XCTAssertEqual(page?.key, "cover") + XCTAssertEqual(page?.bookmark, "Cover") + XCTAssertEqual(page?.imageWidth, 800) + XCTAssertEqual(page?.imageHeight, 1200) + } + + func testPageInfoRequiresImageAttribute() throws { + let xml = """ + + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + // Only the page with Image attribute should be parsed + XCTAssertEqual(result?.pages.count, 1) + XCTAssertEqual(result?.pages.first?.image, 1) + } + + func testPageInfoWithMinimalAttributes() throws { + let xml = """ + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.pages.count, 1) + let page = result?.pages.first + XCTAssertEqual(page?.image, 0) + XCTAssertNil(page?.type) + XCTAssertNil(page?.doublePage) + XCTAssertNil(page?.imageSize) + XCTAssertNil(page?.key) + XCTAssertNil(page?.bookmark) + XCTAssertNil(page?.imageWidth) + XCTAssertNil(page?.imageHeight) + } + + func testPageInfoDoublePageBooleanParsing() throws { + let xml = """ + + + + + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.pages.count, 5) + XCTAssertEqual(result?.pages[0].doublePage, true) + XCTAssertEqual(result?.pages[1].doublePage, true) + XCTAssertEqual(result?.pages[2].doublePage, true) + XCTAssertEqual(result?.pages[3].doublePage, false) + XCTAssertEqual(result?.pages[4].doublePage, false) + } + + // MARK: - Story Start Detection + + func testFirstPageWithTypeStory() throws { + let xml = """ + + + + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 0) + XCTAssertEqual(result?.firstPageWithType(.story)?.image, 2) + } + + func testFirstPageWithTypeStoryReturnsNilWhenNoStoryPages() throws { + let xml = """ + + + + + + + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertEqual(result?.firstPageWithType(.frontCover)?.image, 0) + XCTAssertNil(result?.firstPageWithType(.story)) + } + + func testFirstPageWithTypeStoryReturnsNilWhenNoPagesElement() throws { + let xml = """ + + + Test + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + + XCTAssertNil(result?.firstPageWithType(.story)) + } + + // MARK: - Metadata Conversion + + func testToMetadataWithSeriesAndNumber() throws { + let xml = """ + + + Issue 5 + Amazing Comics + 5 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.title, "Issue 5") + XCTAssertEqual(metadata?.belongsToSeries.count, 1) + XCTAssertEqual(metadata?.belongsToSeries.first?.name, "Amazing Comics") + XCTAssertEqual(metadata?.belongsToSeries.first?.position, 5.0) + } + + func testToMetadataWithAlternateSeries() throws { + let xml = """ + + + Crossover Issue + Batman + 10 + Justice League + 3 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.belongsToSeries.count, 2) + XCTAssertEqual(metadata?.belongsToSeries[0].name, "Batman") + XCTAssertEqual(metadata?.belongsToSeries[0].position, 10.0) + XCTAssertEqual(metadata?.belongsToSeries[1].name, "Justice League") + XCTAssertEqual(metadata?.belongsToSeries[1].position, 3.0) + } + + func testToMetadataWithFractionalNumber() throws { + let xml = """ + + + Issue 5.5 + Amazing Comics + 5.5 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.belongsToSeries.first?.position, 5.5) + } + + func testToMetadataWithNonNumericNumber() throws { + let xml = """ + + + Annual Issue + Amazing Comics + Annual 1 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + // Non-numeric number should result in nil position + XCTAssertNil(metadata?.belongsToSeries.first?.position) + } + + func testToMetadataMangaYesAndRightToLeftSetsRTL() throws { + let xml = """ + + + Manga + YesAndRightToLeft + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .rtl) + } + + func testToMetadataMangaYesDoesNotSetRTL() throws { + let xml = """ + + + Manga + Yes + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .auto) + } + + func testToMetadataMangaNoSetsAuto() throws { + let xml = """ + + + Comic + No + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .auto) + } + + func testToMetadataMangaCaseInsensitiveParsing() throws { + let xml = """ + + + Manga + YESANDRIGHTTOLEFT + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.readingProgression, .rtl) + } + + func testToMetadataContributors() throws { + let xml = """ + + + Test + Frank Miller, Bob Kane + Jim Lee + Alex Ross + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.authors.count, 2) + XCTAssertEqual(metadata?.authors.map(\.name), ["Frank Miller", "Bob Kane"]) + XCTAssertEqual(metadata?.pencilers.count, 1) + XCTAssertEqual(metadata?.pencilers.first?.name, "Jim Lee") + XCTAssertEqual(metadata?.contributors.count, 1) + XCTAssertEqual(metadata?.contributors.first?.name, "Alex Ross") + XCTAssertEqual(metadata?.contributors.first?.roles, ["cov"]) + } + + func testToMetadataSubjects() throws { + let xml = """ + + + Test + Superhero, Action, Adventure + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.subjects.count, 3) + XCTAssertEqual(metadata?.subjects.map(\.name), ["Superhero", "Action", "Adventure"]) + } + + func testToMetadataPublishedDate() throws { + let xml = """ + + + Test + 2020 + 6 + 15 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + let calendar = Calendar(identifier: .gregorian) + let components = try calendar.dateComponents([.year, .month, .day], from: XCTUnwrap(metadata?.published)) + + XCTAssertEqual(components.year, 2020) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 15) + } + + func testToMetadataPublishedDateYearOnly() throws { + let xml = """ + + + Test + 2020 + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + let calendar = Calendar(identifier: .gregorian) + let components = try calendar.dateComponents([.year, .month, .day], from: XCTUnwrap(metadata?.published)) + + XCTAssertEqual(components.year, 2020) + XCTAssertEqual(components.month, 1) // Default to January + XCTAssertEqual(components.day, 1) // Default to 1st + } + + func testToMetadataOtherMetadata() throws { + let xml = """ + + + Test + 2 + Batman, Robin + Teen + + """ + + let result = try ComicInfoParser.parse(data: XCTUnwrap(xml.data(using: .utf8)), warnings: nil) + let metadata = result?.toMetadata() + + XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#volume"] as? String, "2") + XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#characters"] as? String, "Batman, Robin") + XCTAssertEqual(metadata?.otherMetadata["https://anansi-project.github.io/docs/comicinfo/documentation#agerating"] as? String, "Teen") + } +} diff --git a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index f889fbf9e1..cac26af9b2 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // @@ -13,6 +13,7 @@ class ImageParserTests: XCTestCase { var parser: ImageParser! var cbzAsset: Asset! + var cbzWithComicInfoAsset: Asset! var jpgAsset: Asset! override func setUp() async throws { @@ -23,6 +24,11 @@ class ImageParserTests: XCTestCase { format: Format(specifications: .zip, .informalComic, mediaType: .cbz, fileExtension: "cbz") ).get()) + cbzWithComicInfoAsset = try await .container(ZIPArchiveOpener().open( + resource: FileResource(file: fixtures.url(for: "test-comicinfo.cbz")), + format: Format(specifications: .zip, .informalComic, mediaType: .cbz, fileExtension: "cbz") + ).get()) + jpgAsset = .resource(ResourceAsset( resource: FileResource(file: fixtures.url(for: "futuristic_tales/Cory Doctorow's Futuristic Tales of the Here and Now/a-fc.jpg")), format: Format(specifications: .jpeg, mediaType: .jpeg, fileExtension: "jpeg") @@ -74,24 +80,21 @@ class ImageParserTests: XCTestCase { ]) } - func testFirstReadingOrderItemIsCover() async throws { + /// When no ComicInfo.xml declares a cover, no `cover` rel should be set. + /// The cover will be determined at runtime with the default + /// `ResourceCoverService`. + func testNoCoverRelWhenNoExplicitCover() async throws { let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() - let cover = try XCTUnwrap(publication.linkWithRel(.cover)) - XCTAssertEqual(publication.readingOrder.first, cover) - } - - func testComputeTitleFromArchiveRootDirectory() async throws { - let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() - XCTAssertEqual(publication.metadata.title, "Cory Doctorow's Futuristic Tales of the Here and Now") + XCTAssertNil(publication.linkWithRel(.cover)) } func testPositions() async throws { let publication = try await parser.parse(asset: cbzAsset, warnings: nil).get().build() let result = try await publication.positions().get() - XCTAssertEqual(result, [ + XCTAssertEqual(result, try [ Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/a-fc.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/a-fc.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 0, @@ -99,7 +102,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-002.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-002.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 1 / 5.0, @@ -107,7 +110,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-003.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-003.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 2 / 5.0, @@ -115,7 +118,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-153.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/x-153.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 3 / 5.0, @@ -123,7 +126,7 @@ class ImageParserTests: XCTestCase { ) ), Locator( - href: AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/z-bc.jpg")!, + href: XCTUnwrap(AnyURL(string: "Cory%20Doctorow's%20Futuristic%20Tales%20of%20the%20Here%20and%20Now/z-bc.jpg")), mediaType: .jpeg, locations: .init( totalProgression: 4 / 5.0, @@ -132,4 +135,39 @@ class ImageParserTests: XCTestCase { ), ]) } + + func testParsesMetadataFromComicInfo() async throws { + let publication = try await parser.parse(asset: cbzWithComicInfoAsset, warnings: nil).get().build() + + XCTAssertEqual(publication.metadata.conformsTo, [.divina]) + XCTAssertEqual(publication.metadata.title, "Test Comic Issue") + XCTAssertEqual(publication.metadata.publishers.map(\.name), ["Test Publisher"]) + XCTAssertEqual(publication.metadata.languages, ["en"]) + XCTAssertEqual(publication.metadata.description, "A test comic for unit testing.") + XCTAssertEqual(publication.metadata.subjects.map(\.name), ["Action", "Adventure"]) + XCTAssertEqual(publication.metadata.belongsToSeries.count, 1) + XCTAssertEqual(publication.metadata.belongsToSeries.first?.name, "Test Series") + XCTAssertEqual(publication.metadata.belongsToSeries.first?.position, 5.0) + XCTAssertEqual(publication.metadata.authors.map(\.name), ["Test Writer"]) + XCTAssertEqual(publication.metadata.pencilers.map(\.name), ["Test Artist"]) + + let coverLink = publication.linkWithRel(.cover) + XCTAssertNotNil(coverLink) + XCTAssertEqual(coverLink?.href, "TestComic/page-01.png") + + // Story starts at page-02 (index 1), which is different from cover (index 0) + let startLink = publication.linkWithRel(.start) + XCTAssertNotNil(startLink) + XCTAssertEqual(startLink?.href, "TestComic/page-02.png") + } + + func testDoublePageSpreadSetsCenterPage() async throws { + let publication = try await parser.parse(asset: cbzWithComicInfoAsset, warnings: nil).get().build() + + XCTAssertNil(publication.readingOrder[0].properties.page) + XCTAssertNil(publication.readingOrder[1].properties.page) + + // Page 2 has DoublePage="True" in ComicInfo.xml, should have center page + XCTAssertEqual(publication.readingOrder[2].properties.page, .center) + } } diff --git a/Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift b/Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift new file mode 100644 index 0000000000..a583ea03ae --- /dev/null +++ b/Tests/StreamerTests/Parser/Readium/ReadiumGuidedNavigationServiceTests.swift @@ -0,0 +1,108 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared +@testable import ReadiumStreamer +import Testing + +@Suite class ReadiumGuidedNavigationServiceTests { + /// Per-resource GN alternate link. + lazy var gnLink = Link( + href: "guided.json", + mediaType: .readiumGuidedNavigationDocument + ) + + /// Reading order link with a per-resource GN alternate. + lazy var linkWithGN = Link( + href: "chapter01.xhtml", + mediaType: .html, + alternates: [gnLink] + ) + + /// Reading order link without any GN alternate. + lazy var linkWithoutGN = Link( + href: "chapter02.xhtml", + mediaType: .html + ) + + func makeService( + readingOrder: [Link], + guided: [[String: Any]]? = nil + ) -> ReadiumGuidedNavigationService { + let manifest = Manifest( + metadata: Metadata(title: "Test"), + readingOrder: readingOrder + ) + + let container: Container + if let guided = guided { + let json: [String: Any] = ["guided": guided] + let gnd = try! JSONSerialization.data(withJSONObject: json) + container = SingleResourceContainer( + resource: DataResource(data: gnd), + at: gnLink.url() + ) + } else { + container = EmptyContainer() + } + + return ReadiumGuidedNavigationService(manifest: manifest, container: container) + } + + // MARK: - hasGuidedNavigation(for:) + + @Test func hasGuidedNavigationFalseForLinkWithoutGN() { + let service = makeService(readingOrder: [linkWithoutGN]) + #expect(service.hasGuidedNavigation(for: linkWithoutGN) == false) + } + + @Test func hasGuidedNavigationTrueForLinkWithGNAlternate() { + let service = makeService(readingOrder: [linkWithGN]) + #expect(service.hasGuidedNavigation(for: linkWithGN) == true) + } + + @Test func hasGuidedNavigationPublicationLevelTrueWhenAnyLinkHasGN() { + let service = makeService(readingOrder: [linkWithoutGN, linkWithGN]) + #expect(service.hasGuidedNavigation == true) + } + + @Test func hasGuidedNavigationPublicationLevelFalseWhenNoLinkHasGN() { + let service = makeService(readingOrder: [linkWithoutGN]) + #expect(service.hasGuidedNavigation == false) + } + + // MARK: - guidedNavigationDocument(for:) β€” per-resource + + @Test func returnsNilForLinkWithoutGN() async throws { + let service = makeService(readingOrder: [linkWithoutGN]) + let result = await service.guidedNavigationDocument(for: linkWithoutGN) + #expect(try result.get() == nil) + } + + @Test func returnsFailureWhenGNDocumentMissingFromContainer() async { + let service = makeService(readingOrder: [linkWithGN]) + let result = await service.guidedNavigationDocument(for: linkWithGN) + #expect { + try result.get() + } throws: { error in + guard let readError = error as? ReadError, case .decoding = readError else { return false } + return true + } + } + + @Test func returnsDocumentFromPerResourceAlternate() async throws { + let guided: [[String: Any]] = [ + ["textref": "chapter01.xhtml#s1"], + ] + let service = makeService(readingOrder: [linkWithGN], guided: guided) + + let doc = try await service.guidedNavigationDocument(for: linkWithGN).get() + #expect(try doc == GuidedNavigationDocument(guided: [ + #require(GuidedNavigationObject(refs: .init(text: AnyURL(string: "chapter01.xhtml#s1")))), + ])) + } +} diff --git a/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift b/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift index ac391efebf..4f2424e447 100644 --- a/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift +++ b/Tests/StreamerTests/Parser/Readium/ReadiumWebPubParserTests.swift @@ -1,5 +1,5 @@ // -// Copyright 2025 Readium Foundation. All rights reserved. +// Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license // available in the top-level LICENSE file of the project. // diff --git a/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift b/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift deleted file mode 100644 index c88bc89879..0000000000 --- a/Tests/StreamerTests/Toolkit/Extensions/ContainerTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Copyright 2025 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import ReadiumShared -@testable import ReadiumStreamer -import XCTest - -class ContainerTests: XCTestCase { - func testGuessTitleWithoutDirectories() { - let container = TestContainer(hrefs: ["a.txt", "b.png"]) - XCTAssertNil(container.guessTitle()) - } - - func testGuessTitleWithOneRootDirectory() { - let container = TestContainer(hrefs: ["Root%20Directory/b.png", "Root%20Directory/dir/c.png"]) - XCTAssertEqual(container.guessTitle(), "Root Directory") - } - - func testGuessTitleWithOneRootDirectoryButRootFiles() { - let container = TestContainer(hrefs: ["a.txt", "Root%20Directory/b.png", "Root%20Directory/dir/c.png"]) - XCTAssertNil(container.guessTitle()) - } - - func testGuessTitleWithOneRootDirectoryIgnoringRootFile() { - let container = TestContainer(hrefs: [".hidden", "Root%20Directory/b.png", "Root%20Directory/dir/c.png"]) - XCTAssertEqual(container.guessTitle(ignoring: { url in url.lastPathSegment == ".hidden" }), "Root Directory") - } - - func testGuessTitleWithSeveralDirectories() { - let container = TestContainer(hrefs: ["a.txt", "dir1/b.png", "dir2/c.png"]) - XCTAssertNil(container.guessTitle()) - } - - func testGuessTitleIgnoresSingleFiles() { - let container = TestContainer(hrefs: ["single"]) - XCTAssertNil(container.guessTitle()) - } -} - -private struct TestContainer: Container { - init(hrefs: [String]) { - entries = Set(hrefs.map { AnyURL(string: $0)! }) - } - - let entries: Set - - let sourceURL: (any AbsoluteURL)? = nil - - subscript(url: any URLConvertible) -> (any Resource)? { nil } -} diff --git a/docs/Guides/Content.md b/docs/Guides/Content.md index ad66cbbb6c..92bb1a2d98 100644 --- a/docs/Guides/Content.md +++ b/docs/Guides/Content.md @@ -37,7 +37,7 @@ This is an expensive operation, proceed with caution and cache the result if you The individual `Content` elements can be iterated through with a regular `for` loop by converting it to a sequence: ```swift -for (element in content.sequence()) { +for element in content.sequence() { // Process element } ``` diff --git a/docs/Guides/EPUB Fonts.md b/docs/Guides/EPUB Fonts.md deleted file mode 100644 index e61d78a665..0000000000 --- a/docs/Guides/EPUB Fonts.md +++ /dev/null @@ -1,120 +0,0 @@ -# Font families in the EPUB navigator - -Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Navigator%20Preferences.md). - -> [!NOTE] -> You cannot change the default font family of a fixed-layout EPUB (with zoomable pages), as it is similar to a PDF or a comic book. - -## Available font families - -iOS ships with a large collection of font families that you can use directly in the EPUB preferences. [Take a look at the Apple catalog of System Fonts](https://developer.apple.com/fonts/system-fonts/). - -To improve readability, Readium embeds three additional font families designed for accessibility: - -* [OpenDyslexic](https://opendyslexic.org/) -* [AccessibleDfA](https://github.com/Orange-OpenSource/font-accessible-dfa), by Orange -* [iA Writer Duospace](https://github.com/iaolo/iA-Fonts/tree/master/iA%20Writer%20Duospace), by iA - -You can use all the iOS System Fonts out of the box with the EPUB navigator: - -```swift -epubNavigator.submitPreferences(EPUBPreferences( - fontFamily: "Palatino" -)) -``` - -Alternatively, extend `FontFamily` to benefit from the compiler type safety: - -```swift -extension FontFamily { - public static let palatino: FontFamily = "Palatino" -} - -epubNavigator.submitPreferences(EPUBPreferences( - fontFamily: .palatino -)) -``` - -For your convenience, a number of [recommended fonts](https://readium.org/readium-css/docs/CSS09-default_fonts) are pre-declared in the `FontFamily` type: Iowan Old Style, Palatino, Athelas, Georgia, Helvetica Neue, Seravek and Arial. - -## Setting the available font families in the user interface - -If you build your settings user interface with the EPUB Preferences Editor, you can customize the list of available font families using `with(supportedValues:)`. - -```swift -epubPreferencesEditor.fontFamily.with(supportedValues: [ - nil, // A `nil` value means that the original author font will be used. - .palatino, - .helveticaNeue, - .iaWriterDuospace, - .accessibleDfA, - .openDyslexic -]) -``` - -## How to add custom font families? - -To offer more choices to your users, you must embed and declare custom font families. Use the following steps: - -1. Get the font files in the desired format, such as .ttf and .otf. [Google Fonts](https://fonts.google.com/) is a good source of free fonts. -2. Add the files to your app target from Xcode. -3. Declare new extensions for your custom font families to make them first-class citizens. This is optional but convenient. - ```swift - extension FontFamily { - public static let literata: FontFamily = "Literata" - public static let atkinsonHyperlegible: FontFamily = "Atkinson Hyperlegible" - } - ``` -4. Configure the EPUB navigator with a declaration of the font faces for all the additional font families. - ```swift - let resources = Bundle.main.resourceURL! - let navigator = try EPUBNavigatorViewController( - publication: publication, - initialLocation: locator, - config: .init( - fontFamilyDeclarations: [ - CSSFontFamilyDeclaration( - fontFamily: .literata, - fontFaces: [ - // Literata is a variable font family, so we can provide a font weight range. - // https://fonts.google.com/knowledge/glossary/variable_fonts - CSSFontFace( - file: resources.appendingPathComponent("Literata-VariableFont_opsz,wght.ttf"), - style: .normal, weight: .variable(200...900) - ), - CSSFontFace( - file: resources.appendingPathComponent("Literata-Italic-VariableFont_opsz,wght.ttf"), - style: .italic, weight: .variable(200...900) - ) - ] - ).eraseToAnyHTMLFontFamilyDeclaration(), - - CSSFontFamilyDeclaration( - fontFamily: .atkinsonHyperlegible, - fontFaces: [ - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Regular.ttf"), - style: .normal, weight: .standard(.normal) - ), - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Italic.ttf"), - style: .italic, weight: .standard(.normal) - ), - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Bold.ttf"), - style: .normal, weight: .standard(.bold) - ), - CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-BoldItalic.ttf"), - style: .italic, weight: .standard(.bold) - ), - ] - ).eraseToAnyHTMLFontFamilyDeclaration() - ] - ), - httpServer: GCDHTTPServer.shared - ) - ``` - -You are now ready to use your custom font families. - diff --git a/docs/Guides/Getting Started.md b/docs/Guides/Getting Started.md index e61d58a1d7..fe0998de1a 100644 --- a/docs/Guides/Getting Started.md +++ b/docs/Guides/Getting Started.md @@ -28,8 +28,7 @@ The toolkit has been designed following these core tenets: ### Adapters to third-party dependencies -* `ReadiumAdapterGCDWebServer` provides an HTTP server built with [GCDWebServer](https://github.com/swisspol/GCDWebServer). -* `ReadiumAdapterLCPSQLite` provides implementations of the `ReadiumLCP` license and passphrase repositories using [SQLite.swift](https://github.com/stephencelis/SQLite.swift). +* `ReadiumAdapterGCDWebServer` provides an HTTP server built with [GCDWebServer](https://github.com/swisspol/GCDWebServer), used by the PDF navigator. ## Overview of the shared models (`ReadiumShared`) @@ -113,7 +112,7 @@ let assetRetriever = AssetRetriever( httpClient: httpClient ) let publicationOpener = PublicationOpener( - publicationParser: DefaultPublicationParser( + parser: DefaultPublicationParser( httpClient: httpClient, assetRetriever: assetRetriever, pdfFactory: DefaultPDFDocumentFactory() diff --git a/docs/Guides/Navigator/Decorations.md b/docs/Guides/Navigator/Decorations.md new file mode 100644 index 0000000000..57a24afaa3 --- /dev/null +++ b/docs/Guides/Navigator/Decorations.md @@ -0,0 +1,255 @@ +# Decorations + +The Decoration API lets you overlay visual elements on publication content – highlights, search result markers, TTS playback indicators, page-number labels in the margin, and more. For the common case of implementing user highlights, see the [Highlighting guide](Highlights.md). + +> [!NOTE] +> Only `EPUBNavigatorViewController` implements `DecorableNavigator` today. Always check if a navigator implements `DecorableNavigator` before enabling decoration-dependent features to future-proof your code. + +## Overview + +The Decoration API is built around a small set of types that work together. + +### Decoration + +A `Decoration` is a single UI element overlaid on publication content. It pairs a **location** (`Locator`) with a **style** (`Decoration.Style`) and carries a stable `id` used to track changes across updates. + +A single logical entity can map to multiple Decoration objects. For example, a user annotation might use one decoration for the highlight and a second for a margin icon at the same location. + +### Decoration Style + +A `Decoration.Style` describes the *abstract appearance* of a decoration β€” for example, a semi-transparent highlight or an underline β€” independently of the underlying media type or rendering engine. The toolkit ships two built-in styles (`highlight` and `underline`) and lets you define your own via `Decoration.Style.Id`. + +#### Decoration Template (EPUB) + +For EPUB, each `Decoration.Style.Id` maps to an `HTMLDecorationTemplate` that translates the abstract style into concrete HTML/CSS injected into the page. + +### Decoration Group + +Decorations are organised into **named groups**, one per logical app feature (e.g. `highlights`, `search`, `tts`, `page-list`, etc.). Each call to `apply(decorations:in:)` declares the complete desired state of one group; groups are fully independent. The navigator diffs the new list against the previous one internally and pushes only the necessary changes to the rendered content, so you can call `apply` on every state change without worrying about performance. + +## Guides + +### Creating a Decoration + +A `Decoration` pairs a location in the publication with a style to render. The `id` must be unique within the group and should match your model's primary keyβ€”this is what lets you look up the underlying record when the user taps the decoration later. + +```swift +let decoration = Decoration( + id: highlight.id, + locator: highlight.locator, + style: .highlight(tint: highlight.color) +) +``` + +### Applying Decorations to the Navigator + +Rather than telling the navigator about individual additions or removals, you declare the complete desired state of a group and let the navigator figure out what changed. Observe your models, map each one to a `Decoration`, then apply the full list: + +```swift +let decorations = highlights.map { highlight in + Decoration( + id: highlight.id, + locator: highlight.locator, + style: .highlight(tint: highlight.color) + ) +} + +navigator.apply(decorations: decorations, in: "highlights") +``` + +The navigator diffs the new list against the previous one and pushes only the actual changes to the rendered content. This means you can call `apply` freely on every state change β€” after an add, a color update, or a delete β€” without worrying about redundant work. + +To clear a group entirely, apply an empty array: + +```swift +navigator.apply(decorations: [], in: "highlights") +``` + +### Handling User Interactions on Decorations + +Register a callback with `observeDecorationInteractions(inGroup:onActivated:)` to be notified when the user taps a decoration. The event carries the decoration itself, its group name, and the tap location in navigator view coordinates: + +```swift +navigator.observeDecorationInteractions(inGroup: "highlights") { event in + +} +``` + +**`event.decoration.id`** matches the id you set when creating the Decoration, so you can retrieve the corresponding model from your database. + +**`event.rect`** gives the bounding box of the tapped decoration in the navigator view, useful for anchoring a popover. + +### Checking Whether a Navigator Supports a Decoration Style + +Not every navigator can render every style β€” underlining a sentence in an audiobook, for example, makes no sense. Before enabling a feature that depends on a specific style, verify that the navigator supports it: + +```swift +if navigator.supports(decorationStyle: .underline) { + // Offer underlining in the UI +} +``` + +For `EPUBNavigatorViewController`, this returns `true` for any style ID present in `Configuration.decorationTemplates`. + +### Creating a Custom Decoration Style + +The following example shows how to add a page-number label in the left margin for each entry in `publication.pageList` (declared print page markers). + +#### 1. Declare a custom `Decoration.Style.Id` + +```swift +extension Decoration.Style.Id { + static let pageList: Decoration.Style.Id = "page-list" +} +``` + +#### 2. Define a config struct + +The config carries the data your template needs. It must be `Hashable` so the diffing engine can detect changes. + +```swift +struct PageListConfig: Hashable { + /// Page number label from publication.pageList[].title + var label: String +} +``` + +#### 3. Write the `HTMLDecorationTemplate` + +```swift +extension HTMLDecorationTemplate { + static var pageList: HTMLDecorationTemplate { + let className = "app-page-number" + + return HTMLDecorationTemplate( + // One rectangle for the whole range + layout: .bounds, + // Span the full page so the label can float left + width: .page, + element: { decoration in + let config = decoration.style.config as? PageListConfig + + // var(--RS__backgroundColor) matches the Readium CSS theme background. + // Setting it inline prevents it being forced transparent by Readium CSS. + return """ +
+ + \(config?.label ?? "") + +
+ """ + }, + stylesheet: """ + .\(className) { + float: left; + margin-left: 4px; + padding: 0px 2px 0px 2px; + border: 1px solid; + border-radius: 10%; + box-shadow: rgba(50, 50, 93, 0.25) 0px 2px 5px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px; + opacity: 0.8; + } + """ + ) + } +} +``` + +#### 4. Register it in `Configuration.decorationTemplates` + +```swift +var templates = HTMLDecorationTemplate.defaultTemplates() +templates[.pageList] = .pageList + +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + config: EPUBNavigatorViewController.Configuration( + decorationTemplates: templates + ) +) +``` + +#### 5. Build and apply decorations + +```swift +private func updatePageListDecorations() async { + guard let navigator = navigator as? DecorableNavigator else { return } + + var decorations: [Decoration] = [] + for (index, link) in publication.pageList.enumerated() { + guard + let title = link.title, + let locator = await publication.locate(link) + else { + continue + } + + decorations.append(Decoration( + id: "page-list-\(index)", + locator: locator, + style: Decoration.Style( + id: .pageList, + config: PageListConfig(label: title) + ) + )) + } + + navigator.apply(decorations: decorations, in: "page-list") +} +``` + +### Common Patterns + +#### Search Results + +Apply a temporary `"search"` group when the user performs a search. Use index-based IDs and `isActive` to highlight the currently selected result. Clear the group when the search is dismissed. + +```swift +let navigator: DecorableNavigator + +func applySearchDecorations(selectedIndex: Int?) { + let decorations = searchResults.enumerated().map { index, result in + Decoration( + id: "\(index)", + locator: result.locator, + style: .highlight(isActive: index == selectedIndex) + ) + } + navigator.apply(decorations: decorations, in: "search") +} + +// Show all results with none selected +applySearchDecorations(selectedIndex: nil) + +// When the user moves to a result, mark it as active +applySearchDecorations(selectedIndex: currentResultIndex) + +// Clear on dismiss +navigator.apply(decorations: [], in: "search") +``` + +#### TTS Playback + +Track the currently spoken sentence with a single-decoration `"tts"` group. Replace the decoration each time TTS advances, and clear it when TTS stops. + +```swift +let navigator: DecorableNavigator + +func ttsDidStartSpeaking(locator: Locator) { + let decoration = Decoration( + id: "tts", + locator: locator, + style: .underline(tint: .red) + ) + navigator.apply(decorations: [decoration], in: "tts") +} + +func ttsDidStop() { + navigator.apply(decorations: [], in: "tts") +} +``` + +## Further Reading + +- [Readium Decorator API proposal](https://readium.org/architecture/proposals/008-decorator-api.html) diff --git a/docs/Guides/Navigator/EPUB Fonts.md b/docs/Guides/Navigator/EPUB Fonts.md index e61d78a665..ea5ae94128 100644 --- a/docs/Guides/Navigator/EPUB Fonts.md +++ b/docs/Guides/Navigator/EPUB Fonts.md @@ -1,6 +1,6 @@ # Font families in the EPUB navigator -Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Navigator%20Preferences.md). +Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](Preferences.md). > [!NOTE] > You cannot change the default font family of a fixed-layout EPUB (with zoomable pages), as it is similar to a PDF or a comic book. @@ -67,7 +67,7 @@ To offer more choices to your users, you must embed and declare custom font fami ``` 4. Configure the EPUB navigator with a declaration of the font faces for all the additional font families. ```swift - let resources = Bundle.main.resourceURL! + let resources = FileURL(url: Bundle.main.resourceURL!)! let navigator = try EPUBNavigatorViewController( publication: publication, initialLocation: locator, @@ -79,11 +79,11 @@ To offer more choices to your users, you must embed and declare custom font fami // Literata is a variable font family, so we can provide a font weight range. // https://fonts.google.com/knowledge/glossary/variable_fonts CSSFontFace( - file: resources.appendingPathComponent("Literata-VariableFont_opsz,wght.ttf"), + file: resources.appendingPath("Literata-VariableFont_opsz,wght.ttf", isDirectory: false), style: .normal, weight: .variable(200...900) ), CSSFontFace( - file: resources.appendingPathComponent("Literata-Italic-VariableFont_opsz,wght.ttf"), + file: resources.appendingPath("Literata-Italic-VariableFont_opsz,wght.ttf", isDirectory: false), style: .italic, weight: .variable(200...900) ) ] @@ -93,26 +93,25 @@ To offer more choices to your users, you must embed and declare custom font fami fontFamily: .atkinsonHyperlegible, fontFaces: [ CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Regular.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-Regular.ttf", isDirectory: false), style: .normal, weight: .standard(.normal) ), CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Italic.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-Italic.ttf", isDirectory: false), style: .italic, weight: .standard(.normal) ), CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-Bold.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-Bold.ttf", isDirectory: false), style: .normal, weight: .standard(.bold) ), CSSFontFace( - file: resources.appendingPathComponent("Atkinson-Hyperlegible-BoldItalic.ttf"), + file: resources.appendingPath("Atkinson-Hyperlegible-BoldItalic.ttf", isDirectory: false), style: .italic, weight: .standard(.bold) ), ] ).eraseToAnyHTMLFontFamilyDeclaration() ] - ), - httpServer: GCDHTTPServer.shared + ) ) ``` diff --git a/docs/Guides/Navigator/Highlights.md b/docs/Guides/Navigator/Highlights.md new file mode 100644 index 0000000000..154ccdfd33 --- /dev/null +++ b/docs/Guides/Navigator/Highlights.md @@ -0,0 +1,309 @@ +# Implementing Highlights + +Highlighting lets users mark up passages in a publication for later reference - a core feature of any reading app. In Readium, highlights are built on top of the **Decoration API**. If you want to understand that API in depth or build a custom decoration style, see the [Decorations guide](Decorations.md). + +**Readium is only responsible for *rendering* highlights over the publication content**. Persisting highlights to a database, and any UI around them (color pickers, annotation editors, highlight lists, etc.) are entirely the responsibility of your app. This guide assumes you already have a `Highlight` model and a repository to store and observe it. + +> [!NOTE] +> Only `EPUBNavigatorViewController` implements `DecorableNavigator` today. Always check if a navigator implements `DecorableNavigator` before enabling decoration-dependent features to future-proof your code. + +## Setting Up + +iOS shows a context menu when the user selects text in the navigator. You hook into this by declaring a custom `EditingAction` with a selector that will be fired when the user taps the menu item. + +```swift +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + config: EPUBNavigatorViewController.Configuration( + editingActions: EditingAction.defaultActions + [ + EditingAction(title: "Highlight", action: #selector(highlightSelection)) + ] + ) +) +``` + +## Creating a Highlight from a Text Selection + +The `action` selector must be implemented somewhere in the **responder chain** above the navigator β€” typically in its parent view controller. iOS routes editing actions up the responder chain, so the navigator passes them through without handling them itself. + +```swift +@objc func highlightSelection() { + guard let selection = navigator.currentSelection else { + return + } + + let highlight = Highlight( + bookId: bookId, + locator: selection.locator, + text: selection.locator.text.highlight, + color: .yellow + ) + + Task { + try await highlightRepository.add(highlight) + // If you use a reactive pattern (see below), the navigator + // updates automatically when the database changes. + } + + // dismisses the text selection handles immediately after saving; + // without it, the selection would linger on screen alongside the + // newly rendered highlight decoration. + navigator.clearSelection() +} +``` + +`navigator.currentSelection` returns a `Selection` value with: + +- `locator` β€” a `Locator` pointing at the selected range; `locator.text.highlight` contains the selected string. +- `frame` β€” the bounding rect of the selection in navigator view coordinates, useful for anchoring a popover. + +## Displaying Highlights + +Rather than calling `apply` manually after each add, delete, or color change, subscribe to your database and re-apply the complete list of decorations whenever it changes. This means there is a single code path for keeping the navigator in sync β€” no risk of forgetting to update the UI for one of the operations. + +The navigator diffs each new list against the previous one internally, so passing the full list every time is both safe and efficient β€” you never need to track individual changes yourself. + +```swift +func observeHighlightDecorations() { + guard let navigator = navigator as? DecorableNavigator else { return } + + // Register the tap callback once (see "Handling Taps" below) + navigator.observeDecorationInteractions(inGroup: "highlights") { [weak self] event in + self?.activateDecoration(event) + } + + // Re-apply on every database change. + highlightRepository.highlights(for: bookId) + .receive(on: DispatchQueue.main) + .sink { _ in } receiveValue: { [weak self] highlights in + guard let self else { return } + + let decorations = highlights.map { highlight in + Decoration( + // Use your database primary key as the highlight's `id` β€” this is what + // links the `Decoration` back to your model when the user later taps it. + id: highlight.id, + locator: highlight.locator, + style: .highlight(tint: highlight.color) + ) + } + + navigator.apply(decorations: decorations, in: "highlights") + } + .store(in: &subscriptions) +} +``` + +`receive(on: DispatchQueue.main)` is required because `apply(decorations:in:)` updates the UI and must run on the main thread, while database publishers typically deliver on a background thread. + +Call `observeHighlightDecorations()` once in `viewDidLoad`. + +## Handling Taps + +Use `observeDecorationInteractions(inGroup:onActivated:)` to react when the user taps a highlight. Your callback receives `OnDecorationActivatedEvent` objects. + +**`event.decoration.id`** matches the id you set when building the `Decoration`, so you can use it directly to retrieve the full record from your database. + +**`event.rect`** gives you the position of the tapped highlight in the navigator view, which you can use to anchor a popover precisely over it. + +```swift +private func activateDecoration(_ event: OnDecorationActivatedEvent) { + // Matches the id you used when building the Decoration. + let highlightId = event.decoration.id + + Task { @MainActor in + guard let highlight = try? await highlightRepository.highlight(forId: highlightId) else { + return + } + + presentHighlightMenu(for: highlight, anchoredTo: event.rect) + } +} + +private func presentHighlightMenu(for highlight: Highlight, anchoredTo rect: CGRect?) { + let alert = UIAlertController(title: "Highlight", message: nil, preferredStyle: .actionSheet) + + // Delete: remove from the database; the reactive stream clears the decoration automatically. + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { return } + Task { try await self.highlightRepository.remove(highlight.id) } + }) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = rect ?? view.bounds + } + + present(alert, animated: true) +} +``` + +Because the navigator is wired to the reactive stream, updating or deleting a highlight in the database is automatically reflected in the navigator β€” no extra calls needed. + +## Navigating to a Highlight + +Use `navigator.go(to:)` to jump to a saved highlight's location: + +```swift +await navigator.go(to: highlight.locator) +``` + +## Complete Example + +The following self-contained `EPUBReaderViewController` wires up the full highlights workflow. `HighlightRepository` is left as a protocol so the example is storage-agnostic. + +```swift +import Combine +import ReadiumNavigator +import ReadiumShared +import UIKit + +// MARK: - Data model + +struct Highlight { + /// Database primary key (used as Decoration.id) + var id: String + var bookId: String + var locator: Locator + var color: UIColor +} + +// MARK: - Storage protocol (implement with GRDB, CoreData, etc.) + +protocol HighlightRepository { + func highlights(for bookId: String) -> AnyPublisher<[Highlight], Never> + func highlight(forId id: String) async throws -> Highlight? + func add(_ highlight: Highlight) async throws + func remove(_ id: String) async throws +} + +// MARK: - Reader view controller + +class EPUBReaderViewController: UIViewController { + + private let navigator: EPUBNavigatorViewController + private let highlightRepository: HighlightRepository + private let bookId: String + private var subscriptions = Set() + + private let highlightDecorationGroup = "highlights" + + init( + publication: Publication, + bookId: String, + lastLocation: Locator?, + highlightRepository: HighlightRepository + ) throws { + self.bookId = bookId + self.highlightRepository = highlightRepository + + navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastLocation, + config: EPUBNavigatorViewController.Configuration( + editingActions: EditingAction.defaultActions + [ + EditingAction(title: "Highlight", action: #selector(highlightSelection)) + ] + ) + ) + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + + // Embed the navigator + addChild(navigator) + navigator.view.frame = view.bounds + navigator.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(navigator.view) + navigator.didMove(toParent: self) + + // Wire up highlights + observeHighlightDecorations() + } + + // MARK: - Displaying highlights + + private func observeHighlightDecorations() { + guard let decorator = navigator as? DecorableNavigator else { return } + + decorator.observeDecorationInteractions(inGroup: highlightDecorationGroup) { [weak self] event in + self?.activateDecoration(event) + } + + highlightRepository.highlights(for: bookId) + .receive(on: DispatchQueue.main) + .sink { _ in } receiveValue: { [weak self] highlights in + guard let self else { return } + let decorations = highlights.map { h in + Decoration( + id: h.id, + locator: h.locator, + style: .highlight(tint: h.color) + ) + } + decorator.apply(decorations: decorations, in: self.highlightDecorationGroup) + } + .store(in: &subscriptions) + } + + // MARK: - Creating highlights + + @objc func highlightSelection() { + guard let selection = navigator.currentSelection else { return } + + let highlight = Highlight( + id: UUID().uuidString, + bookId: bookId, + locator: selection.locator, + color: .yellow + ) + + Task { + try await highlightRepository.add(highlight) + } + + navigator.clearSelection() + } + + // MARK: - Tapping existing highlights + + private func activateDecoration(_ event: OnDecorationActivatedEvent) { + let highlightId = event.decoration.id + + Task { + guard let highlight = try? await highlightRepository.highlight(forId: highlightId) else { return } + await MainActor.run { + presentHighlightMenu(for: highlight, anchoredTo: event.rect) + } + } + } + + private func presentHighlightMenu(for highlight: Highlight, anchoredTo rect: CGRect?) { + let alert = UIAlertController(title: "Highlight", message: nil, preferredStyle: .actionSheet) + + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { return } + Task { try await self.highlightRepository.remove(highlight.id) } + }) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = rect ?? view.bounds + popover.permittedArrowDirections = .down + } + + present(alert, animated: true) + } +} +``` diff --git a/docs/Guides/Navigator/Input.md b/docs/Guides/Navigator/Input.md index e3d83895b7..e86fc2bc6f 100644 --- a/docs/Guides/Navigator/Input.md +++ b/docs/Guides/Navigator/Input.md @@ -12,12 +12,12 @@ Here's an example of a simple `InputObserving` implementation that logs all key navigator.addObserver(InputObserver()) @MainActor final class InputObserver: InputObserving { - func didReceive(_ event: PointerEvent) -> Bool { + func didReceive(_ event: PointerEvent) async -> Bool { print("Received pointer event: \(event)") return false } - - func didReceive(_ event: KeyEvent) -> Bool { + + func didReceive(_ event: KeyEvent) async -> Bool { print("Received key event: \(event)") return false } @@ -47,7 +47,7 @@ navigator.addObserver(.click(modifiers: [.shift]) { event in ## Observing keyboard events -Similarly, the `KeyboardObserver` implementation provides an easy method to intercept keyboard events. +Similarly, the `KeyObserver` implementation provides an easy method to intercept keyboard events. ```swift navigator.addObserver(.key { event in diff --git a/docs/Guides/Navigator/Navigator.md b/docs/Guides/Navigator/Navigator.md index ff5cd2d5f7..5a9b818da8 100644 --- a/docs/Guides/Navigator/Navigator.md +++ b/docs/Guides/Navigator/Navigator.md @@ -22,8 +22,7 @@ To find out which Navigator is compatible with a publication, refer to its [prof if publication.conforms(to: .epub) { let navigator = try EPUBNavigatorViewController( publication: publication, - initialLocation: lastReadLocation, - httpServer: GCDHTTPServer.shared + initialLocation: lastReadLocation ) hostViewController.present(navigator, animated: true) @@ -54,7 +53,7 @@ Navigators enabling users to select parts of the content implement `SelectableNa A Decorable Navigator is able to render decorations over a publication, such as highlights or margin icons. -[See the corresponding proposal for more information](https://readium.org/architecture/proposals/008-decorator-api.html). +To implement user highlights in your app, see the [Highlights guide](Highlights.md). For a deeper look at the API or to build a custom decoration style, see the [Decorations guide](Decorations.md). ## Instantiating a Navigator @@ -65,16 +64,12 @@ The Visual Navigators are implemented as `UIViewController` and must be added to ```swift let navigator = try EPUBNavigatorViewController( publication: publication, - initialLocation: lastReadLocation, - httpServer: GCDHTTPServer.shared + initialLocation: lastReadLocation ) hostViewController.present(navigator, animated: true) ``` -> [!NOTE] -> The HTTP server is used to serve the publication resources to the Navigator. You may use your own implementation, or the recommended `GCDHTTPServer` which is part of the `ReadiumAdapterGCDWebServer` package. - ### Audio Navigator The `AudioNavigator` is chromeless and does not provide any user interface, allowing you to create your own custom UI. @@ -107,17 +102,17 @@ You can observe the current position in the publication by implementing a `Navig ```swift navigator.delegate = MyNavigatorDelegate() -class MyNavigatorDelegate: NavigatorDelegate { +@MainActor class MyNavigatorDelegate: NavigatorDelegate { - override func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { if let position = locator.locations.position { - print("At position \(position) on \(publication.positions.count)") + print("At position \(position)") } if let progression = locator.locations.progression { - return "Progression in the current resource: \(progression)%" + print("Progression in the current resource: \(progression.formatted(.percent))") } if let totalProgression = locator.locations.totalProgression { - return "Total progression in the publication: \(progression)%" + print("Total progression in the publication: \(totalProgression.formatted(.percent))") } // Save the position in your bookshelf database @@ -129,12 +124,11 @@ class MyNavigatorDelegate: NavigatorDelegate { The `Locator` object may be serialized to JSON in your database, and deserialized to set the initial location when creating the navigator. ```swift -let lastReadLocation = Locator(jsonString: dabase.lastReadLocation()) +let lastReadLocation = try? Locator(jsonString: database.lastReadLocation()) let navigator = try EPUBNavigatorViewController( publication: publication, - initialLocation: lastReadLocation, - httpServer: GCDHTTPServer.shared + initialLocation: lastReadLocation ) ``` @@ -151,8 +145,8 @@ To display a percentage-based progression slider, use the `locations.totalProgre Given a progression from 0 to 1, you can obtain a `Locator` object from the `Publication`. This can be used to navigate to a specific percentage within the publication. ```swift -if let locator = publication.locate(progression: 0.5) { - navigator.go(to: locator) +if let locator = await publication.locate(progression: 0.5) { + await navigator.go(to: locator) } ``` @@ -161,36 +155,25 @@ if let locator = publication.locate(progression: 0.5) { > [!NOTE] > Readium does not have the concept of pages, as they are not useful when dealing with reflowable publications across different screen sizes. Instead, we use [**positions**](https://readium.org/architecture/models/locators/positions/) which remain stable even when the user changes the font size or device. -Not all Navigators provide positions, but most `VisualNavigator` implementations do. Verify if `publication.positions` is not empty to determine if it is supported. - -To find the total positions in the publication, use `publication.positions.count`. You can get the current position with `navigator.currentLocation?.locations.position`. +Not all Navigators provide positions, but most `VisualNavigator` implementations do. To find the total positions in the publication, use `try await publication.positions().get().count`. You can get the current position with `navigator.currentLocation?.locations.position`. ## Navigating with edge taps and keyboard arrows -Readium provides a `DirectionalNavigationAdapter` helper to turn pages using arrow and space keys or screen taps. +Readium provides a `DirectionalNavigationAdapter` helper to turn pages when the user taps the edge of the screen or presses the arrow or space keys. -You can use it from your `VisualNavigatorDelegate` implementation: +Bind it to your `Navigator` instance before adding your own input observers, so it takes precedence. ```swift -extension MyReader: VisualNavigatorDelegate { +DirectionalNavigationAdapter().bind(to: navigator) - func navigator(_ navigator: VisualNavigator, didTapAt point: CGPoint) { - // Turn pages when tapping the edge of the screen. - guard !DirectionalNavigationAdapter(navigator: navigator).didTap(at: point) else { - return - } - - toggleNavigationBar() - } - - func navigator(_ navigator: VisualNavigator, didPressKey event: KeyEvent) { - // Turn pages when pressing the arrow keys. - DirectionalNavigationAdapter(navigator: navigator).didPressKey(event: event) - } -} +// Toggle the navigation bar when the user taps outside the edge zones. +token = navigator.addObserver(.tap { [weak self] _ in + self?.toggleNavigationBar() + return true +}) ``` -`DirectionalNavigationAdapter` offers a lot of customization options. Take a look at its API. +`DirectionalNavigationAdapter` offers many customization options. Take a look at its API. ## User preferences diff --git a/docs/Guides/Navigator/SwiftUI.md b/docs/Guides/Navigator/SwiftUI.md index 5ca3b13fef..dab87260d6 100644 --- a/docs/Guides/Navigator/SwiftUI.md +++ b/docs/Guides/Navigator/SwiftUI.md @@ -133,4 +133,14 @@ let view = ReaderView( ## Handling touch and keyboard events -You still need to implement the `VisualNavigatorDelegate` protocol to handle gestures in the navigator. Avoid using SwiftUI touch modifiers, as they will prevent the user from interacting with the book. +Use the navigator's input observer API to handle touch and keyboard events. Avoid using SwiftUI touch modifiers, as they will prevent the user from interacting with the book. + +```swift +// Example: Toggle UI visibility when the user taps the navigator. +navigator.addObserver(.tap { [weak viewModel] _ in + viewModel?.toggleUIVisibility() + return true +}) +``` + +See the [Input guide](Input.md) for more details on handling user input. diff --git a/docs/Guides/Readium LCP.md b/docs/Guides/Readium LCP.md index 28a1ba02be..5d488fa636 100644 --- a/docs/Guides/Readium LCP.md +++ b/docs/Guides/Readium LCP.md @@ -177,11 +177,10 @@ A file required by the LCP library needs to be downloaded from an insecure HTTP `ReadiumLCP` offers an `LCPService` object that exposes its API. Since the `ReadiumLCP` package is not linked with `R2LCPClient`, you need to create your own adapter when setting up the `LCPService`. -The `LCPService` expects repositories to store the opened licenses and passphrases. While you can implement your own persistence layer, the `ReadiumAdapterLCPSQLite` module provides default implementations based on an SQLite database. +The `LCPService` expects repositories to store the opened licenses and passphrases. `ReadiumLCP` provides built-in Keychain-based implementations that store data securely in the iOS/macOS Keychain. Unlike database-based storage, Keychain data persists across app reinstalls and can optionally be synchronized across the user's devices via iCloud Keychain. ```swift import R2LCPClient -import ReadiumAdapterLCPSQLite import ReadiumLCP let httpClient = DefaultHTTPClient() @@ -192,8 +191,8 @@ let assetRetriever = AssetRetriever( let lcpService = LCPService( client: LCPClientAdapter(), - licenseRepository: try LCPSQLiteLicenseRepository(), - passphraseRepository: try LCPSQLitePassphraseRepository(), + licenseRepository: LCPKeychainLicenseRepository(), + passphraseRepository: LCPKeychainPassphraseRepository(), assetRetriever: assetRetriever, httpClient: httpClient ) @@ -214,6 +213,10 @@ class LCPClientAdapter: ReadiumLCP.LCPClient { } ``` +To disable iCloud synchronization, pass `synchronizable: false` when creating the repositories. + +You may also implement your own persistence layer by conforming to `LCPLicenseRepository` and `LCPPassphraseRepository`. + ## Acquiring a publication from a License Document (LCPL) Users need to import a License Document into your application to download the protected publication (`.epub`, `.lcpdf`, or `.lcpa`). @@ -221,8 +224,8 @@ Users need to import a License Document into your application to download the pr The `LCPService` offers an API to retrieve the full publication from an LCPL on the filesystem. ```swift -let acquisition = lcpService.acquirePublication( - from: lcplURL, +let result = await lcpService.acquirePublication( + from: .file(lcplURL), onProgress: { progress in switch progress { case .indefinite: @@ -230,21 +233,16 @@ let acquisition = lcpService.acquirePublication( case .percent(let percent): // Display a progress bar with percent from 0 to 1. } - }, - completion: { result in - switch result { - case let .success(publication): - // Import the `publication.localURL` file as any publication. - case let .failure(error): - // Display the error message - case .cancelled: - // The acquisition was cancelled before completion. - } } ) -``` -If the user wants to cancel the download, call `cancel()` on the object returned by `LCPService.acquirePublication()`. +switch result { +case let .success(publication): + // Import the `publication.localURL` file as any publication. +case let .failure(error): + // Display the error message. +} +``` After the download is completed, import the `publication.localURL` file into the bookshelf like any other publication file. @@ -305,7 +303,7 @@ The `allowUserInteraction` and `sender` arguments are forwarded to the `LCPAuthe When importing the publication to the bookshelf, set `allowUserInteraction` to `false` as you don't need the passphrase for accessing the publication metadata and cover. If you intend to present the publication using a Navigator, set `allowUserInteraction` to `true` as decryption will be required. > [!TIP] -> To check if a publication is protected with LCP before opening it, you can use `LCPService.isLCPProtected()`. +> To check if an asset is protected with LCP before opening it, you can use `asset.format.conformsTo(.lcp)`. ### Using the opened `Publication` @@ -367,36 +365,30 @@ An LCP License Document contains metadata such as its expiration date, the remai Use the `LCPService` to retrieve the `LCPLicense` instance for a publication. ```swift -lcpService.retrieveLicense( - from: publicationURL, +let result = await lcpService.retrieveLicense( + from: asset, authentication: LCPDialogAuthentication(), allowUserInteraction: true, sender: hostViewController -) { result in - switch result { - case .success(let lcpLicense): - if let lcpLicense = lcpLicense { - if let user = lcpLicense.license.user.name { - print("The publication was acquired by \(user)") - } - if let endDate = lcpLicense.license.rights.end { - print("The loan expires on \(endDate)") - } - if let copyLeft = lcpLicense.charactersToCopyLeft { - print("You can copy up to \(copyLeft) characters remaining.") - } - } else { - // The file was not protected by LCP. - } - case .failure(let error): - // Display the error. - case .cancelled: - // The operation was cancelled. +) + +switch result { +case .success(let lcpLicense): + if let user = lcpLicense.license.user.name { + print("The publication was acquired by \(user)") + } + if let endDate = lcpLicense.license.rights.end { + print("The loan expires on \(endDate)") } + if let copyLeft = await lcpLicense.charactersToCopyLeft() { + print("You can copy up to \(copyLeft) characters remaining.") + } +case .failure(let error): + // Display the error. } ``` -If you have already opened a `Publication` with the `Streamer`, you can directly obtain the `LCPLicense` using `publication.lcpLicense`. +If you have already opened a `Publication` with the `PublicationOpener`, you can directly obtain the `LCPLicense` using `publication.lcpLicense`. ## Managing a loan @@ -407,12 +399,12 @@ Readium LCP allows borrowing publications for a specific period. Use the `LCPLic Some loans can be returned before the end date. You can confirm this by using `lcpLicense.canReturnPublication`. To return the publication, execute: ```swift -lcpLicense.returnPublication { error in - if let error = error { - // Present the error. - } else { - // The publication was returned. - } +let result = await lcpLicense.returnPublication() +switch result { +case .success: + // The publication was returned. +case .failure(let error): + // Present the error. } ``` @@ -428,17 +420,17 @@ Readium LCP supports [two types of renewal interactions](https://readium.org/lcp You need to support both interactions by implementing the `LCPRenewDelegate` protocol. A default implementation is available with `LCPDefaultRenewDelegate`. ```swift -lcpLicense.renewLoan( +let result = await lcpLicense.renewLoan( with: LCPDefaultRenewDelegate( presentingViewController: hostViewController ) -) { result in - switch result { - case .success, .cancelled: - // The publication was renewed. - case let .failure(error): - // Display the error. - } +) + +switch result { +case .success: + // The publication was renewed. +case let .failure(error): + // Display the error. } ``` @@ -450,7 +442,7 @@ For an example, [take a look at the Test App](https://github.com/readium/swift-t ## Using the SwiftUI LCP Authentication dialog -If your application is built using SwiftUI, you cannot use `LCPAuthenticationDialog` because it requires a UIKit view controller as its host. Instead, use an `LCPObservableAuthentication` combined with our SwiftUI `LCPDialog` presented as a sheet. +If your application is built using SwiftUI, you cannot use `LCPDialogAuthentication` because it requires a UIKit view controller as its host. Instead, use an `LCPObservableAuthentication` combined with our SwiftUI `LCPDialog` presented as a sheet. ```swift @main diff --git a/docs/Guides/TTS.md b/docs/Guides/TTS.md index 31505058e3..6c3eb7c253 100644 --- a/docs/Guides/TTS.md +++ b/docs/Guides/TTS.md @@ -93,7 +93,7 @@ While `PublicationSpeechSynthesizer` is completely independent from `Navigator` `PublicationSpeechSynthesizer.start()` takes a starting `Locator` for parameter. You can use it to begin the playback from the currently visible page in a `VisualNavigator` using `firstVisibleElementLocator()`. ```swift -navigator.firstVisibleElementLocator { start in +if let start = await navigator.firstVisibleElementLocator() { synthesizer.start(from: start) } ``` diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 1ee37a540a..1e970f01be 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -4,6 +4,111 @@ All migration steps necessary in reading apps to upgrade to major versions of th +## 3.8.0 + +### Removing the HTTP Server from the EPUB Navigator + +The EPUB navigator no longer requires an HTTP server. Publication resources are now served directly to the web views using a custom URL scheme handler. + +Remove the `httpServer` parameter when creating an `EPUBNavigatorViewController`: + +```diff + let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, +- httpServer: GCDHTTPServer.shared + ) +``` + +> [!NOTE] +> The PDF navigator still requires an HTTP server. If you are not using the PDF navigator, you can remove the `ReadiumAdapterGCDWebServer` dependency from your project. + +### Migrating LCP Repositories from SQLite to the Keychain + +The `ReadiumAdapterLCPSQLite` module is now deprecated. `ReadiumLCP` provides built-in Keychain-based repositories that are more secure, persist across app reinstalls, and optionally synchronize across devices via iCloud Keychain. + +#### Updating the `LCPService` initialization + +Replace the SQLite repositories with their Keychain equivalents: + +```diff +-import ReadiumAdapterLCPSQLite + import ReadiumLCP + + let lcpService = LCPService( + client: LCPClient(), +- licenseRepository: try! LCPSQLiteLicenseRepository(), +- passphraseRepository: try! LCPSQLitePassphraseRepository(), ++ licenseRepository: LCPKeychainLicenseRepository(), ++ passphraseRepository: LCPKeychainPassphraseRepository(), + assetRetriever: assetRetriever, + httpClient: httpClient + ) +``` + +Then remove `ReadiumAdapterLCPSQLite` from your project dependencies: + +* **Swift Package Manager:** Remove the `ReadiumAdapterLCPSQLite` product from your target dependencies. +* **Carthage:** Remove `ReadiumAdapterLCPSQLite.xcframework` and `SQLite.xcframework` from your project. +* **CocoaPods:** Remove `pod 'ReadiumAdapterLCPSQLite'` from your `Podfile` and run `pod install`. + +#### Migrating existing data + +If your app already stores LCP data in the SQLite database, you can migrate it to the Keychain using the built-in migration helpers. Run this migration once, for example during an app update: + +```swift +import ReadiumAdapterLCPSQLite +import ReadiumLCP + +let keychainLicenseRepository = LCPKeychainLicenseRepository() +let keychainPassphraseRepository = LCPKeychainPassphraseRepository() + +let sqliteLicenseRepository = try LCPSQLiteLicenseRepository() +let sqlitePassphraseRepository = try LCPSQLitePassphraseRepository() + +try await sqliteLicenseRepository.migrate(to: keychainLicenseRepository) +try await sqlitePassphraseRepository.migrate(to: keychainPassphraseRepository) +``` + + +## 3.7.0 + +### LCP Dialog Localization Keys + +The LCP dialog localization string keys have been renamed to align with the [thorium-locales](https://github.com/edrlab/thorium-locales/) repository. Contributions are welcome on [Weblate](https://hosted.weblate.org/projects/thorium-reader/readium-lcp/). + +If you overrode any of these strings in your app's `Localizable.strings`, you must update them to use the new keys: + +| Old Key | New Key | +|-----------------------------------------------|-------------------------------------------------| +| `ReadiumLCP.dialog.cancel` | `readium.lcp.dialog.actions.cancel` | +| `ReadiumLCP.dialog.continue` | `readium.lcp.dialog.actions.continue` | +| `ReadiumLCP.dialog.forgotYourPassphrase` | `readium.lcp.dialog.actions.recoverPassphrase` | +| `ReadiumLCP.dialog.hint` | `readium.lcp.dialog.passphrase.hint` | +| `ReadiumLCP.dialog.header` | `readium.lcp.dialog.message` | +| `ReadiumLCP.dialog.details.title` | `readium.lcp.dialog.info.title` | +| `ReadiumLCP.dialog.details.body` | `readium.lcp.dialog.info.body` | +| `ReadiumLCP.dialog.details.more` | `readium.lcp.dialog.info.more` | +| `ReadiumLCP.dialog.error.incorrectPassphrase` | `readium.lcp.dialog.errors.incorrectPassphrase` | +| `ReadiumLCP.dialog.title` | `readium.lcp.dialog.title` | +| `ReadiumLCP.dialog.passphrase.placeholder` | `readium.lcp.dialog.passphrase.placeholder` | + +The following legacy strings from the old UIKit-based dialog have been removed entirely: + +* `ReadiumLCP.dialog.prompt.message1` +* `ReadiumLCP.dialog.prompt.message2` +* `ReadiumLCP.dialog.reason.passphraseNotFound` +* `ReadiumLCP.dialog.reason.invalidPassphrase` +* `ReadiumLCP.dialog.prompt.forgotPassphrase` +* `ReadiumLCP.dialog.prompt.support` +* `ReadiumLCP.dialog.prompt.continue` +* `ReadiumLCP.dialog.prompt.passphrase` +* `ReadiumLCP.dialog.support` +* `ReadiumLCP.dialog.support.website` +* `ReadiumLCP.dialog.support.phone` +* `ReadiumLCP.dialog.support.mail` + + ## 3.3.0 ### New Input API @@ -349,16 +454,16 @@ let navigator = try EPUBNavigatorViewController( ### Upgrading to the new Preferences API -The 2.5.0 release introduces a brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](Guides/Navigator%20Preferences.md). +The 2.5.0 release introduces a brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](Guides/Navigator/Preferences.md). If you integrated the EPUB navigator from a previous version, follow these steps to migrate: -1. Get familiar with [the concepts of this new API](Guides/Navigator%20Preferences.md#overview). +1. Get familiar with [the concepts of this new API](Guides/Navigator/Preferences.md#overview). 2. Migrate the local HTTP server from your app, [as explained in the previous section](#migrating-the-http-server). -3. Adapt your user settings interface to the new API using preferences editors. The [Test App](https://github.com/readium/swift-toolkit/blob/2.5.0/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift) and the [user guide](Guides/Navigator%20Preferences.md#build-a-user-settings-interface) contain examples using SwiftUI. -4. [Handle the persistence of the user preferences](Guides/Navigator%20Preferences.md#save-and-restore-the-user-preferences). The settings are not stored in the User Defaults by the toolkit anymore. Instead, you are responsible for persisting and restoring the user preferences as you see fit (e.g. as a JSON file). +3. Adapt your user settings interface to the new API using preferences editors. The [Test App](https://github.com/readium/swift-toolkit/blob/2.5.0/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift) and the [user guide](Guides/Navigator/Preferences.md#build-a-user-settings-interface) contain examples using SwiftUI. +4. [Handle the persistence of the user preferences](Guides/Navigator/Preferences.md#save-and-restore-the-user-preferences). The settings are not stored in the User Defaults by the toolkit anymore. Instead, you are responsible for persisting and restoring the user preferences as you see fit (e.g. as a JSON file). * If you want to migrate the legacy EPUB settings, you can use the helper `EPUBPreferences.fromLegacyPreferences()` which will create a new `EPUBPreferences` object after translating the existing user settings. -5. Make sure you [restore the stored user preferences](Guides/Navigator%20Preferences.md#setting-the-initial-navigator-preferences-and-app-defaults) when initializing the EPUB navigator. +5. Make sure you [restore the stored user preferences](Guides/Navigator/Preferences.md#setting-the-initial-navigator-preferences-and-app-defaults) when initializing the EPUB navigator. Please refer to the following table for the correspondence between legacy settings (from `UserSettings`) and new ones (`EPUBPreferences`). diff --git a/docs/NavigatorOverview.md b/docs/NavigatorOverview.md new file mode 100644 index 0000000000..249c61d4ae --- /dev/null +++ b/docs/NavigatorOverview.md @@ -0,0 +1,19 @@ +# Navigator Overview + +@Metadata { + @PageKind(article) +} + +Learn about the architecture, configuration, and usage of the Readium Navigator. + +## Topics + +### Essentials + +- +- +- +- +- +- +- diff --git a/docs/Readium.md b/docs/Readium.md new file mode 100644 index 0000000000..02832d84d6 --- /dev/null +++ b/docs/Readium.md @@ -0,0 +1,28 @@ +# Readium + +@Metadata { + @TechnologyRoot +} + +The Readium Swift Toolkit is a toolkit for ebooks, audiobooks, and comics written in Swift & Kotlin. + +## Topics + +### Guides + +- +- +- +- +- +- +- +- + +### API Reference + +- +- +- +- +-