diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ac57cdd7b..031cd4990 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,9 +7,9 @@ on: env: platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone SE (3rd generation)' }} + device: ${{ 'iPhone 17' }} commit_sha: ${{ github.sha }} - DEVELOPER_DIR: /Applications/Xcode_16.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 01251861a..92bb29ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,26 @@ All notable changes to this project will be documented in this file. Take a look #### 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. #### 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. + +### Deprecated + +#### Streamer + +* The EPUB manifest item `id` attribute is no longer exposed in `Link.properties`. + +### Fixed + +#### Navigator + +* PDF documents are now opened off the main thread, preventing UI freezes with large files. ## [3.6.0] 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 f69329384..6eb8689fa 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}}();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,c()}));var d=u.closest(".viewport");function c(){if(l&&o&&a){u.style.width=l.width+"px",u.style.height=l.height+"px";var t,i=o.width/l.width,d=o.height/l.height;t=r===n.WIDTH?i:Math.min(i,d);var c=l.height*t,h=s===e.SINGLE||s===e.SPREAD_CENTER;if(r===n.WIDTH&&c>o.height)u.style.top=a.top+"px",u.style.transform=h?"translateX(-50%)":"none";else{var f=a.top-a.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=h?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+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)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,l=null,u.src="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),c()},show:function(){d.style.display="block"},hide:function(){d.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)}}})(); +(()=>{"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 148f3816f..244222298 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={};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,r=null,l=null,a=n.AUTO,s=Object.values(e).includes(i)?i:e.SINGLE,u=document.getElementById(t);u.addEventListener("load",(function(){var t,e,n;o=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 o=Number.parseFloat(i.width),r=Number.parseFloat(i.height);return o&&r?{width:o,height:r}: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:r,h()}));var c=u.closest(".viewport");function h(){if(o&&r&&l){u.style.width=o.width+"px",u.style.height=o.height+"px";var t,i=r.width/o.width,c=r.height/o.height;t=a===n.WIDTH?i:Math.min(i,c);var h=o.height*t,d=s===e.SINGLE||s===e.SPREAD_CENTER;if(a===n.WIDTH&&h>r.height)u.style.top=l.top+"px",u.style.transform=d?"translateX(-50%)":"none";else{var f=l.top-l.bottom;u.style.top="calc(50% + "+f+"px)",u.style.transform=d?"translate(-50%, -50%)":"translateY(-50%)"}document.querySelector("meta[name=viewport]").content="initial-scale="+t+", minimum-scale="+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)})),u.src=t.url}else e&&e()},reset:function(){this.link&&(this.link=null,o=null,u.src="about:blank")},eval:function(t){if(this.link&&!this.isLoading)return u.contentWindow.eval(t)},setViewport:function(t,e,i){r=t,l=e,Object.values(n).includes(i)&&(a=i),h()},show:function(){c.style.display="block"},hide:function(){c.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 r(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({})}r((function(t){t.reset(),t.hide()}));for(const n in t){const i=t[n],r=o[i.page];r&&(r.show(),r.load(i,e))}},eval:function(t,e){if("#"===t||""===t)r((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)}}})(); +(()=>{"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/Scripts/src/fixed-page.js b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js index cd4f2a98a..15baa4bcf 100644 --- a/Sources/Navigator/EPUB/Scripts/src/fixed-page.js +++ b/Sources/Navigator/EPUB/Scripts/src/fixed-page.js @@ -156,6 +156,15 @@ export function FixedPage(iframeId, pageType) { 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, @@ -194,17 +203,20 @@ export function FixedPage(iframeId, pageType) { } _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. @@ -236,3 +248,46 @@ export function FixedPage(iframeId, pageType) { }, }; } + +// 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/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 82af331bb..2efab2dee 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -455,7 +455,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 } @@ -483,6 +483,13 @@ open class PDFNavigatorViewController: return true } + 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 diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index cbe83b64b..ad8ee2c6c 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -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/Streamer/Parser/EPUB/EPUBManifestParser.swift b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift index 5d821ff63..ddaca5374 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift @@ -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) diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 9d28bf5e3..f39a31b05 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -29,6 +29,13 @@ public enum OPFParserError: Error { /// 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? + } + /// Relative path to the OPF in the EPUB container private let baseURL: RelativeURL @@ -88,13 +95,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 +142,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 +165,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 - } - } - - /// 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 item } - - 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 +206,68 @@ final class OPFParser: Loggable { properties["encrypted"] = encryption } - let type = manifestItem.attr("media-type") - - 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) ) + + return ManifestItem( + id: id, + link: link, + fallbackId: manifestItem.attr("fallback") + ) + } + + /// 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 + ) + } + + readingOrder.append(spineLink) + } + + let resources = items.map(\.link) + return (resources, readingOrder) } /// Parse string properties into an `otherProperties` dictionary. @@ -272,4 +312,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/Image/ImageParser.swift b/Sources/Streamer/Parser/Image/ImageParser.swift index 822688c5e..e7f7fb018 100644 --- a/Sources/Streamer/Parser/Image/ImageParser.swift +++ b/Sources/Streamer/Parser/Image/ImageParser.swift @@ -179,9 +179,6 @@ public final class ImageParser: PublicationParser { metadata.localizedTitle = .nonlocalized(fallbackTitle) } - // Display the first page on its own by default. - readingOrder[0].properties.page = .center - // Apply center page layout for double-page spreads if let pages = comicInfo?.pages { for pageInfo in pages where pageInfo.doublePage == true { 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 000000000..79c8b4074 --- /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 000000000..3e8bee182 --- /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 000000000..d023eae0b --- /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 000000000..958da78c7 --- /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 000000000..d01bb3427 --- /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/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index 0405fa988..fa0bfcea2 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -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: 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/OPFParserTests.swift b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift index 5e4ca281d..9a9d07f4d 100644 --- a/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/OPFParserTests.swift @@ -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,19 @@ 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), ]) 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"), + link(href: "EPUB/fonts/MinionPro.otf", mediaType: 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/chapter01.smil", mediaType: .smil), + link(href: "EPUB/chapter02.smil", mediaType: .smil), + 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 +66,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 +74,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 +107,123 @@ 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: - Toolkit + // 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: - 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 +240,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/Image/ImageParserTests.swift b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift index 20794f8df..172c4780d 100644 --- a/Tests/StreamerTests/Parser/Image/ImageParserTests.swift +++ b/Tests/StreamerTests/Parser/Image/ImageParserTests.swift @@ -167,10 +167,7 @@ class ImageParserTests: XCTestCase { func testDoublePageSpreadSetsCenterPage() async throws { let publication = try await parser.parse(asset: cbzWithComicInfoAsset, warnings: nil).get().build() - // Page 0 (cover) should have center page property (default behavior) - XCTAssertEqual(publication.readingOrder[0].properties.page, .center) - - // Page 1 should not have center page property + XCTAssertNil(publication.readingOrder[0].properties.page) XCTAssertNil(publication.readingOrder[1].properties.page) // Page 2 has DoublePage="True" in ComicInfo.xml, should have center page