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 } }