449 lines
14 KiB
Swift
449 lines
14 KiB
Swift
import Foundation
|
|
|
|
public struct RDEPUBLocation: Codable, Equatable {
|
|
public var bookIdentifier: String?
|
|
public var href: String
|
|
public var progression: Double
|
|
public var lastProgression: Double?
|
|
public var fragment: String?
|
|
|
|
public init(
|
|
bookIdentifier: String? = nil,
|
|
href: String,
|
|
progression: Double,
|
|
lastProgression: Double? = nil,
|
|
fragment: String? = nil
|
|
) {
|
|
self.bookIdentifier = bookIdentifier
|
|
self.href = href
|
|
self.progression = Self.clamp(progression)
|
|
self.lastProgression = lastProgression.map(Self.clamp)
|
|
self.fragment = fragment?.nilIfEmpty
|
|
}
|
|
|
|
public var navigationProgression: Double {
|
|
let end = lastProgression ?? progression
|
|
if end.isNaN || end.isInfinite {
|
|
return progression
|
|
}
|
|
return Self.clamp((progression + end) / 2.0)
|
|
}
|
|
|
|
private static func clamp(_ value: Double) -> Double {
|
|
guard value.isFinite else { return 0 }
|
|
return min(1, max(0, value))
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBViewportResource: Codable, Equatable {
|
|
public var href: String
|
|
public var spineIndex: Int
|
|
public var progression: Double
|
|
public var lastProgression: Double?
|
|
public var fragment: String?
|
|
|
|
public init(
|
|
href: String,
|
|
spineIndex: Int,
|
|
progression: Double,
|
|
lastProgression: Double? = nil,
|
|
fragment: String? = nil
|
|
) {
|
|
self.href = href
|
|
self.spineIndex = spineIndex
|
|
self.progression = progression
|
|
self.lastProgression = lastProgression
|
|
self.fragment = fragment
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBViewport: Codable, Equatable {
|
|
public var resources: [RDEPUBViewportResource]
|
|
public var visiblePageNumber: Int
|
|
public var chapterIndex: Int?
|
|
public var isFixedLayout: Bool
|
|
|
|
public init(
|
|
resources: [RDEPUBViewportResource],
|
|
visiblePageNumber: Int,
|
|
chapterIndex: Int? = nil,
|
|
isFixedLayout: Bool
|
|
) {
|
|
self.resources = resources
|
|
self.visiblePageNumber = visiblePageNumber
|
|
self.chapterIndex = chapterIndex
|
|
self.isFixedLayout = isFixedLayout
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBReadingContext: Codable, Equatable {
|
|
public var location: RDEPUBLocation
|
|
public var viewport: RDEPUBViewport
|
|
public var pageNumber: Int
|
|
public var chapterIndex: Int?
|
|
|
|
public init(
|
|
location: RDEPUBLocation,
|
|
viewport: RDEPUBViewport,
|
|
pageNumber: Int,
|
|
chapterIndex: Int? = nil
|
|
) {
|
|
self.location = location
|
|
self.viewport = viewport
|
|
self.pageNumber = pageNumber
|
|
self.chapterIndex = chapterIndex
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBSelection: Codable, Equatable {
|
|
public var bookIdentifier: String?
|
|
public var location: RDEPUBLocation
|
|
public var text: String
|
|
public var rangeInfo: String?
|
|
public var createdAt: Date
|
|
|
|
public init(
|
|
bookIdentifier: String? = nil,
|
|
location: RDEPUBLocation,
|
|
text: String,
|
|
rangeInfo: String? = nil,
|
|
createdAt: Date = Date()
|
|
) {
|
|
self.bookIdentifier = bookIdentifier
|
|
self.location = location
|
|
self.text = text
|
|
self.rangeInfo = rangeInfo?.nilIfEmpty
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
public var isEmpty: Bool {
|
|
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
|
|
}
|
|
}
|
|
|
|
public enum RDEPUBHighlightStyle: String, Codable {
|
|
case highlight
|
|
case underline
|
|
}
|
|
|
|
public enum RDEPUBAnnotationMenuAction: Equatable {
|
|
case copy
|
|
case highlight
|
|
case annotate
|
|
}
|
|
|
|
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
|
public var kind: String
|
|
public var href: String
|
|
public var start: Int
|
|
public var end: Int
|
|
|
|
public init(href: String, start: Int, end: Int) {
|
|
self.kind = "text-offset"
|
|
self.href = href
|
|
self.start = start
|
|
self.end = end
|
|
}
|
|
|
|
public var nsRange: NSRange? {
|
|
guard end > start else { return nil }
|
|
return NSRange(location: start, length: end - start)
|
|
}
|
|
|
|
public func jsonString() -> String? {
|
|
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
|
guard let string,
|
|
let data = string.data(using: .utf8),
|
|
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
|
|
rangeInfo.kind == "text-offset",
|
|
rangeInfo.end > rangeInfo.start else {
|
|
return nil
|
|
}
|
|
return rangeInfo
|
|
}
|
|
}
|
|
|
|
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
|
case chapterEnd
|
|
case frameLimit
|
|
case blockBoundary
|
|
case attachmentBoundary
|
|
}
|
|
|
|
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
|
case image
|
|
case generic
|
|
}
|
|
|
|
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
|
public var breakReason: RDEPUBTextPageBreakReason
|
|
public var blockRange: NSRange?
|
|
public var attachmentRanges: [NSRange]
|
|
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
|
public var trailingFragmentID: String?
|
|
public var diagnostics: [String]
|
|
|
|
public init(
|
|
breakReason: RDEPUBTextPageBreakReason,
|
|
blockRange: NSRange? = nil,
|
|
attachmentRanges: [NSRange] = [],
|
|
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
|
trailingFragmentID: String? = nil,
|
|
diagnostics: [String] = []
|
|
) {
|
|
self.breakReason = breakReason
|
|
self.blockRange = blockRange
|
|
self.attachmentRanges = attachmentRanges
|
|
self.attachmentKinds = attachmentKinds
|
|
self.trailingFragmentID = trailingFragmentID
|
|
self.diagnostics = diagnostics
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBHighlight: Codable, Equatable {
|
|
public var id: String
|
|
public var bookIdentifier: String?
|
|
public var location: RDEPUBLocation
|
|
public var text: String
|
|
public var rangeInfo: String?
|
|
public var style: RDEPUBHighlightStyle
|
|
public var color: String
|
|
public var note: String?
|
|
public var createdAt: Date
|
|
|
|
public init(
|
|
id: String = UUID().uuidString,
|
|
bookIdentifier: String? = nil,
|
|
location: RDEPUBLocation,
|
|
text: String,
|
|
rangeInfo: String? = nil,
|
|
style: RDEPUBHighlightStyle = .highlight,
|
|
color: String = "#F8E16C",
|
|
note: String? = nil,
|
|
createdAt: Date = Date()
|
|
) {
|
|
self.id = id
|
|
self.bookIdentifier = bookIdentifier
|
|
self.location = location
|
|
self.text = text
|
|
self.rangeInfo = rangeInfo?.nilIfEmpty
|
|
self.style = style
|
|
self.color = color
|
|
self.note = note?.nilIfEmpty
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
public var hasNote: Bool {
|
|
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case bookIdentifier
|
|
case location
|
|
case text
|
|
case rangeInfo
|
|
case style
|
|
case color
|
|
case note
|
|
case createdAt
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
|
|
location = try container.decode(RDEPUBLocation.self, forKey: .location)
|
|
text = try container.decode(String.self, forKey: .text)
|
|
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
|
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
|
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
|
style = decodedStyle
|
|
} else {
|
|
style = .highlight
|
|
}
|
|
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
|
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
|
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(id, forKey: .id)
|
|
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
|
|
try container.encode(location, forKey: .location)
|
|
try container.encode(text, forKey: .text)
|
|
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
|
|
try container.encode(style, forKey: .style)
|
|
try container.encode(color, forKey: .color)
|
|
try container.encodeIfPresent(note, forKey: .note)
|
|
try container.encode(createdAt, forKey: .createdAt)
|
|
}
|
|
}
|
|
|
|
public struct RDEPUBBookmark: Codable, Equatable {
|
|
public var id: String
|
|
public var bookIdentifier: String?
|
|
public var location: RDEPUBLocation
|
|
public var chapterTitle: String?
|
|
public var note: String?
|
|
public var createdAt: Date
|
|
|
|
public init(
|
|
id: String = UUID().uuidString,
|
|
bookIdentifier: String? = nil,
|
|
location: RDEPUBLocation,
|
|
chapterTitle: String? = nil,
|
|
note: String? = nil,
|
|
createdAt: Date = Date()
|
|
) {
|
|
self.id = id
|
|
self.bookIdentifier = bookIdentifier
|
|
self.location = location
|
|
self.chapterTitle = chapterTitle?.nilIfEmpty
|
|
self.note = note?.nilIfEmpty
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
public var hasNote: Bool {
|
|
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
}
|
|
}
|
|
|
|
public struct EPUBChapterInfo: Codable, Equatable {
|
|
public var spineIndex: Int
|
|
public var title: String
|
|
public var pageCount: Int
|
|
|
|
public init(spineIndex: Int, title: String, pageCount: Int) {
|
|
self.spineIndex = spineIndex
|
|
self.title = title
|
|
self.pageCount = pageCount
|
|
}
|
|
}
|
|
|
|
public struct EPUBPage: Codable, Equatable {
|
|
public var spineIndex: Int
|
|
public var chapterIndex: Int
|
|
public var pageIndexInChapter: Int
|
|
public var totalPagesInChapter: Int
|
|
public var chapterTitle: String
|
|
public var fixedSpread: EPUBFixedSpread?
|
|
|
|
public init(
|
|
spineIndex: Int,
|
|
chapterIndex: Int,
|
|
pageIndexInChapter: Int,
|
|
totalPagesInChapter: Int,
|
|
chapterTitle: String,
|
|
fixedSpread: EPUBFixedSpread?
|
|
) {
|
|
self.spineIndex = spineIndex
|
|
self.chapterIndex = chapterIndex
|
|
self.pageIndexInChapter = pageIndexInChapter
|
|
self.totalPagesInChapter = totalPagesInChapter
|
|
self.chapterTitle = chapterTitle
|
|
self.fixedSpread = fixedSpread
|
|
}
|
|
}
|
|
|
|
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
|
public var spineIndex: Int
|
|
public var href: String
|
|
public var title: String
|
|
public var pageSpread: RDEPUBPageSpread?
|
|
|
|
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
|
|
self.spineIndex = spineIndex
|
|
self.href = href
|
|
self.title = title
|
|
self.pageSpread = pageSpread
|
|
}
|
|
}
|
|
|
|
public struct EPUBFixedSpread: Codable, Equatable {
|
|
public var resources: [EPUBFixedSpreadResource]
|
|
|
|
public init(resources: [EPUBFixedSpreadResource]) {
|
|
self.resources = resources
|
|
}
|
|
|
|
public var primaryResource: EPUBFixedSpreadResource {
|
|
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
|
}
|
|
|
|
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
|
resources.contains { resource in
|
|
normalizer(resource.href) == normalizedHref
|
|
}
|
|
}
|
|
|
|
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
|
let resources = spine.enumerated().map { index, item in
|
|
EPUBFixedSpreadResource(
|
|
spineIndex: index,
|
|
href: item.href,
|
|
title: item.title,
|
|
pageSpread: item.pageSpread
|
|
)
|
|
}
|
|
|
|
guard spreadEnabled, resources.count > 1 else {
|
|
return resources.map { EPUBFixedSpread(resources: [$0]) }
|
|
}
|
|
|
|
var spreads: [EPUBFixedSpread] = []
|
|
var cursor = 0
|
|
|
|
while cursor < resources.count {
|
|
let current = resources[cursor]
|
|
|
|
guard cursor + 1 < resources.count else {
|
|
spreads.append(EPUBFixedSpread(resources: [current]))
|
|
break
|
|
}
|
|
|
|
let next = resources[cursor + 1]
|
|
if shouldPair(current: current, next: next) {
|
|
spreads.append(EPUBFixedSpread(resources: [current, next]))
|
|
cursor += 2
|
|
} else {
|
|
spreads.append(EPUBFixedSpread(resources: [current]))
|
|
cursor += 1
|
|
}
|
|
}
|
|
|
|
return spreads
|
|
}
|
|
|
|
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
|
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
|
}
|
|
|
|
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
|
if current.pageSpread == .center || next.pageSpread == .center {
|
|
return false
|
|
}
|
|
|
|
switch (current.pageSpread, next.pageSpread) {
|
|
case (.left, .right), (.right, .left):
|
|
return true
|
|
case (.left, .left), (.right, .right):
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
var nilIfEmpty: String? {
|
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|