ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift

720 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// RDEPUBReadingModels.swift
// RDReaderView
//
// EPUBCore
// LocationViewportReadingContext
// SelectionHighlightBookmark
// ChapterInfoPage Spread
// RDEPUBReadingSession 穿
//
import Foundation
/// EPUB 使 href + progression
/// progression [0, 1]
public struct RDEPUBLocation: Codable, Equatable {
///
public var bookIdentifier: String?
/// OPF spine
public var href: String
/// [0, 1]
public var progression: Double
/// [0, 1]
public var lastProgression: Double?
/// #section1
public var fragment: String?
/// EPUB
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
}
/// progression lastProgression
/// /
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
return progression
}
return Self.clamp((progression + end) / 2.0)
}
/// [0, 1]
private static func clamp(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(1, max(0, value))
}
}
/// spine
public struct RDEPUBViewportResource: Codable, Equatable {
/// OPF
public var href: String
/// spine
public var spineIndex: Int
/// [0, 1]
public var progression: Double
/// [0, 1]
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
}
}
///
/// spine
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
}
}
///
/// RDEPUBReadingSession
public struct RDEPUBReadingContext: Codable, Equatable {
/// href + progression
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
}
}
/// JS ssReaderSelectionChanged
public struct RDEPUBSelection: Codable, Equatable {
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var text: String
/// DOM Range JSON
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
}
}
///
/// - highlight:
/// - underline: 线
public enum RDEPUBHighlightStyle: String, Codable {
case highlight
case underline
}
/// WXRead WRBookmark
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
case bookmark
case highlight
case underline
}
///
public enum RDEPUBAnnotationMenuAction: Equatable {
case copy
case highlight
case annotate
}
/// EPUB
/// 使 DOM Range DTCoreText
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
/// "text-offset"
public var kind: String
///
public var href: String
///
public var start: Int
///
public var end: Int
///
/// - Parameters:
/// - href:
/// - start:
/// - end:
public init(href: String, start: Int, end: Int) {
self.kind = "text-offset"
self.href = href
self.start = start
self.end = end
}
/// NSRange NSAttributedString
public var nsRange: NSRange? {
guard end > start else { return nil }
return NSRange(location: start, length: end - start)
}
/// JSON
public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
/// JSON kind
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]
/// fragment ID
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
}
}
///
/// Web EPUBDOM Range EPUB
public struct RDEPUBHighlight: Codable, Equatable {
///
public var id: String
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var text: String
/// Web EPUB DOM Range JSON EPUB JSON
public var rangeInfo: String?
/// 线
public var style: RDEPUBHighlightStyle
/// CSS "#F8E16C"
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
}
/// Codable /
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 var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(highlight: self)
}
}
///
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 var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(bookmark: self)
}
}
/// WXRead WRBookmark
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var kind: RDEPUBAnnotationKind
public var location: RDEPUBLocation
public var text: String?
public var rangeInfo: String?
public var color: String?
public var chapterTitle: String?
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
kind: RDEPUBAnnotationKind,
location: RDEPUBLocation,
text: String? = nil,
rangeInfo: String? = nil,
color: String? = nil,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.kind = kind
self.location = location
self.text = text?.nilIfEmpty
self.rangeInfo = rangeInfo?.nilIfEmpty
self.color = color?.nilIfEmpty
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public init(highlight: RDEPUBHighlight) {
self.init(
id: highlight.id,
bookIdentifier: highlight.bookIdentifier,
kind: highlight.style == .underline ? .underline : .highlight,
location: highlight.location,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
color: highlight.color,
chapterTitle: nil,
note: highlight.note,
createdAt: highlight.createdAt
)
}
public init(bookmark: RDEPUBBookmark) {
self.init(
id: bookmark.id,
bookIdentifier: bookmark.bookIdentifier,
kind: .bookmark,
location: bookmark.location,
text: nil,
rangeInfo: nil,
color: nil,
chapterTitle: bookmark.chapterTitle,
note: bookmark.note,
createdAt: bookmark.createdAt
)
}
public var bookmark: RDEPUBBookmark? {
guard kind == .bookmark else { return nil }
return RDEPUBBookmark(
id: id,
bookIdentifier: bookIdentifier,
location: location,
chapterTitle: chapterTitle,
note: note,
createdAt: createdAt
)
}
public var highlight: RDEPUBHighlight? {
guard kind == .highlight || kind == .underline,
let text else {
return nil
}
return RDEPUBHighlight(
id: id,
bookIdentifier: bookIdentifier,
location: location,
text: text,
rangeInfo: rangeInfo,
style: kind == .underline ? .underline : .highlight,
color: color ?? "#F8E16C",
note: note,
createdAt: createdAt
)
}
}
///
public struct EPUBChapterInfo: Codable, Equatable {
/// spine
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
}
}
///
/// RDEPUBReadingSession
public struct EPUBPage: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0
public var chapterIndex: Int
/// 0
public var pageIndexInChapter: Int
///
public var totalPagesInChapter: Int
///
public var chapterTitle: String
/// Spread fixed layout
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
}
}
/// Spread
public struct EPUBFixedSpreadResource: Codable, Equatable {
/// spine
public var spineIndex: Int
///
public var href: String
///
public var title: String
/// spread
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
}
}
/// Spread
/// 1-2 spine
public struct EPUBFixedSpread: Codable, Equatable {
/// spread 1-2
public var resources: [EPUBFixedSpreadResource]
public init(resources: [EPUBFixedSpreadResource]) {
self.resources = resources
}
///
public var primaryResource: EPUBFixedSpreadResource {
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
}
/// href spread
/// - Parameters:
/// - normalizedHref: href
/// - normalizer: href
/// - Returns:
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
resources.contains { resource in
normalizer(resource.href) == normalizedHref
}
}
/// spine spread Spread
/// pageSpread left/right/center
/// - Parameters:
/// - spine: spine
/// - spreadEnabled: spread
/// - Returns: spread
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
}
/// 便 parser spread
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
}
/// spread
/// center left+right right+left
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
}
}
}
/// String nil Codable
private extension String {
/// nil
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}