diff --git a/r2-shared-swift.xcodeproj/project.pbxproj b/r2-shared-swift.xcodeproj/project.pbxproj index 7e45b672..779e84e9 100644 --- a/r2-shared-swift.xcodeproj/project.pbxproj +++ b/r2-shared-swift.xcodeproj/project.pbxproj @@ -43,7 +43,7 @@ CA94291622BCD08C00305CDB /* ResourcesServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA94291522BCD08B00305CDB /* ResourcesServer.swift */; }; CA9A40D1221B0AA200531EA1 /* Either.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A40D0221B0AA200531EA1 /* Either.swift */; }; CA9E6BA12239823300ECF6E4 /* WP+Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9E6BA02239823300ECF6E4 /* WP+Deprecated.swift */; }; - CA9E6BA4223A657900ECF6E4 /* JSONEquatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9E6BA3223A657900ECF6E4 /* JSONEquatable.swift */; }; + CA9E6BA4223A657900ECF6E4 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9E6BA3223A657900ECF6E4 /* JSON.swift */; }; CA9E6BA9223A749900ECF6E4 /* EPUBPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9E6BA8223A749900ECF6E4 /* EPUBPublication.swift */; }; CA9E6BAE223A76C600ECF6E4 /* OPDSPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9E6BAD223A76C600ECF6E4 /* OPDSPublication.swift */; }; CAB88C90224E510000D36C99 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CAB88C8F224E510000D36C99 /* MobileCoreServices.framework */; }; @@ -139,7 +139,7 @@ CA94291522BCD08B00305CDB /* ResourcesServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourcesServer.swift; sourceTree = ""; }; CA9A40D0221B0AA200531EA1 /* Either.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Either.swift; sourceTree = ""; }; CA9E6BA02239823300ECF6E4 /* WP+Deprecated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WP+Deprecated.swift"; sourceTree = ""; }; - CA9E6BA3223A657900ECF6E4 /* JSONEquatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONEquatable.swift; sourceTree = ""; }; + CA9E6BA3223A657900ECF6E4 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; CA9E6BA6223A67D300ECF6E4 /* Publication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publication.swift; sourceTree = ""; }; CA9E6BA8223A749900ECF6E4 /* EPUBPublication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBPublication.swift; sourceTree = ""; }; CA9E6BAD223A76C600ECF6E4 /* OPDSPublication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSPublication.swift; sourceTree = ""; }; @@ -202,7 +202,7 @@ CA2006502225A1F300E6B3BD /* Observable.swift */, CA9A40D0221B0AA200531EA1 /* Either.swift */, 57470F7D20ED0D1A000CDCA3 /* DownloadSession.swift */, - CA9E6BA3223A657900ECF6E4 /* JSONEquatable.swift */, + CA9E6BA3223A657900ECF6E4 /* JSON.swift */, CA50B86D22B2A1CF003AFF24 /* R2LocalizedString.swift */, CA94291522BCD08B00305CDB /* ResourcesServer.swift */, CADD69E122C3B17500A4CADF /* DocumentTypes.swift */, @@ -625,7 +625,7 @@ CA9E6BAE223A76C600ECF6E4 /* OPDSPublication.swift in Sources */, CABEB3DC2215698600090B6C /* Deferred.swift in Sources */, CA2006512225A1F300E6B3BD /* Observable.swift in Sources */, - CA9E6BA4223A657900ECF6E4 /* JSONEquatable.swift in Sources */, + CA9E6BA4223A657900ECF6E4 /* JSON.swift in Sources */, CA2AE321221C1DCB008BD18F /* LoggerStub.swift in Sources */, CA2AE322221C1DCB008BD18F /* Loggable.swift in Sources */, CA9A40D1221B0AA200531EA1 /* Either.swift in Sources */, diff --git a/r2-shared-swift/Publication/ContentLayout.swift b/r2-shared-swift/Publication/ContentLayout.swift index 82af3d5a..c09be337 100644 --- a/r2-shared-swift/Publication/ContentLayout.swift +++ b/r2-shared-swift/Publication/ContentLayout.swift @@ -16,6 +16,16 @@ public enum ReadingProgression: String { case rtl case ltr case auto + + /// Returns the leading Page for the reading progression. + public var leadingPage: Properties.Page { + switch self { + case .ltr, .auto: + return .left + case .rtl: + return .right + } + } } diff --git a/r2-shared-swift/Publication/Locator.swift b/r2-shared-swift/Publication/Locator.swift index 0f4b4e12..74f38ad1 100644 --- a/r2-shared-swift/Publication/Locator.swift +++ b/r2-shared-swift/Publication/Locator.swift @@ -25,12 +25,12 @@ public struct Locator: Equatable, CustomStringConvertible, Loggable { public var title: String? /// One or more alternative expressions of the location. - public var locations: Locations? + public var locations: Locations /// Textual context of the locator. - public var text: LocatorText? + public var text: LocatorText - public init(href: String, type: String, title: String? = nil, locations: Locations? = nil, text: LocatorText? = nil) { + public init(href: String, type: String, title: String? = nil, locations: Locations = .init(), text: LocatorText = .init()) { self.href = href self.type = type self.title = title @@ -52,12 +52,8 @@ public struct Locator: Equatable, CustomStringConvertible, Loggable { self.href = href self.type = type self.title = json["title"] as? String - if let locations = json["locations"] { - self.locations = try Locations(json: locations) - } - if let text = json["text"] { - self.text = try LocatorText(json: text) - } + self.locations = try Locations(json: json["locations"]) + self.text = try LocatorText(json: json["text"]) } public init?(jsonString: String) throws { @@ -74,9 +70,9 @@ public struct Locator: Equatable, CustomStringConvertible, Loggable { public init(link: Link) { let components = link.href.split(separator: "#", maxSplits: 1).map(String.init) - var locations: Locations? + var locations = Locations() if components.count > 1 { - locations = Locations(fragment: String(components[1])) + locations.fragments = [String(components[1])] } self.init( @@ -92,8 +88,8 @@ public struct Locator: Equatable, CustomStringConvertible, Loggable { "href": href, "type": type, "title": encodeIfNotNil(title), - "locations": encodeIfNotEmpty(locations?.json), - "text": encodeIfNotEmpty(text?.json) + "locations": encodeIfNotEmpty(locations.json), + "text": encodeIfNotEmpty(text.json) ]) } @@ -118,7 +114,10 @@ public struct LocatorText: Equatable, Loggable { self.highlight = highlight } - public init(json: Any) throws { + public init(json: Any?) throws { + if json == nil { + return + } guard let json = json as? [String: Any] else { throw JSONError.parsing(LocatorText.self) } @@ -137,7 +136,7 @@ public struct LocatorText: Equatable, Loggable { } } - public var json: [String: Any]? { + public var json: [String: Any] { return makeJSON([ "after": encodeIfNotNil(after), "before": encodeIfNotNil(before), @@ -146,9 +145,6 @@ public struct LocatorText: Equatable, Loggable { } public var jsonString: String? { - guard let json = self.json else { - return nil - } return serializeJSONString(json) } @@ -167,24 +163,35 @@ public struct LocatorText: Equatable, Loggable { /// Location : Class that contain the different variables needed to localize a particular position public struct Locations: Equatable, Loggable { /// Contains one or more fragment in the resource referenced by the Locator Object. - public var fragment: String? // 1 = fragment identifier (toc, page lists, landmarks) + public var fragments: [String] = [] /// Progression in the resource expressed as a percentage. - public var progression: Double? // 2 = bookmarks + public var progression: Double? + /// Progression in the publication expressed as a percentage. + public var totalProgression: Double? /// An index in the publication. - public var position: Int? // 3 = goto page + public var position: Int? - public init(fragment: String? = nil, progression: Double? = nil, position: Int? = nil) { - self.fragment = fragment + public init(fragments: [String] = [], progression: Double? = nil, totalProgression: Double? = nil, position: Int? = nil) { + self.fragments = fragments self.progression = progression + self.totalProgression = totalProgression self.position = position } - public init(json: Any) throws { + public init(json: Any?) throws { + if json == nil { + return + } guard let json = json as? [String: Any] else { throw JSONError.parsing(Locations.self) } - self.fragment = json["fragment"] as? String + var fragments = (json["fragments"] as? [String]) ?? [] + if let fragment = json["fragment"] as? String { + fragments.append(fragment) + } + self.fragments = fragments self.progression = json["progression"] as? Double + self.totalProgression = json["totalProgression"] as? Double self.position = json["position"] as? Int } @@ -198,18 +205,20 @@ public struct Locations: Equatable, Loggable { } } - public var json: [String: Any]? { + public var isEmpty: Bool { + return json.isEmpty + } + + public var json: [String: Any] { return makeJSON([ - "fragment": encodeIfNotNil(fragment), + "fragments": encodeIfNotEmpty(fragments), "progression": encodeIfNotNil(progression), + "totalProgression": encodeIfNotNil(totalProgression), "position": encodeIfNotNil(position) ]) } public var jsonString: String? { - guard let json = self.json else { - return nil - } return serializeJSONString(json) } @@ -223,6 +232,11 @@ public struct Locations: Equatable, Loggable { return jsonString } + @available(*, deprecated, message: "Use `fragments.first` instead") + public var fragment: String? { + return fragments.first + } + } @@ -262,8 +276,8 @@ public class Bookmark { public var resourceHref: String { return locator.href } public var resourceType: String { return locator.type } public var resourceTitle: String { return locator.title ?? "" } - public var location: Locations { return locator.locations ?? Locations() } + public var location: Locations { return locator.locations } public var locations: Locations? { return locator.locations } - public var locatorText: LocatorText { return locator.text ?? LocatorText() } + public var locatorText: LocatorText { return locator.text } } diff --git a/r2-shared-swift/Publication/Publication+JSON.swift b/r2-shared-swift/Publication/Publication+JSON.swift index 2b1c6e59..28b32496 100644 --- a/r2-shared-swift/Publication/Publication+JSON.swift +++ b/r2-shared-swift/Publication/Publication+JSON.swift @@ -143,31 +143,6 @@ func parseDate(_ json: Any?) -> Date? { } -// MARK: - JSON Serialization - -func serializeJSONString(_ object: Any) -> String? { - var options: JSONSerialization.WritingOptions = [] - if #available(iOS 11.0, *) { - options.insert(.sortedKeys) - } - guard let data = try? JSONSerialization.data(withJSONObject: object, options: options), - let string = String(data: data, encoding: .utf8) else - { - return nil - } - - // Unescapes slashes - return string.replacingOccurrences(of: "\\/", with: "/") -} - -func serializeJSONData(_ object: Any) -> Data? { - guard let string = serializeJSONString(object) else { - return nil - } - return string.data(using: .utf8) -} - - /// Returns the given JSON object after removing any key with NSNull value. /// To be used with `encodeIfX` functions for more compact serialization code. func makeJSON(_ object: [String: Any], additional: [String: Any] = [:]) -> [String: Any] { diff --git a/r2-shared-swift/Publication/Publication.swift b/r2-shared-swift/Publication/Publication.swift index 61ffc878..5868faec 100644 --- a/r2-shared-swift/Publication/Publication.swift +++ b/r2-shared-swift/Publication/Publication.swift @@ -26,7 +26,25 @@ public class Publication: WebPublication, Loggable { public var format: Format = .unknown /// Version of the publication's format, eg. 3 for EPUB 3 public var formatVersion: String? + + /// Factory used to build lazily the `positionList`. + /// By default, a parser will set this to parse the `positionList` from the publication. But the host app might want to overwrite this with a custom closure to implement for example a cache mechanism. + public var positionListFactory: (Publication) -> [Locator] = { _ in [] } + + /// List of all the positions in the publication. + public lazy var positionList: [Locator] = positionListFactory(self) + /// List of all the positions in each resource, indexed by their `href`. + public lazy var positionListByResource: [String: [Locator]] = positionList + .reduce([:]) { mapping, position in + var mapping = mapping + if mapping[position.href] == nil { + mapping[position.href] = [] + } + mapping[position.href]?.append(position) + return mapping + } + public var userProperties = UserProperties() // The status of User Settings properties (enabled or disabled). @@ -51,9 +69,10 @@ public class Publication: WebPublication, Loggable { ) } - public init(format: Format = .unknown, formatVersion: String? = nil, context: [String] = [], metadata: Metadata, links: [Link] = [], readingOrder: [Link] = [], resources: [Link] = [], tableOfContents: [Link] = [], otherCollections: [PublicationCollection] = []) { + public init(format: Format = .unknown, formatVersion: String? = nil, positionListFactory: @escaping (Publication) -> [Locator] = { _ in [] }, context: [String] = [], metadata: Metadata, links: [Link] = [], readingOrder: [Link] = [], resources: [Link] = [], tableOfContents: [Link] = [], otherCollections: [PublicationCollection] = []) { self.format = format self.formatVersion = formatVersion + self.positionListFactory = positionListFactory super.init(context: context, metadata: metadata, links: links, readingOrder: readingOrder, resources: resources, tableOfContents: tableOfContents, otherCollections: otherCollections) } diff --git a/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBMetadata.swift b/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBMetadata.swift index b6ca7b65..365cccab 100644 --- a/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBMetadata.swift +++ b/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBMetadata.swift @@ -16,7 +16,7 @@ import Foundation /// https://readium.org/webpub-manifest/schema/extensions/epub/metadata.schema.json protocol EPUBMetadata { - var rendition: EPUBRendition? { get set } + var rendition: EPUBRendition { get set } } @@ -25,17 +25,18 @@ private let renditionKey = "rendition" extension Metadata: EPUBMetadata { - public var rendition: EPUBRendition? { + public var rendition: EPUBRendition { get { do { return try EPUBRendition(json: otherMetadata[renditionKey]) } catch { log(.warning, error) - return nil + return EPUBRendition() } } set { - if let json = newValue?.json, !json.isEmpty { + let json = newValue.json + if !json.isEmpty { otherMetadata[renditionKey] = json } else { otherMetadata.removeValue(forKey: renditionKey) diff --git a/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBRendition.swift b/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBRendition.swift index 495c46c4..df263b0f 100644 --- a/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBRendition.swift +++ b/r2-shared-swift/Publication/Web Publication/Extensions/EPUB/EPUBRendition.swift @@ -128,7 +128,7 @@ public struct EPUBRendition: Equatable { /// Indicates the condition to be met for the linked resource to be rendered within a synthetic spread. public var spread: Spread? - + public init(layout: Layout? = nil, orientation: Orientation? = nil, overflow: Overflow? = nil, spread: Spread? = nil) { self.layout = layout self.orientation = orientation @@ -136,9 +136,9 @@ public struct EPUBRendition: Equatable { self.spread = spread } - public init?(json: Any?) throws { - if json == nil { - return nil + public init(json: Any?) throws { + guard json != nil else { + return } guard let json = json as? [String: Any] else { throw JSONError.parsing(EPUBRendition.self) @@ -159,4 +159,12 @@ public struct EPUBRendition: Equatable { ]) } + /// Determines the layout of the given resource in this publication. + /// Default layout is reflowable. + public func layout(of link: Link) -> Layout { + return link.properties.layout + ?? layout + ?? .reflowable + } + } diff --git a/r2-shared-swift/Toolkit/JSONEquatable.swift b/r2-shared-swift/Toolkit/JSON.swift similarity index 64% rename from r2-shared-swift/Toolkit/JSONEquatable.swift rename to r2-shared-swift/Toolkit/JSON.swift index 28045671..4f699ee0 100644 --- a/r2-shared-swift/Toolkit/JSONEquatable.swift +++ b/r2-shared-swift/Toolkit/JSON.swift @@ -1,5 +1,5 @@ // -// JSONEquatable.swift +// JSON.swift // r2-shared-swift // // Created by Mickaƫl Menu on 14.03.19. @@ -12,6 +12,33 @@ import Foundation +// MARK: - JSON Serialization + +public func serializeJSONString(_ object: Any) -> String? { + var options: JSONSerialization.WritingOptions = [] + if #available(iOS 11.0, *) { + options.insert(.sortedKeys) + } + guard let data = try? JSONSerialization.data(withJSONObject: object, options: options), + let string = String(data: data, encoding: .utf8) else + { + return nil + } + + // Unescapes slashes + return string.replacingOccurrences(of: "\\/", with: "/") +} + +public func serializeJSONData(_ object: Any) -> Data? { + guard let string = serializeJSONString(object) else { + return nil + } + return string.data(using: .utf8) +} + + +// MARK: - JSON Equatable + /// Protocol to automatically conforms to Equatable by comparing the JSON representation of a type. /// WARNING: this is only reliable on iOS 11+, because the keys order is not deterministic before. So only use JSON equality comparisons in unit tests, for example. public protocol JSONEquatable: Equatable { @@ -39,5 +66,3 @@ extension JSONEquatable { } } - - diff --git a/r2-shared-swiftTests/Publication/LocatorTests.swift b/r2-shared-swiftTests/Publication/LocatorTests.swift index f2cd829c..01f76a34 100644 --- a/r2-shared-swiftTests/Publication/LocatorTests.swift +++ b/r2-shared-swiftTests/Publication/LocatorTests.swift @@ -94,7 +94,7 @@ class LocatorTests: XCTestCase { Locator( href: "http://locator", type: "", - locations: Locations(fragment: "page=42") + locations: Locations(fragments: ["page=42"]) ) ) } @@ -154,18 +154,31 @@ class LocationTests: XCTestCase { func testParseFullJSON() { XCTAssertEqual( try? Locations(json: [ - "fragment": "frag34", + "fragments": ["p=4", "frag34"], "progression": 0.74, + "totalProgression": 25.32, "position": 42 ]), Locations( - fragment: "frag34", + fragments: ["p=4", "frag34"], progression: 0.74, + totalProgression: 25.32, position: 42 ) ) } + func testParseSingleFragment() { + XCTAssertEqual( + try? Locations(json: [ + "fragment": "frag34", + ]), + Locations( + fragments: ["frag34"] + ) + ) + } + func testParseEmptyJSON() { XCTAssertEqual( try Locations(json: [:]), @@ -191,13 +204,15 @@ class LocationTests: XCTestCase { func testGetFullJSON() { AssertJSONEqual( Locations( - fragment: "frag34", + fragments: ["p=4", "frag34"], progression: 0.74, + totalProgression: 25.32, position: 42 ).json as Any, [ - "fragment": "frag34", + "fragments": ["p=4", "frag34"], "progression": 0.74, + "totalProgression": 25.32, "position": 42 ] ) diff --git a/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBMetadataTests.swift b/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBMetadataTests.swift index 69d3574f..f45c0c8a 100644 --- a/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBMetadataTests.swift +++ b/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBMetadataTests.swift @@ -21,7 +21,7 @@ class EPUBMetadataTests: XCTestCase { } func testNoRendition() { - XCTAssertNil(sut.rendition) + XCTAssertEqual(sut.rendition, EPUBRendition()) } func testRendition() { diff --git a/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBRenditionTests.swift b/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBRenditionTests.swift index fa80a53e..dfa90b60 100644 --- a/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBRenditionTests.swift +++ b/r2-shared-swiftTests/Publication/Web Publication/Extensions/EPUB/EPUBRenditionTests.swift @@ -102,7 +102,7 @@ class EPUBRenditionTests: XCTestCase { } func testParseAllowsNil() { - XCTAssertNil(try EPUBRendition(json: nil)) + XCTAssertEqual(try EPUBRendition(json: nil), EPUBRendition()) } func testGetMinimalJSON() {