ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift

462 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 var rangeAnchor: RDEPUBTextRangeAnchor?
public init(
bookIdentifier: String? = nil,
href: String,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil,
rangeAnchor: RDEPUBTextRangeAnchor? = nil
) {
self.bookIdentifier = bookIdentifier
self.href = href
self.progression = Self.clamp(progression)
self.lastProgression = lastProgression.map(Self.clamp)
self.fragment = fragment?.nilIfEmpty
self.rangeAnchor = rangeAnchor
}
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
case semanticBoundary
}
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 blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
public var trailingFragmentID: String?
public var diagnostics: [String]
public init(
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange? = nil,
attachmentRanges: [NSRange] = [],
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
blockKinds: [RDEPUBTextBlockKind] = [],
semanticHints: [RDEPUBTextSemanticHint] = [],
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
trailingFragmentID: String? = nil,
diagnostics: [String] = []
) {
self.breakReason = breakReason
self.blockRange = blockRange
self.attachmentRanges = attachmentRanges
self.attachmentKinds = attachmentKinds
self.blockKinds = blockKinds
self.semanticHints = semanticHints
self.attachmentPlacements = attachmentPlacements
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
}
}