feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -75,7 +75,7 @@ public enum RDEPUBCFIDOMPathBuilder {
match.numberOfRanges > 2 else {
return nil
}
return nsAttributes.substring(with: match.range(at: 2)).nilIfEmpty
return nsAttributes.substring(with: match.range(at: 2)).rd_cfiNilIfEmpty
}
static func isIgnorableTag(_ tagName: String) -> Bool {
@@ -162,7 +162,7 @@ enum RDEPUBCFITextNodeMapBuilder {
}
// Use comment/CDATA-stripped HTML for both path building and tag matching
let cleanedHTML = stripCommentsAndCDATA(rawHTML)
let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML)
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
@@ -516,6 +516,14 @@ private struct RDEPUBNormalizedTextIndex {
}
}
extension String {
var rd_cfiNilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
private extension String {
var fnv1a64: UInt64 {
let offsetBasis: UInt64 = 14695981039346656037
@@ -114,7 +114,7 @@ public enum RDEPUBCFIParser {
if let open = rawStep.firstIndex(of: "["),
let close = rawStep.lastIndex(of: "]"),
open < close {
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).nilIfEmpty
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_cfiNilIfEmpty
} else {
idAssertion = nil
}
@@ -34,6 +34,6 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public init(index: Int, idAssertion: String? = nil) {
self.index = index
self.idAssertion = idAssertion?.nilIfEmpty
self.idAssertion = idAssertion?.rd_cfiNilIfEmpty
}
}
@@ -48,7 +48,13 @@ enum RDEPUBCFIRecoveryEngine {
)
}
if let token = tokenRecoveredResult(cfi: cfi, cfiMap: cfiMap, chapterText: chapterText, lastOffset: lastOffset) {
if let token = tokenRecoveredResult(
cfi: cfi,
cfiMap: cfiMap,
chapterText: chapterText,
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
) {
return token
}
@@ -123,6 +129,7 @@ enum RDEPUBCFIRecoveryEngine {
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
chapterText: String?,
fallbackOffset: Int?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
guard let cfiMap,
@@ -149,6 +156,7 @@ enum RDEPUBCFIRecoveryEngine {
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
guard !normalizedText.isEmpty else { return nil }
let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) }
var bestOffset: Int?
var bestScore = Int.max
@@ -161,7 +169,14 @@ enum RDEPUBCFIRecoveryEngine {
lastOffset: lastOffset,
windowRadius: 768
)
let score = abs(calibrated - approximateOffset)
let score = tokenRecoveryScore(
for: anchor,
targetPath: cfi.contentPath,
approximateOffset: approximateOffset,
calibratedOffset: calibrated,
preferredOffset: preferredOffset,
allAnchors: cfiMap.recoveryMetadata.tokenIndex
)
if score < bestScore {
bestScore = score
bestOffset = calibrated
@@ -317,4 +332,49 @@ enum RDEPUBCFIRecoveryEngine {
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
min(max(offset, 0), max(lastOffset, 0))
}
private static func tokenRecoveryScore(
for anchor: RDEPUBCFITokenAnchor,
targetPath: RDEPUBCFIPath,
approximateOffset: Int,
calibratedOffset: Int,
preferredOffset: Int?,
allAnchors: [RDEPUBCFITokenAnchor]
) -> Int {
var score = abs(calibratedOffset - approximateOffset)
score += pathDistance(anchor.cfiPath, targetPath) * 1_024
if let preferredOffset {
score += abs(approximateOffset - preferredOffset)
let expectedOccurrence = nearestOccurrence(
forToken: anchor.token,
preferredOffset: preferredOffset,
anchors: allAnchors
)
score += abs(anchor.occurrence - expectedOccurrence) * 256
}
return score
}
private static func nearestOccurrence(
forToken token: String,
preferredOffset: Int,
anchors: [RDEPUBCFITokenAnchor]
) -> Int {
anchors
.filter { $0.token == token }
.min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })?
.occurrence ?? 0
}
private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int {
let prefix = lhs.commonPrefix(with: rhs).steps.count
let remainingLHS = lhs.steps.dropFirst(prefix)
let remainingRHS = rhs.steps.dropFirst(prefix)
let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in
partial + abs(pair.0.index - pair.1.index)
}
return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2
}
}
@@ -28,7 +28,7 @@ public enum RDEPUBCFIResolver {
if cfi.packagePath.steps.count >= 3 {
manifestStep = cfi.packagePath.steps[2]
} else {
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })
}
let href = manifestStep?.idAssertion
@@ -40,7 +40,7 @@ public enum RDEPUBCFIResolver {
fileIndex = nil
}
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })?.idAssertion
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })?.idAssertion
return RDEPUBCFIResolverResult(
href: href,
fileIndex: fileIndex,
@@ -48,4 +48,4 @@ public enum RDEPUBCFIResolver {
fragmentID: fragmentID
)
}
}
}
@@ -9,8 +9,14 @@ public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var suffix: String?
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
self.prefix = prefix?.nilIfEmpty
self.exact = exact?.nilIfEmpty
self.suffix = suffix?.nilIfEmpty
self.prefix = Self.trimmedOrNil(prefix)
self.exact = Self.trimmedOrNil(exact)
self.suffix = Self.trimmedOrNil(suffix)
}
private static func trimmedOrNil(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -1,26 +1,20 @@
import Foundation
import UIKit
// MARK: - NSAttributedString WXRead com.weread.highlight / com.weread.underline
/// NSAttributedString CoreText
/// WXRead kWRHighlightAttributeName = "com.weread.highlight"
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
/// 线
/// WXRead kWRUnderlineAttributeName = "com.weread.underline"
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
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(
@@ -37,51 +31,38 @@ public struct RDEPUBSelection: Codable, Equatable {
self.createdAt = createdAt
}
///
public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
///
/// - 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
@@ -89,19 +70,16 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
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),
@@ -114,25 +92,24 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
}
}
///
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(
@@ -157,18 +134,15 @@ public struct RDEPUBHighlight: Codable, Equatable {
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
/// CSS hex UIColor WXRead UIColorForHighlightColor35% alpha
public var uiColor: UIColor {
UIColor(rdHexString: color, alpha: 0.35)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
}
/// Codable /
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
@@ -181,7 +155,6 @@ public struct RDEPUBHighlight: Codable, Equatable {
case createdAt
}
/// 使
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
@@ -200,7 +173,6 @@ public struct RDEPUBHighlight: Codable, Equatable {
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)
@@ -219,19 +191,18 @@ public struct RDEPUBHighlight: Codable, Equatable {
}
}
///
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(
@@ -250,7 +221,6 @@ public struct RDEPUBBookmark: Codable, Equatable {
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
@@ -260,7 +230,6 @@ public struct RDEPUBBookmark: Codable, Equatable {
}
}
/// WXRead WRBookmark
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
@@ -358,7 +327,6 @@ public struct RDEPUBAnnotation: Codable, Equatable {
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1,46 +1,43 @@
import Foundation
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(
@@ -66,13 +63,12 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
}
}
///
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) {
@@ -82,20 +78,18 @@ public struct EPUBChapterInfo: Codable, Equatable {
}
}
///
/// 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(
@@ -115,15 +109,14 @@ public struct EPUBPage: Codable, Equatable {
}
}
/// 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) {
@@ -134,38 +127,24 @@ public struct EPUBFixedSpreadResource: Codable, Equatable {
}
}
/// 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(
@@ -204,13 +183,10 @@ public struct EPUBFixedSpread: Codable, Equatable {
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
@@ -226,4 +202,3 @@ public struct EPUBFixedSpread: Codable, Equatable {
}
}
}
@@ -1,26 +1,35 @@
import Foundation
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 var cfi: String?
public var lastCFI: String?
public var rangeCFI: String?
public init(
bookIdentifier: String? = nil,
href: String,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil,
rangeAnchor: RDEPUBTextRangeAnchor? = nil
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
cfi: String? = nil,
lastCFI: String? = nil,
rangeCFI: String? = nil
) {
self.bookIdentifier = bookIdentifier
self.href = href
@@ -28,10 +37,11 @@ public struct RDEPUBLocation: Codable, Equatable {
self.lastProgression = lastProgression.map(Self.clamp)
self.fragment = fragment?.nilIfEmpty
self.rangeAnchor = rangeAnchor
self.cfi = cfi?.nilIfEmpty
self.lastCFI = lastCFI?.nilIfEmpty
self.rangeCFI = rangeCFI?.nilIfEmpty
}
/// progression lastProgression
/// /
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
@@ -40,24 +50,22 @@ public struct RDEPUBLocation: Codable, Equatable {
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(
@@ -75,16 +83,14 @@ public struct RDEPUBViewportResource: Codable, Equatable {
}
}
///
/// spine
public struct RDEPUBViewport: Codable, Equatable {
///
public var resources: [RDEPUBViewportResource]
///
public var visiblePageNumber: Int
///
public var chapterIndex: Int?
///
public var isFixedLayout: Bool
public init(
@@ -100,16 +106,14 @@ public struct RDEPUBViewport: Codable, Equatable {
}
}
///
/// 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(
@@ -125,8 +129,6 @@ public struct RDEPUBReadingContext: Codable, Equatable {
}
}
/// JS ssReaderSelectionChanged
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
@@ -0,0 +1,63 @@
import Foundation
public enum RDEPUBNoteDetector {
private static let noteTokens = [
"noteref",
"footnote",
"endnote",
"rearnote",
"fnref",
"fn-",
"fn_",
"note",
"annotation"
]
public static func makeReferenceIfLikelyNote(
sourceHref: String,
sourceCFI: String?,
targetHref: String,
targetFragment: String?,
targetCFI: String?,
targetElementHTML: String?
) -> RDEPUBNoteReference? {
let haystack = [
targetHref,
targetFragment ?? "",
targetElementHTML ?? ""
].joined(separator: " ").lowercased()
guard noteTokens.contains(where: { haystack.contains($0) }) ||
hasEPUBType("footnote", in: targetElementHTML) ||
hasEPUBType("endnote", in: targetElementHTML) ||
hasEPUBType("rearnote", in: targetElementHTML) else {
return nil
}
return RDEPUBNoteReference(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetHref: targetHref,
targetFragment: targetFragment,
targetCFI: targetCFI,
label: targetFragment,
kind: kind(from: haystack)
)
}
public static func kind(from text: String) -> RDEPUBNoteKind {
let lowered = text.lowercased()
if lowered.contains("endnote") { return .endnote }
if lowered.contains("rearnote") { return .rearnote }
if lowered.contains("footnote") || lowered.contains("fn") { return .footnote }
return .unknown
}
private static func hasEPUBType(_ type: String, in html: String?) -> Bool {
guard let html else { return false }
let pattern = #"epub:type\s*=\s*['"][^'"]*\b"# + NSRegularExpression.escapedPattern(for: type) + #"\b[^'"]*['"]"#
return html.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
}
@@ -0,0 +1,85 @@
import Foundation
public enum RDEPUBNoteKind: String, Codable, Equatable {
case footnote
case endnote
case rearnote
case unknown
}
public struct RDEPUBNoteReference: Codable, Equatable {
public var sourceHref: String
public var sourceCFI: String?
public var targetHref: String
public var targetFragment: String?
public var targetCFI: String?
public var label: String?
public var kind: RDEPUBNoteKind
public init(
sourceHref: String,
sourceCFI: String? = nil,
targetHref: String,
targetFragment: String? = nil,
targetCFI: String? = nil,
label: String? = nil,
kind: RDEPUBNoteKind = .unknown
) {
self.sourceHref = sourceHref
self.sourceCFI = sourceCFI
self.targetHref = targetHref
self.targetFragment = targetFragment
self.targetCFI = targetCFI
self.label = label?.nilIfEmpty
self.kind = kind
}
}
public struct RDEPUBResolvedNote: Equatable {
public var reference: RDEPUBNoteReference
public var sourceLocation: RDEPUBLocation?
public var targetLocation: RDEPUBLocation
public var title: String?
public var html: String
public var plainText: String
public init(
reference: RDEPUBNoteReference,
sourceLocation: RDEPUBLocation? = nil,
targetLocation: RDEPUBLocation,
title: String? = nil,
html: String,
plainText: String
) {
self.reference = reference
self.sourceLocation = sourceLocation
self.targetLocation = targetLocation
self.title = title?.nilIfEmpty
self.html = html
self.plainText = plainText
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,159 @@
import Foundation
public final class RDEPUBNoteResolver {
private let resourceResolver: RDEPUBResourceResolver
public init(resourceResolver: RDEPUBResourceResolver) {
self.resourceResolver = resourceResolver
}
public func resolveInternalLink(
sourceHref: String,
sourceCFI: String?,
targetLocation: RDEPUBLocation,
sourceLocation: RDEPUBLocation? = nil,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBResolvedNote? {
guard let normalizedTarget = resourceResolver.normalizedLocation(
targetLocation,
relativeToSpineIndex: spineIndex,
bookIdentifier: targetLocation.bookIdentifier
) else {
return nil
}
let fragment = normalizedTarget.fragment
guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else {
return nil
}
guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetHref: normalizedTarget.href,
targetFragment: fragment,
targetCFI: normalizedTarget.cfi,
targetElementHTML: elementHTML
) else {
return nil
}
let sanitizedHTML = stripBacklinks(from: elementHTML)
let plainText = plainText(from: sanitizedHTML)
guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
return RDEPUBResolvedNote(
reference: reference,
sourceLocation: sourceLocation,
targetLocation: normalizedTarget,
title: title(for: reference),
html: sanitizedHTML,
plainText: plainText
)
}
public func extractElementHTML(href: String, fragment: String?) -> String? {
guard let fragment,
!fragment.isEmpty,
let fileURL = resourceResolver.fileURL(forRelativePath: href),
let html = try? String(contentsOf: fileURL, encoding: .utf8) else {
return nil
}
return elementHTML(withID: fragment, in: html)
}
private func elementHTML(withID id: String, in html: String) -> String? {
let escapedID = NSRegularExpression.escapedPattern(for: id)
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
guard let match = regex.firstMatch(in: html, options: [], range: nsRange),
let startRange = Range(match.range, in: html),
let tagRange = Range(match.range(at: 1), in: html) else {
return nil
}
let tagName = String(html[tagRange])
guard !String(html[startRange]).hasSuffix("/>") else {
return String(html[startRange])
}
guard let endRange = matchingEndTagRange(
tagName: tagName,
in: html,
after: startRange.upperBound
) else {
return String(html[startRange])
}
return String(html[startRange.lowerBound..<endRange.upperBound])
}
private func stripBacklinks(from html: String) -> String {
let backlinkPattern = #"<a\b[^>]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?</a>"#
guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else {
return html
}
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
return regex.stringByReplacingMatches(in: html, options: [], range: nsRange, withTemplate: "")
}
private func plainText(from html: String) -> String {
let withoutScripts = html
.replacingOccurrences(of: #"<script[\s\S]*?</script>"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"<style[\s\S]*?</style>"#, with: "", options: .regularExpression)
let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
return withoutTags
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&amp;", with: "&")
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
}
private func title(for reference: RDEPUBNoteReference) -> String {
switch reference.kind {
case .footnote:
return ""
case .endnote:
return ""
case .rearnote:
return ""
case .unknown:
return ""
}
}
private func matchingEndTagRange(
tagName: String,
in html: String,
after startIndex: String.Index
) -> Range<String.Index>? {
let escapedTagName = NSRegularExpression.escapedPattern(for: tagName)
let pattern = #"</?\s*"# + escapedTagName + #"\b[^>]*>"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let searchRange = NSRange(startIndex..<html.endIndex, in: html)
var depth = 1
for match in regex.matches(in: html, options: [], range: searchRange) {
guard let range = Range(match.range, in: html) else { continue }
let tag = String(html[range])
if tag.hasPrefix("</") {
depth -= 1
} else if !tag.hasSuffix("/>") {
depth += 1
}
if depth == 0 {
return range
}
}
return nil
}
}
@@ -1,34 +1,28 @@
// RDEPUBAssetRepository.swift
// EPUBCore
// EPUB JS HTML
// {{token}}
import Foundation
/// EPUB
enum RDEPUBAsset: String {
/// Rangy core Web range/selection
case rangyCoreScript = "rangy-core"
/// Rangy serializer DOM range /
case rangySerializerScript = "rangy-serializer"
/// WXRead CSS
case cssInjectorScript = "cssInjector"
/// WXRead WeReadApi JS-Native
case weReadAPIScript = "WeReadApi"
/// JS epub-bridge.js WebView -JS
case bridgeScript = "epub-bridge"
/// HTML epub-fixed-layout.html pre-paginated EPUB
case fixedLayoutTemplate = "epub-fixed-layout"
/// WXRead default.css
case wxReadDefaultCSS = "wxread-default"
/// WXRead replace.css
case wxReadReplaceCSS = "wxread-replace"
/// WXRead dark.css
case wxReadDarkCSS = "wxread-dark"
/// WXRead replaceForLatinLanguageBook.css
case wxReadLatinReplaceCSS = "wxread-replace-latin"
///
var fileExtension: String {
switch self {
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
@@ -41,13 +35,8 @@ enum RDEPUBAsset: String {
}
}
///
enum RDEPUBAssetRepository {
/// {{key}}
/// - Parameters:
/// - asset:
/// - replacements: key value
/// - Returns:
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
var content = try? String(contentsOf: url, encoding: .utf8) else {
@@ -61,7 +50,6 @@ enum RDEPUBAssetRepository {
return content
}
/// Bundle使 bundle宿 bundle
private static var resourceBundle: Bundle {
if let bundle = resolvedBundle {
return bundle
@@ -69,7 +57,6 @@ enum RDEPUBAssetRepository {
return Bundle(for: RDEPUBAssetBundleToken.self)
}
/// RDReaderViewAssets.bundle 宿 bundle framework
private static var resolvedBundle: Bundle? = {
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
for hostBundle in hostBundles {
@@ -82,5 +69,4 @@ enum RDEPUBAssetRepository {
}()
}
/// Bundle
private final class RDEPUBAssetBundleToken {}
@@ -1,25 +1,15 @@
// RDEPUBFixedLayoutTemplate.swift
// Fixed LayoutHTML
// Publication iframe HTML
// HTML
import Foundation
/// spine iframe HTML
enum RDEPUBFixedLayoutTemplate {
/// Publication HTML
/// - Parameters:
/// - request: spread
/// - publication: EPUB URL
/// - Returns: HTML
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
// spread iframe pane HTML
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
return nil
}
// spread ///
let pageType: String
if request.spread.resources.count == 1 {
pageType = "single"
@@ -36,7 +26,6 @@ enum RDEPUBFixedLayoutTemplate {
"""
}.joined()
//
let background = request.backgroundColorCSS ?? "#FFFFFF"
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
@@ -44,7 +33,7 @@ enum RDEPUBFixedLayoutTemplate {
let insetRight = Int(request.contentInset.right.rounded(.down))
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
let insetLeft = Int(request.contentInset.left.rounded(.down))
// HTML
return RDEPUBAssetRepository.string(
for: .fixedLayoutTemplate,
replacements: [
@@ -1,35 +1,27 @@
// RDEPUBJavaScriptBridge.swift
// WebView JavaScript
// JS progression/selection/link/error
// WebView
import Foundation
/// JS WebView
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
///
case progressionChanged = "ssReaderProgressionChanged"
///
case selectionChanged = "ssReaderSelectionChanged"
/// ss-reader://
case internalLink = "ssReaderInternalLink"
/// http/https/mailto/tel
case externalLink = "ssReaderExternalLink"
/// JavaScript
case javaScriptError = "ssReaderJSError"
///
case fixedLayoutReady = "ssReaderFixedLayoutReady"
}
/// JavaScript
enum RDEPUBJavaScriptBridge {
/// JS WKUserContentController
static var messageNames: [String] {
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
}
/// WebView document end
/// 使 JS window.webkit.messageHandlers
static var userScript: String {
[
RDEPUBAssetRepository.string(for: .rangyCoreScript),
@@ -50,10 +42,6 @@ enum RDEPUBJavaScriptBridge {
].joined(separator: "\n\n")
}
///
/// CSS
/// - Parameter request:
/// - Returns: evaluateJavaScript
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
let pageStride = max(1, request.presentation.viewportSize.width)
@@ -87,10 +75,6 @@ enum RDEPUBJavaScriptBridge {
"""
}
///
/// HTML
/// - Parameter request:
/// - Returns: evaluateJavaScript
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
"""
(function() {
@@ -105,11 +89,6 @@ enum RDEPUBJavaScriptBridge {
"""
}
///
/// iframe span
/// 使
/// - Parameter presentation:
/// - Returns:
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """
@@ -121,9 +100,6 @@ enum RDEPUBJavaScriptBridge {
"""
}
///
/// WebView DOM
/// - Returns: evaluateJavaScript highlights search
static func resolveDecorationsScript() -> String {
"""
(function() {
@@ -133,14 +109,12 @@ enum RDEPUBJavaScriptBridge {
"""
}
/// JS
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`")
}
/// JS
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
highlights.compactMap { highlight in
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
@@ -153,7 +127,6 @@ enum RDEPUBJavaScriptBridge {
}
}
/// JS progressionfragment
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
guard let location else { return nil }
return [
@@ -163,7 +136,6 @@ enum RDEPUBJavaScriptBridge {
]
}
/// JS
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
guard let presentation else { return nil }
return [
@@ -178,7 +150,6 @@ enum RDEPUBJavaScriptBridge {
]
}
/// JSON fallback
private static func jsonString(from object: Any?, fallback: String) -> String {
guard let object else { return fallback }
guard JSONSerialization.isValidJSONObject(object),
@@ -189,8 +160,6 @@ enum RDEPUBJavaScriptBridge {
return string
}
/// Swift JavaScript
/// nil "null" JSON
private static func javaScriptStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return jsonString(from: [value], fallback: "[null]")
@@ -1,71 +1,45 @@
//
// RDEPUBModels.swift
// RDReaderView
//
// EPUBCore
// EPUB
// Spine
// RDEPUBParser RDEPUBPublication
//
import Foundation
/// EPUB
/// - reflowable: EPUB
/// - fixed: EPUB
public enum RDEPUBLayout: String, Codable {
case reflowable
case fixed
}
/// 使
/// - webInteractive: + 使 WKWebView
/// - webFixedLayout: EPUB使 WKWebView
/// - textReflowable: EPUB使 DTCoreText
public enum RDEPUBReadingProfile: String, Codable {
case webInteractive
case webFixedLayout
case textReflowable
}
///
/// - ltr:
/// - rtl:
/// - auto:
public enum RDEPUBReadingProgression: String, Codable {
case ltr
case rtl
case auto
}
/// spread
/// - left:
/// - right:
/// - center:
public enum RDEPUBPageSpread: String, Codable {
case left
case right
case center
}
/// EPUB OPF <metadata>
///
public struct RDEPUBMetadata: Codable, Equatable {
/// <dc:identifier>
public var identifier: String?
/// <dc:title>
public var title: String
/// <dc:creator>
public var author: String?
/// <dc:language> "zh""en"
public var language: String?
/// EPUB "2.0""3.0"
public var version: String?
/// reflowable fixed
public var layout: RDEPUBLayout
/// spread <meta property="rendition:spread">
public var spread: String?
/// ltr / rtl / auto
public var readingProgression: RDEPUBReadingProgression
public init(
@@ -89,22 +63,20 @@ public struct RDEPUBMetadata: Codable, Equatable {
}
}
/// EPUB OPF <manifest> <item>
/// IDMIME
public struct RDEPUBManifestItem: Codable, Equatable {
/// <item id="...">
public var id: String
/// OPF
public var href: String
/// MIME "application/xhtml+xml""image/jpeg""text/css"
public var mediaType: String
/// "nav" "scripted" "cover-image"
public var properties: [String]
/// 退 ID <item fallback="...">
public var fallback: String?
/// IDEPUB 3
public var mediaOverlay: String?
///
public var title: String?
public init(
@@ -125,37 +97,31 @@ public struct RDEPUBManifestItem: Codable, Equatable {
self.title = title
}
/// EPUB 3 Nav Document
/// properties "nav"
public var isNavigationDocument: Bool {
properties.contains("nav")
}
/// NCX EPUB 2
/// MIME "application/x-dtbncx+xml"
public var isNCX: Bool {
mediaType == "application/x-dtbncx+xml"
}
}
/// EPUB Spine OPF <spine> <itemref>
/// Spine 线
public struct RDEPUBSpineItem: Codable, Equatable {
/// manifest item id
public var idref: String
/// OPF
public var href: String
/// MIME
public var mediaType: String
/// manifest
public var title: String
/// 线"no" 线
public var linear: Bool
/// rendition:layout-*
public var properties: [String]
/// spread //
public var pageSpread: RDEPUBPageSpread?
/// layout
public var layout: RDEPUBLayout?
public init(
@@ -179,14 +145,12 @@ public struct RDEPUBSpineItem: Codable, Equatable {
}
}
/// EPUB
/// NCX EPUB 2 Nav DocumentEPUB 3
public struct EPUBTableOfContentsItem: Codable, Equatable {
///
public var title: String
/// fragment
public var href: String
///
public var children: [EPUBTableOfContentsItem]
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
@@ -196,23 +160,22 @@ public struct EPUBTableOfContentsItem: Codable, Equatable {
}
}
/// EPUB ZIP OPF
public enum RDEPUBParserError: LocalizedError {
/// ZIP
case archiveOpenFailed(URL)
/// META-INF/container.xml EPUB
case missingContainerXML
/// container.xml <rootfile>
case missingRootFile
/// OPF
case invalidRootFilePath(String)
/// XML
case invalidXML(URL)
/// spine manifest idref
case missingManifestItem(idref: String)
/// spine
case emptySpine
/// 穿
case invalidArchiveEntryPath(String)
public var errorDescription: String? {
@@ -1,22 +1,16 @@
// RDEPUBNavigatorLayoutContext.swift
//
//
// Paginator 使
import UIKit
///
/// Paginator WebView
public struct RDEPUBNavigatorLayoutContext: Equatable {
///
public var containerSize: CGSize
/// 2
public var pagesPerScreen: Int
/// Home Indicator
public var safeAreaInsets: UIEdgeInsets
/// phone/pad
public var userInterfaceIdiom: UIUserInterfaceIdiom
///
public var reflowableContentInsets: UIEdgeInsets
public init(
@@ -33,7 +27,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
self.reflowableContentInsets = reflowableContentInsets
}
/// 2
public var viewportSize: CGSize {
let width: CGFloat
if pagesPerScreen > 1 {
@@ -44,7 +37,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
return CGSize(width: width, height: containerSize.height)
}
/// 使Pad
public var fixedContentInset: UIEdgeInsets {
var insets = safeAreaInsets
if userInterfaceIdiom != .phone {
@@ -1,33 +1,20 @@
//
// RDEPUBNavigatorState.swift
// RDReaderView
//
// EPUBCore
//
// initializing loading idle jumping/moving/repaginating
// RDEPUBReadingSession
//
import Foundation
///
/// initializing loading idle jumping/moving/repaginating
public enum RDEPUBNavigatorState: String, Codable {
///
case initializing
///
case loading
///
case idle
/// /
case jumping
///
case moving
/// /
case repaginating
///
/// idle staged snapshot
public var isStableForSnapshotApplication: Bool {
self == .idle
}
@@ -1,45 +1,38 @@
// RDEPUBPaginator.swift
// EPUB
// 使 WKWebView spine JS scrollWidth
// 0ms/80ms/180ms
// Fixed Layout 1
import UIKit
import WebKit
/// EPUB WebView spine
public final class RDEPUBPaginator: NSObject {
///
private var parser: RDEPUBParser?
/// WebView
private weak var hostingView: UIView?
///
private var presentation = RDEPUBPresentationStyle(
viewportSize: .zero,
contentInsets: .zero,
fontSize: 16,
lineHeightMultiple: 1.5
)
/// spine
private var completion: (([Int]) -> Void)?
/// spine
private var singlePageCountCompletion: ((Int) -> Void)?
/// spine
private var pageCounts: [Int] = []
/// spine
private var measurementIndices: [Int] = []
/// measurementIndices
private var currentMeasurementOffset = 0
/// spine
private var pendingMeasurementValue = 1
/// 0/1/2 0ms/80ms/180ms
private var measurementPass = 0
/// ID
private var activeSessionID = UUID()
///
private let debugScope = "PaginatorWebView"
/// WebView
private lazy var webView: WKWebView = {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
@@ -70,12 +63,6 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview()
}
/// spine
/// - Parameters:
/// - parser: EPUB
/// - hostingView: WebView
/// - presentation:
/// - completion: spine
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
@@ -106,7 +93,6 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem()
}
/// 便使 spine
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
@@ -129,13 +115,6 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// spine
/// - Parameters:
/// - parser: EPUB
/// - spineIndex: spine
/// - hostingView: WebView
/// - presentation:
/// - completion: spine
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
@@ -167,7 +146,6 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem()
}
/// 便使 spine
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
@@ -192,7 +170,6 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// CSS HTML
public static func injectPaginationCSS(
into html: String,
viewportSize: CGSize,
@@ -215,7 +192,6 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// spine WebView
private func measureNextSpineItem() {
guard let parser else {
finishIfNeeded()
@@ -253,12 +229,10 @@ public final class RDEPUBPaginator: NSObject {
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
}
/// spine
private func currentSpineIndexForMeasurement() -> Int? {
measurementIndices[safe: currentMeasurementOffset]
}
/// spine
private func finishIfNeeded() {
if let singlePageCountCompletion {
let measuredSpineIndex = measurementIndices.first ?? 0
@@ -273,7 +247,6 @@ public final class RDEPUBPaginator: NSObject {
cleanupMeasurementState()
}
/// WebView
private func cleanupMeasurementState() {
parser = nil
hostingView = nil
@@ -289,16 +262,13 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview()
}
/// spine HTML/XHTML/XML
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
let mediaType = item.mediaType.lowercased()
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
}
///
private var measurementPassValues: [Int] = []
/// 0ms/80ms/180ms
private func scheduleMeasurementPass() {
let sessionID = activeSessionID
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
@@ -308,7 +278,7 @@ public final class RDEPUBPaginator: NSObject {
return
}
let finalValue = max(1, pendingMeasurementValue)
//
if measurementPassValues.count >= 2 {
let minVal = measurementPassValues.min() ?? 1
let maxVal = measurementPassValues.max() ?? 1
@@ -331,7 +301,6 @@ public final class RDEPUBPaginator: NSObject {
}
}
/// JS scrollWidth
private func measureCurrentDocument(sessionID: UUID) {
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
@@ -357,6 +326,7 @@ public final class RDEPUBPaginator: NSObject {
}
extension RDEPUBPaginator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
@@ -402,7 +372,6 @@ extension RDEPUBPaginator: WKNavigationDelegate {
}
}
/// Array nil
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
@@ -1,15 +1,9 @@
// RDEPUBParser+Archive.swift
// EPUB container.xml
// EPUB ZIP ~/Library/Caches/ssreaderview-epub/
// META-INF/container.xml OPF
import Foundation
import ZIPFoundation
extension RDEPUBParser {
/// container.xml OPFPackage Document
/// - Parameter containerURL: container.xml
/// - Returns: OPF "OEBPS/content.opf"
func parseContainerRootFile(at containerURL: URL) throws -> String {
guard let parser = XMLParser(contentsOf: containerURL) else {
throw RDEPUBParserError.invalidXML(containerURL)
@@ -30,10 +24,6 @@ extension RDEPUBParser {
return rootFilePath
}
/// EPUB ZIP
/// slug + +
/// - Parameter epubURL: EPUB
/// - Returns: URL
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL)
@@ -66,17 +56,12 @@ extension RDEPUBParser {
return extractionURL
}
/// 穿
/// - Parameters:
/// - entryPath:
/// - extractionRoot:
/// - Returns: URL nil
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
//
if entryPath.hasPrefix("/") {
return nil
}
// ..
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
if components.contains(where: { $0 == ".." }) {
return nil
@@ -84,15 +69,13 @@ extension RDEPUBParser {
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
let standardizedDest = destinationURL.standardizedFileURL.path
let standardizedRoot = extractionRoot.standardizedFileURL.path
//
guard standardizedDest.hasPrefix(standardizedRoot) else {
return nil
}
return destinationURL
}
/// EPUB
/// ~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
@@ -106,14 +89,12 @@ extension RDEPUBParser {
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
}
///
func reset() {
extractionRootURL = nil
opfURL = nil
resetPublicationState()
}
/// metadatamanifestspine
func resetPublicationState() {
metadata = RDEPUBMetadata()
manifest = [:]
@@ -122,9 +103,8 @@ extension RDEPUBParser {
}
}
/// container.xml SAX <rootfile> full-path
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
/// OPF
private(set) var rootFilePath: String?
func parser(
@@ -1,18 +1,8 @@
// RDEPUBParser+Package.swift
// OPFPackage Document Spine
// OPFPackageParserDelegateSAX metadata/manifest/spine
// RDEPUBSpineItem rendition:layoutpage-spread
// XMLNameXMLSectionXMLParserContext XML
import Foundation
extension RDEPUBParser {
/// OPF spine
/// manifest hreffixed/reflowablepage-spread
/// - Parameters:
/// - packageDocument: OPF metadatamanifestspineReferences
/// - opfURL: OPF
/// - Returns: spine
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
let publicationLayout = packageDocument.metadata.layout
@@ -62,47 +52,52 @@ extension RDEPUBParser {
}
}
/// OPF metadatamanifestspine NCX/nav
struct OPFPackageDocument {
var metadata: RDEPUBMetadata
var manifest: [RDEPUBManifestItem]
var manifestByID: [String: RDEPUBManifestItem]
var spineReferences: [OPFSpineReference]
/// NCX manifest EPUB 2
var ncxItem: RDEPUBManifestItem?
/// Navigation Document manifest EPUB 3
var navigationItem: RDEPUBManifestItem?
}
/// OPF spine/itemref
struct OPFSpineReference {
/// manifest item id
var idref: String
/// 线"no"
var linear: Bool
/// rendition:layout-pre-paginatedpage-spread-left
var properties: [String]
/// //
var pageSpread: RDEPUBPageSpread?
}
/// OPF SAX
/// metadataidentifier/title/creator/language/rendition:layout
/// manifestitem spineitemref
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var metadata = RDEPUBMetadata()
private var manifestItems: [RDEPUBManifestItem] = []
private var spineReferences: [OPFSpineReference] = []
private let xmlContext = XMLParserContext()
private var uniqueIdentifierID: String?
private var currentIdentifierElementID: String?
private var currentMetaProperty: String?
private var identifierByID: [String: String] = [:]
private var manifestTitleByID: [String: String] = [:]
private var currentMetaRefinesID: String?
private var ncxID: String?
/// OPFPackageDocument
func packageDocument() -> OPFPackageDocument {
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
metadata.identifier = fallbackIdentifier
@@ -251,7 +246,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
xmlContext.endElement()
}
/// OPF metadata/manifest/spine/other
private var currentSection: XMLSection {
if xmlContext.containsElement(named: "manifest") {
return .manifest
@@ -265,7 +259,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return .other
}
/// <meta> rendition:layoutrendition:spreadidentifier
private func applyMetaValue(_ value: String) {
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
@@ -290,7 +283,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
}
}
/// page-spread properties page-spread
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
if let properties = attributes["properties"]?.lowercased() {
if properties.contains("page-spread-left") {
@@ -320,13 +312,11 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return nil
}
/// rendition:layout
private func layout(from rawValue: String) -> RDEPUBLayout {
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
}
}
/// OPF
enum XMLSection {
case metadata
case manifest
@@ -334,14 +324,12 @@ enum XMLSection {
case other
}
/// XML refines ID
enum XMLName {
/// "dc:title" -> "title"
static func localName(from rawName: String) -> String {
rawName.split(separator: ":").last.map(String.init) ?? rawName
}
/// token
static func tokenize(_ rawValue: String?) -> [String] {
guard let rawValue else { return [] }
return rawValue
@@ -349,7 +337,6 @@ enum XMLName {
.map(String.init)
}
/// refines ID "#"
static func refinedID(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("#") else {
@@ -359,14 +346,12 @@ enum XMLName {
}
}
/// XML
final class XMLParserContext {
///
private var elementStack: [String] = []
///
private var currentCharacters = ""
///
var trimmedCharacters: String {
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -1,13 +1,8 @@
// RDEPUBParser+ReadingProfile.swift
// EPUB
// metadata layout manifest/spine
// webFixedLayoutwebInteractive/ textReflowable
import Foundation
extension RDEPUBParser {
///
/// /iframe/
public func readingProfile() -> RDEPUBReadingProfile {
if metadata.layout == .fixed {
return .webFixedLayout
@@ -15,9 +10,6 @@ extension RDEPUBParser {
return hasInteractiveContent() ? .webInteractive : .textReflowable
}
///
/// manifest "scripted"
/// HTML script/iframe/video/audio/canvas/svg/form
public func hasInteractiveContent() -> Bool {
let interactiveMediaTypes = [
"application/javascript",
@@ -42,8 +34,6 @@ extension RDEPUBParser {
return false
}
/// HTML Web
/// EPUB SVG `<svg>` interactive
private func containsInteractiveMarkup(_ html: String) -> Bool {
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
@@ -1,18 +1,13 @@
// RDEPUBParser+Resources.swift
// EPUB URL
// IDhref manifest spine href
// ss-reader:// URL HTML
import Foundation
import UIKit
extension RDEPUBParser {
/// ID manifest
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
manifest[id]
}
/// href manifest fragment
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let target = href.components(separatedBy: "#").first ?? href
return manifest.values.first { item in
@@ -20,13 +15,11 @@ extension RDEPUBParser {
}
}
/// spine href
public func href(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else { return nil }
return spine[index].href
}
/// URL
public func fileURL(forRelativePath relativePath: String) -> URL? {
guard let opfDirectoryURL else { return nil }
let path = relativePath.components(separatedBy: "#").first ?? relativePath
@@ -41,7 +34,6 @@ extension RDEPUBParser {
return resolvedURL
}
/// ss-reader://book/ URL WebView
public func resourceURL(forRelativePath relativePath: String) -> URL? {
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !normalizedPath.isEmpty else { return nil }
@@ -55,7 +47,6 @@ extension RDEPUBParser {
return components.url
}
/// ss-reader://book/ URL URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
@@ -65,7 +56,6 @@ extension RDEPUBParser {
return fileURL(forRelativePath: relativePath)
}
/// "cover-image" ID "cover" manifest
public func coverImage() -> UIImage? {
let coverCandidates = manifest.values.filter { item in
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
@@ -79,7 +69,6 @@ extension RDEPUBParser {
return nil
}
/// spine HTML
public func htmlString(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else {
return nil
@@ -87,7 +76,6 @@ extension RDEPUBParser {
return htmlString(forRelativePath: spine[index].href)
}
/// HTML
public func htmlString(forRelativePath relativePath: String) -> String? {
guard let fileURL = fileURL(forRelativePath: relativePath) else {
return nil
@@ -95,7 +83,6 @@ extension RDEPUBParser {
return try? String(contentsOf: fileURL)
}
/// href URL
func normalize(href: String, relativeTo directoryURL: URL) -> String {
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
return href
@@ -1,14 +1,8 @@
// RDEPUBParser+TOC.swift
// EPUB
// NCXEPUB 2 Navigation DocumentEPUB 3
// 使 NCX Nav Document退 spine
// 使 TOCTreeBuilder
import Foundation
extension RDEPUBParser {
/// OPF
/// NCX Nav Document退 spine
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
@@ -31,7 +25,6 @@ extension RDEPUBParser {
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
}
/// NCXEPUB 2
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: ncxURL.path),
let parser = XMLParser(contentsOf: ncxURL) else {
@@ -49,7 +42,6 @@ extension RDEPUBParser {
return delegate.items
}
/// Navigation DocumentEPUB 3
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: navURL.path),
let parser = XMLParser(contentsOf: navURL) else {
@@ -67,7 +59,6 @@ extension RDEPUBParser {
return delegate.items
}
/// href fragment
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
let pathPart = components.first.map(String.init) ?? href
@@ -91,13 +82,11 @@ extension RDEPUBParser {
}
}
/// href EPUBTableOfContentsItem
private final class TOCNode {
var title = ""
var href: String?
var children: [TOCNode] = []
///
func asItem() -> EPUBTableOfContentsItem? {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard let href, !href.isEmpty else {
@@ -111,14 +100,12 @@ private final class TOCNode {
}
}
/// beginNode/endNode
private final class TOCTreeBuilder {
///
private var nodeStack: [TOCNode] = []
///
private var rootNodes: [TOCNode] = []
///
var items: [EPUBTableOfContentsItem] {
rootNodes.compactMap { $0.asItem() }
}
@@ -151,7 +138,6 @@ private final class TOCTreeBuilder {
}
}
/// NCX SAX <navPoint><content><text>
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String?
@@ -216,7 +202,6 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate {
}
}
/// Navigation Document SAX epub:type="toc" <nav>
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String?
@@ -303,7 +288,6 @@ private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
}
}
/// <nav> TOC epub:typetyperole
private func isTOCNav(attributes: [String: String]) -> Bool {
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
return true
@@ -1,41 +1,30 @@
// RDEPUBParser.swift
// EPUB
// epubURL ZIP container.xml OPF spine/TOC
// EPUBCore Publication makePublication() RDEPUBPublication
// +Archive+Package+TOC+Resources+ReadingProfile
import Foundation
/// EPUB epub
/// epubURL extractArchiveIfNeeded container.xml OPF metadata/manifest/spine/TOC
public final class RDEPUBParser {
///
public internal(set) var metadata = RDEPUBMetadata()
/// key manifest item ID
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
/// spine
public internal(set) var spine: [RDEPUBSpineItem] = []
/// NCX Nav Document
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
/// EPUB
public internal(set) var extractionRootURL: URL?
/// OPF
public internal(set) var opfURL: URL?
/// OPF URL
public var opfDirectoryURL: URL? {
opfURL?.deletingLastPathComponent()
}
public init() {}
/// RDEPUBPublication
public func makePublication() -> RDEPUBPublication {
RDEPUBPublication(parser: self)
}
/// EPUB container.xml OPF
/// - Parameter epubURL: EPUB
public func parse(epubURL: URL) throws {
reset()
@@ -56,8 +45,6 @@ public final class RDEPUBParser {
try parseOPF(at: packageURL)
}
/// OPF metadata/manifest/spine/TOC
/// - Parameter opfURL: OPF
public func parseOPF(at opfURL: URL) throws {
resetPublicationState()
@@ -81,12 +68,10 @@ public final class RDEPUBParser {
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
}
///
public func parseTOC() -> [EPUBTableOfContentsItem] {
tableOfContents
}
/// Navigation Document
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
}
@@ -1,33 +1,28 @@
// RDEPUBPreferences.swift
// EPUB
//
// 便
import UIKit
/// EPUB
public struct RDEPUBPreferences: Equatable {
/// pt
public var fontSize: CGFloat
/// 1.5 1.5
public var lineHeightMultiple: CGFloat
///
public var reflowableContentInsets: UIEdgeInsets
///
public var fixedContentInset: UIEdgeInsets
/// CSS "#FFFFFF"
public var themeBackgroundColor: String?
/// CSS
public var themeTextColor: String?
///
public var fixedBackgroundColor: String?
/// page/width/height/contain/cover
public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// never/always/automatic
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
///
public var numberOfColumns: Int
///
public var columnGap: CGFloat
public init(
@@ -56,7 +51,6 @@ public struct RDEPUBPreferences: Equatable {
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
}
///
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
RDEPUBPresentationStyle(
viewportSize: viewportSize,
@@ -70,7 +64,6 @@ public struct RDEPUBPreferences: Equatable {
)
}
///
public func renderRequest(
for page: EPUBPage,
publication: RDEPUBPublication,
@@ -1,67 +1,49 @@
// RDEPUBPublication.swift
// EPUB Facade
// RDEPUBParser metadataspineTOC 访
// spread
// EPUBCore Publication
import Foundation
/// EPUB 访
public final class RDEPUBPublication {
///
public let parser: RDEPUBParser
/// URL
public let resourceResolver: RDEPUBResourceResolver
///
/// - Parameter parser: EPUB
public init(parser: RDEPUBParser) {
self.parser = parser
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
}
///
public var metadata: RDEPUBMetadata {
parser.metadata
}
///
public var manifest: [String: RDEPUBManifestItem] {
parser.manifest
}
///
public var spine: [RDEPUBSpineItem] {
parser.spine
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
parser.tableOfContents
}
/// fixed/reflowable
public var layout: RDEPUBLayout {
metadata.layout
}
/// webFixedLayout/webInteractive/textReflowable
public var readingProfile: RDEPUBReadingProfile {
parser.readingProfile()
}
/// ltr/rtl/auto
public var readingProgression: RDEPUBReadingProgression {
metadata.readingProgression
}
///
public var bookIdentifier: String? {
metadata.identifier
}
///
/// metadata.spread
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
guard layout == .fixed else {
return false
@@ -80,7 +62,6 @@ public final class RDEPUBPublication {
}
}
/// spread
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
EPUBFixedSpread.makeSpreads(
spine: spine,
@@ -1,58 +1,46 @@
// RDEPUBReadingSession.swift
// EPUB
// initializing/loading/idle/jumping/moving/repaginating
//
//
import Foundation
/// EPUB
/// initializing loading idle jumping/moving/repaginating
public final class RDEPUBReadingSession {
/// +
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
///
public let publication: RDEPUBPublication
///
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
///
public private(set) var activePages: [EPUBPage] = []
///
public private(set) var activeChapters: [EPUBChapterInfo] = []
///
public private(set) var stagedPages: [EPUBPage]?
///
public private(set) var stagedChapters: [EPUBChapterInfo]?
///
public private(set) var stagedRestoreLocation: RDEPUBLocation?
/// fragment
public private(set) var pendingNavigationLocation: RDEPUBLocation?
///
public private(set) var pendingNavigationPageNum: Int?
/// Range Web 使
public private(set) var pendingNavigationHighlightRangeInfo: String?
///
public private(set) var currentViewport: RDEPUBViewport?
/// + + +
public private(set) var currentReadingContext: RDEPUBReadingContext?
public init(publication: RDEPUBPublication) {
self.publication = publication
}
/// 访
public var resourceResolver: RDEPUBResourceResolver {
publication.resourceResolver
}
///
public func transition(to state: RDEPUBNavigatorState) {
navigatorState = state
}
/// initializing
public func resetRuntimeState() {
navigatorState = .initializing
activePages = []
@@ -63,20 +51,17 @@ public final class RDEPUBReadingSession {
currentReadingContext = nil
}
///
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
activePages = snapshot.pages
activeChapters = snapshot.chapters
}
///
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
stagedPages = snapshot.pages
stagedChapters = snapshot.chapters
stagedRestoreLocation = restoreLocation
}
///
public func stagedSnapshot() -> PaginationSnapshot? {
guard let stagedPages, let stagedChapters else {
return nil
@@ -84,7 +69,6 @@ public final class RDEPUBReadingSession {
return (stagedPages, stagedChapters)
}
///
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
guard navigatorState.isStableForSnapshotApplication,
let stagedPages,
@@ -96,21 +80,18 @@ public final class RDEPUBReadingSession {
return ((stagedPages, stagedChapters), restoreLocation)
}
///
public func clearStagedSnapshot() {
stagedPages = nil
stagedChapters = nil
stagedRestoreLocation = nil
}
///
public func clearPendingNavigation() {
pendingNavigationLocation = nil
pendingNavigationPageNum = nil
pendingNavigationHighlightRangeInfo = nil
}
/// spine
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationLocation else {
@@ -126,7 +107,6 @@ public final class RDEPUBReadingSession {
return pendingNavigationLocation
}
/// Range
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationHighlightRangeInfo,
@@ -146,7 +126,6 @@ public final class RDEPUBReadingSession {
return pendingNavigationHighlightRangeInfo
}
/// spine spread
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread {
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
@@ -154,7 +133,6 @@ public final class RDEPUBReadingSession {
return page.spineIndex == spineIndex
}
/// 退
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
if let fixedSpread = page.fixedSpread {
return RDEPUBLocation(
@@ -184,7 +162,6 @@ public final class RDEPUBReadingSession {
)
}
///
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard currentPageNumber > 0,
activePages.indices.contains(currentPageNumber - 1) else {
@@ -193,7 +170,6 @@ public final class RDEPUBReadingSession {
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
}
/// spine
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
guard let location,
let normalizedHref = resourceResolver.normalizedHref(location.href),
@@ -203,7 +179,6 @@ public final class RDEPUBReadingSession {
return spineIndex
}
///
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
return nil
@@ -244,7 +219,6 @@ public final class RDEPUBReadingSession {
return candidates[localIndex].offset
}
/// jumping
public func queueNavigation(
to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@@ -271,7 +245,6 @@ public final class RDEPUBReadingSession {
return pageIndex + 1
}
/// idle
public func updateReadingContext(
pageNumber: Int,
location: RDEPUBLocation,
@@ -320,13 +293,10 @@ public final class RDEPUBReadingSession {
}
}
/// 退
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
}
///
/// spread spine
public func makePaginationSnapshot(
pageCounts: [Int],
preferences: RDEPUBPreferences,
@@ -1,53 +1,34 @@
//
// RDEPUBRenderRequest.swift
// RDReaderView
//
// EPUBCore
// PresentationStyle/RenderRequest
// Fit Spread SpreadMode
// RDEPUBPreferences RDEPUBWebView
//
import UIKit
///
/// - auto:
/// - page:
/// - width:
public enum RDEPUBFixedLayoutFit: String, Codable {
case auto
case page
case width
}
/// Spread
/// - automatic:
/// - always:
/// - never:
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
case automatic
case always
case never
}
/// WebView Paginator
/// RDEPUBPreferences
public struct RDEPUBPresentationStyle: Equatable {
///
public var viewportSize: CGSize
///
public var contentInsets: UIEdgeInsets
/// pt
public var fontSize: CGFloat
/// 1.0
public var lineHeightMultiple: CGFloat
/// WXRead
public var numberOfColumns: Int
/// WXRead columnGap
public var columnGap: CGFloat
/// CSS "#FFFFFF"
public var themeBackgroundColor: String?
/// CSS "#000000"
public var themeTextColor: String?
public init(
@@ -71,25 +52,24 @@ public struct RDEPUBPresentationStyle: Equatable {
}
}
/// reflowable spine
public struct RDEPUBReflowableRenderRequest: Equatable {
/// spine
public var spineIndex: Int
///
public var href: String
///
public var pageIndex: Int
///
public var totalPagesInChapter: Int
///
public var presentation: RDEPUBPresentationStyle
///
public var targetLocation: RDEPUBLocation?
/// DOM Range Web
public var targetHighlightRangeInfo: String?
///
public var highlights: [RDEPUBHighlight]
///
public var searchPresentation: RDEPUBSearchPresentation?
public init(
@@ -115,19 +95,18 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
}
}
/// fixed spread
public struct RDEPUBFixedRenderRequest: Equatable {
/// spread 1-2
public var spread: EPUBFixedSpread
///
public var viewportSize: CGSize
///
public var contentInset: UIEdgeInsets
/// CSS
public var backgroundColorCSS: String?
/// auto/page/width
public var fit: RDEPUBFixedLayoutFit
///
public var searchPresentation: RDEPUBSearchPresentation?
public init(
@@ -147,15 +126,12 @@ public struct RDEPUBFixedRenderRequest: Equatable {
}
}
///
/// RDEPUBWebView
public enum RDEPUBRenderRequest: Equatable {
///
case reflowable(RDEPUBReflowableRenderRequest)
///
case fixed(RDEPUBFixedRenderRequest)
///
public var isFixedLayout: Bool {
if case .fixed = self {
return true
@@ -163,7 +139,6 @@ public enum RDEPUBRenderRequest: Equatable {
return false
}
/// spine
public var primarySpineIndex: Int {
switch self {
case .reflowable(let request):
@@ -173,7 +148,6 @@ public enum RDEPUBRenderRequest: Equatable {
}
}
///
public var primaryHref: String {
switch self {
case .reflowable(let request):
@@ -1,41 +1,30 @@
// RDEPUBResourceResolver.swift
// EPUB
// RDEPUBParser href
// spine Services
// RDEPUBPublication RDEPUBReadingSession 广使
import Foundation
/// EPUB URL href
public final class RDEPUBResourceResolver {
///
private let parser: RDEPUBParser
public init(parser: RDEPUBParser) {
self.parser = parser
}
/// OPF URL
public var opfDirectoryURL: URL? {
parser.opfDirectoryURL
}
/// URL
public func fileURL(forRelativePath relativePath: String) -> URL? {
parser.fileURL(forRelativePath: relativePath)
}
/// ss-reader://book/ URL
public func resourceURL(forRelativePath relativePath: String) -> URL? {
parser.resourceURL(forRelativePath: relativePath)
}
/// ss-reader://book/ URL URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
parser.fileURL(forResourceURL: resourceURL)
}
/// href OPF spine OPF
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
@@ -70,7 +59,6 @@ public final class RDEPUBResourceResolver {
return pathPart
}
/// href href
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
@@ -93,7 +81,6 @@ public final class RDEPUBResourceResolver {
return pathPart
}
/// href URL
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
return nil
@@ -101,7 +88,6 @@ public final class RDEPUBResourceResolver {
return fileURL(forRelativePath: normalizedHref)
}
/// href progression fragment
public func normalizedLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@@ -131,18 +117,19 @@ public final class RDEPUBResourceResolver {
progression: location.progression,
lastProgression: location.lastProgression,
fragment: fragment,
rangeAnchor: location.rangeAnchor
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
/// href spine
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
parser.spine.firstIndex { item in
self.normalizedHref(item.href) == normalizedHref
}
}
/// spine
public func spineIndex(for location: RDEPUBLocation) -> Int? {
guard let normalizedHref = normalizedHref(location.href) else {
return nil
@@ -150,7 +137,6 @@ public final class RDEPUBResourceResolver {
return spineIndex(forNormalizedHref: normalizedHref)
}
/// spine href
public func href(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else {
return nil
@@ -158,7 +144,6 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].href
}
/// spine
public func title(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else {
return nil
@@ -166,7 +151,6 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].title
}
/// href manifest
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
return parser.manifest.values.first {
@@ -1,12 +1,7 @@
// RDEPUBResourceURLSchemeHandler.swift
// ss-reader:// URL
// WKURLSchemeHandler ss-reader://book/<path>
// EPUB MIME
import Foundation
import WebKit
/// URL ss-reader://book/ EPUB
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
struct DebugMetrics {
let streamedResponses: Int
@@ -14,18 +9,16 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
let failures: Int
}
///
public static let scheme = "ss-reader"
///
public static let host = "book"
///
private weak var parser: RDEPUBParser?
///
private let fileManager = FileManager.default
/// 访
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
/// URL Scheme
private var activeTasks: [ObjectIdentifier: Bool] = [:]
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
private static var streamedResponseCount = 0
@@ -55,8 +48,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// URL Scheme
/// //CSS/JS 404
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
syncQueue.sync {
@@ -100,20 +91,17 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// URL Scheme
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
}
///
private func clearTask(_ id: ObjectIdentifier) {
syncQueue.sync {
activeTasks[id] = nil
}
}
///
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
syncQueue.sync {
activeTasks[id] == true
@@ -203,7 +191,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// MIME
private static func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() {
case "html", "htm":
@@ -243,7 +230,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// UTF-8
private static func textEncodingName(for pathExtension: String) -> String? {
switch pathExtension.lowercased() {
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
@@ -253,7 +239,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// //CSS/JS
private static func isOptionalResource(_ url: URL) -> Bool {
switch url.pathExtension.lowercased() {
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
@@ -1,37 +1,23 @@
// RDEPUBSearchEngine.swift
// EPUB
// spine 线 HTML/XHTML
// hrefprogressionpreviewText
import Foundation
import UIKit
///
protocol RDEPUBSearchEngine {
///
/// - Parameter keyword:
/// - Returns:
func search(keyword: String) -> [RDEPUBSearchMatch]
}
/// HTML
/// HTML
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
/// EPUB HTML
private let parser: RDEPUBParser
/// EPUB spine href
private let publication: RDEPUBPublication
/// HTML
/// - Parameters:
/// - parser: EPUB HTML
/// - publication: EPUB spine href
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
self.parser = parser
self.publication = publication
}
/// 线 spine
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@@ -53,7 +39,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return searchMatches
}
/// HTML 使 NSAttributedString
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
guard let data = html.data(using: .utf8) else {
return fallbackPlainText(fromHTML: html)
@@ -73,7 +58,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return fallbackPlainText(fromHTML: html)
}
/// HTML HTML
private func fallbackPlainText(fromHTML html: String) -> String {
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
return stripped
@@ -85,7 +69,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
.replacingOccurrences(of: "&quot;", with: "\"")
}
///
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
let source = text as NSString
let fullLength = source.length
@@ -127,7 +110,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return results
}
/// 12
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
@@ -1,27 +1,26 @@
// RDEPUBSearchModels.swift
// EPUB
// RDEPUBSearchMatchRDEPUBSearchResult
// RDEPUBSearchStateRDEPUBSearchPresentation
import Foundation
///
public struct RDEPUBSearchMatch: Codable, Equatable {
/// href
public var href: String
/// 0.0 ~ 1.0
public var progression: Double
///
public var previewText: String
///
public var localMatchIndex: Int
///
public var rangeLocation: Int?
///
public var rangeLength: Int
///
public var rangeAnchor: RDEPUBTextRangeAnchor?
public var cfi: String?
public var rangeCFI: String?
public init(
href: String,
progression: Double,
@@ -29,7 +28,9 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
localMatchIndex: Int,
rangeLocation: Int? = nil,
rangeLength: Int,
rangeAnchor: RDEPUBTextRangeAnchor? = nil
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
cfi: String? = nil,
rangeCFI: String? = nil
) {
self.href = href
self.progression = progression
@@ -38,18 +39,19 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
self.rangeLocation = rangeLocation
self.rangeLength = rangeLength
self.rangeAnchor = rangeAnchor
self.cfi = cfi
self.rangeCFI = rangeCFI
}
}
///
public struct RDEPUBSearchResult: Codable, Equatable {
///
public var keyword: String
///
public var totalMatchCount: Int
/// 1
public var currentMatchIndex: Int?
///
public var currentMatch: RDEPUBSearchMatch?
public init(
@@ -65,13 +67,12 @@ public struct RDEPUBSearchResult: Codable, Equatable {
}
}
///
public struct RDEPUBSearchState: Codable, Equatable {
///
public var keyword: String
///
public var matches: [RDEPUBSearchMatch]
/// 0
public var currentMatchIndex: Int?
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
@@ -80,7 +81,6 @@ public struct RDEPUBSearchState: Codable, Equatable {
self.currentMatchIndex = currentMatchIndex
}
///
public var currentMatch: RDEPUBSearchMatch? {
guard let currentMatchIndex,
matches.indices.contains(currentMatchIndex) else {
@@ -89,7 +89,6 @@ public struct RDEPUBSearchState: Codable, Equatable {
return matches[currentMatchIndex]
}
///
public var result: RDEPUBSearchResult {
RDEPUBSearchResult(
keyword: keyword,
@@ -100,13 +99,12 @@ public struct RDEPUBSearchState: Codable, Equatable {
}
}
///
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
/// href
public var href: String
///
public var matchCount: Int
///
public var activeLocalMatchIndex: Int?
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
@@ -116,11 +114,10 @@ public struct RDEPUBSearchPresentationResource: Codable, Equatable {
}
}
/// JS
public struct RDEPUBSearchPresentation: Codable, Equatable {
///
public var keyword: String
///
public var resources: [RDEPUBSearchPresentationResource]
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
@@ -1,13 +1,8 @@
// RDEPUBStyleSheetBuilder.swift
// EPUB CSS
// CSS CSS
// HTML JS
import Foundation
/// CSS CSS/JS
public enum RDEPUBStyleSheetBuilder {
/// CSS HTML </head> <body
public static func injectPaginationCSS(
into html: String,
presentation: RDEPUBPresentationStyle
@@ -22,8 +17,6 @@ public enum RDEPUBStyleSheetBuilder {
return styleTag + html
}
/// CSSbody#ss-reader-viewport#ss-reader-content
///
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@@ -106,7 +99,6 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// CSS CSS transform/will-change
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@@ -153,7 +145,6 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// JS CSS scrollWidth
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
let style = measurementCSS(for: presentation)
.replacingOccurrences(of: "\\", with: "\\\\")
@@ -185,7 +176,6 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// CSS
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
viewportWidth: String,
viewportHeight: String,
@@ -1,26 +1,18 @@
// RDEPUBTextAnchor.swift
// EPUB
// RDEPUBTextAnchor fileIndex/row/column/chapterOffset/fragmentID
// RDEPUBTextRangeAnchor
// Codable spineIndex/fileIndex key
import Foundation
/// EPUB
/// fragment ID
public struct RDEPUBTextAnchor: Codable, Equatable {
/// spineIndex
public let fileIndex: Int
///
public let row: Int
///
public let column: Int
///
public let chapterOffset: Int
/// fragment ID URL
public let fragmentID: String?
/// spine fileIndex
public var spineIndex: Int { fileIndex }
public init(
@@ -67,11 +59,10 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
}
}
///
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
///
public let start: RDEPUBTextAnchor
///
public let end: RDEPUBTextAnchor
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
@@ -79,7 +70,6 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
self.end = end
}
/// NSRange chapterOffset
public var nsRange: NSRange {
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
}
@@ -1,14 +1,9 @@
// RDEPUBWebView+Configuration.swift
// RDEPUBWebView WKWebView
// WKWebView JS
// URL Scheme
// // WebView
import UIKit
import WebKit
extension RDEPUBWebView {
/// WKWebView Publication WebView
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
let parser = publication.parser
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
@@ -73,18 +68,15 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
}
/// //
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
delegate?.epubWebView(self, didRequestSelectionAction: action)
clearWebSelection()
}
/// WebView
func clearWebSelection() {
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
}
/// WebView
func teardownWebView() {
cancelFixedLayoutReadyFallback()
if let webView {
@@ -106,7 +98,7 @@ extension RDEPUBWebView {
}
extension UIColor {
/// UIColor CSS "#FF0000"
var ss_hexString: String {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -1,13 +1,9 @@
// RDEPUBWebView+FixedLayout.swift
// RDEPUBWebView
// Fixed Layout HTML
// 退
import UIKit
import WebKit
extension RDEPUBWebView {
/// 便 spread
public func loadFixedSpread(
parser: RDEPUBParser,
spread: EPUBFixedSpread,
@@ -28,7 +24,6 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request)
}
/// HTML WebView
func handleFixedLayoutLoad(
publication: RDEPUBPublication,
request: RDEPUBFixedRenderRequest,
@@ -55,11 +50,10 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
webView.loadHTMLString(
html,
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme):///")
)
}
///
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
let searchSignature = [
request.searchPresentation?.keyword ?? "",
@@ -87,7 +81,6 @@ extension RDEPUBWebView {
].joined(separator: "#")
}
/// 退1 ready
func scheduleFixedLayoutReadyFallback() {
let workItem = DispatchWorkItem { [weak self] in
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
@@ -101,7 +94,6 @@ extension RDEPUBWebView {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
}
/// 退
func cancelFixedLayoutReadyFallback() {
fixedLayoutReadyWorkItem?.cancel()
fixedLayoutReadyWorkItem = nil
@@ -1,14 +1,9 @@
// RDEPUBWebView+JavaScriptBridge.swift
// RDEPUBWebView JS
// WKNavigationDelegate
// WKScriptMessageHandler JS ////
//
import Foundation
import WebKit
extension RDEPUBWebView {
/// URL ss-reader://
func internalLocation(from url: URL) -> RDEPUBLocation? {
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
let fragment = url.fragment
@@ -16,7 +11,6 @@ extension RDEPUBWebView {
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
}
/// JS
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
@@ -52,7 +46,6 @@ extension RDEPUBWebView: WKNavigationDelegate {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
}
/// ss-reader://http/https/mailto/tel
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
guard navigationAction.navigationType == .linkActivated,
@@ -79,7 +72,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
}
extension RDEPUBWebView: WKScriptMessageHandler {
/// JS
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
switch message.name {
@@ -1,14 +1,9 @@
// RDEPUBWebView+Reflowable.swift
// RDEPUBWebView
// Reflowable
// JS
//
import UIKit
import WebKit
extension RDEPUBWebView {
/// 便
public func loadPage(
parser: RDEPUBParser,
spineIndex: Int,
@@ -46,7 +41,6 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request)
}
///
func handleReflowableLoad(
publication: RDEPUBPublication,
request: RDEPUBReflowableRenderRequest,
@@ -91,7 +85,6 @@ extension RDEPUBWebView {
webView.load(URLRequest(url: requestURL))
}
///
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
let targetSignature = [
request.targetLocation?.href ?? "",
@@ -139,7 +132,6 @@ extension RDEPUBWebView {
].joined(separator: "#")
}
/// JS CSS
func applyPresentation() {
guard let webView, let currentRenderRequest else { return }
guard case .reflowable(let request) = currentRenderRequest else {
@@ -1,6 +1,3 @@
// RDEPUBWebView+Search.swift
// RDEPUBWebView
// WebView
import UIKit
import WebKit
@@ -14,7 +11,6 @@ extension RDEPUBWebView {
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
}
/// WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
completion?()
@@ -1,39 +1,30 @@
// RDEPUBWebView.swift
// EPUB WebView
// WKWebView EPUB Resource View
// spineIndexpageIndex
//
// +Configuration+FixedLayout+Reflowable+JavaScriptBridge+Search
import UIKit
import WebKit
/// RDEPUBWebView WebView
public protocol RDEPUBWebViewDelegate: AnyObject {
///
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
///
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
///
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
///
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
///
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
/// JavaScript
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
///
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
}
///
public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
}
/// WKWebView //
final class RDEPUBAnnotationWebView: WKWebView {
///
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override var canBecomeFirstResponder: Bool {
@@ -64,63 +55,60 @@ final class RDEPUBAnnotationWebView: WKWebView {
}
}
/// EPUB WebView WKWebView EPUB
/// Resource View
public final class RDEPUBWebView: UIView {
///
public weak var delegate: RDEPUBWebViewDelegate?
/// delegate 使
public var onRendered: (() -> Void)?
///
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
///
var publication: RDEPUBPublication?
///
var currentRenderRequest: RDEPUBRenderRequest?
/// WKWebView
var webView: WKWebView?
/// URL Scheme
var schemeHandler: RDEPUBResourceURLSchemeHandler?
/// WebView
var configuredPublicationKey: String?
///
var currentLoadSignature: String?
/// spine
var currentSpineIndex = 0
/// href
var currentHref = ""
/// 0
var currentPageIndex = 0
///
var currentTotalPagesInChapter = 1
///
var viewportSize: CGSize = .zero
///
var currentPadding: UIEdgeInsets = .zero
///
var currentFontSize: CGFloat = 16
///
var currentLineHeightMultiple: CGFloat = 1.5
///
var currentThemeBackgroundColor: String?
///
var currentThemeTextColor: String?
///
var targetLocation: RDEPUBLocation?
///
var pendingHighlights: [RDEPUBHighlight] = []
/// spread
var fixedSpread: EPUBFixedSpread?
///
var isFixedLayout = false
///
var pendingProgressionRequest = false
/// 退
var fixedLayoutReadyWorkItem: DispatchWorkItem?
///
var didRenderCurrentRequest = false
///
let debugScope = "ReaderWebView"
public override init(frame: CGRect) {
@@ -141,7 +129,6 @@ public final class RDEPUBWebView: UIView {
webView?.frame = bounds
}
/// WebView
public func reset() {
delegate = nil
onRendered = nil
@@ -167,7 +154,6 @@ public final class RDEPUBWebView: UIView {
teardownWebView()
}
/// WebView
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
configureWebViewIfNeeded(publication: publication)
guard let webView else { return }
@@ -194,7 +180,6 @@ public final class RDEPUBWebView: UIView {
}
}
///
func pageLoadSignature(
publicationKey: String,
request: RDEPUBRenderRequest
@@ -207,7 +192,6 @@ public final class RDEPUBWebView: UIView {
}
}
/// 100ms
func rendered() {
guard !didRenderCurrentRequest else { return }
didRenderCurrentRequest = true
@@ -1,15 +1,9 @@
// RDEPUBWebViewDebug.swift
// WebView
// JS
// URL Scheme
// DEBUG Release UserDefaults
import Foundation
import WebKit
/// WebView
enum RDEPUBWebViewDebug {
/// DEBUG UserDefaults "RDEPUBWebViewDebugEnabled"
static var isEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
return configured
@@ -21,8 +15,6 @@ enum RDEPUBWebViewDebug {
#endif
}()
/// UserDefaults "RDEPUBWebViewVerboseEnabled"
/// logMessage
static var isVerboseEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
return configured
@@ -30,8 +22,6 @@ enum RDEPUBWebViewDebug {
return false
}()
/// inspectable
/// UserDefaults
static var isInspectableEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
return configured
@@ -39,25 +29,21 @@ enum RDEPUBWebViewDebug {
return false
}()
///
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
isInspectableEnabled = inspectableEnabled
isVerboseEnabled = verboseLoggingEnabled
}
/// WebView WebView
static func webViewID(_ webView: WKWebView?) -> String {
guard let webView else { return "nil-webview" }
return String(ObjectIdentifier(webView).hashValue, radix: 16)
}
///
static func log(_ scope: String, message: String) {
guard isEnabled else { return }
print("[RDReaderWK][\(scope)] \(message)")
}
/// WebView IDURL
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
guard isEnabled else { return }
let webViewToken = webViewID(webView)
@@ -69,13 +55,11 @@ enum RDEPUBWebViewDebug {
}
}
/// JavaScript
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
}
/// JS verbose
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
guard isEnabled else { return }
if isVerboseEnabled {
@@ -97,7 +81,6 @@ enum RDEPUBWebViewDebug {
}
}
/// URL Scheme URL URL
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
guard isEnabled else { return }
let requestText = summarizedURL(requestURL)
@@ -109,7 +92,6 @@ enum RDEPUBWebViewDebug {
}
}
/// URL ss-reader:// URL
static func summarizedURL(_ url: URL?) -> String {
guard let url else { return "nil" }
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
@@ -89,6 +89,60 @@
}
}
function parseCssPixelValue(value) {
if (!value) { return 0; }
var match = String(value).match(/([0-9]+(?:\.[0-9]+)?)px?/);
return match ? Number.parseFloat(match[1]) : 0;
}
function sizeFromElement(element) {
if (!element) { return null; }
var width = parseCssPixelValue(element.style && element.style.width);
var height = parseCssPixelValue(element.style && element.style.height);
if (!width || !height) {
width = parseCssPixelValue(element.getAttribute && element.getAttribute('width'));
height = parseCssPixelValue(element.getAttribute && element.getAttribute('height'));
}
if (!width || !height) {
var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
if (rect) {
width = width || rect.width;
height = height || rect.height;
}
}
if (width > 1 && height > 1) {
return { width: width, height: height };
}
return null;
}
function parsePageSizeFromDocumentBox(iframe) {
try {
var doc = iframe.contentWindow.document;
return sizeFromElement(doc.body) ||
sizeFromElement(doc.documentElement) ||
sizeFromElement(doc.querySelector('[id$="_hype_container"]')) ||
sizeFromElement(doc.querySelector('iframe')) ||
null;
} catch (error) {
return null;
}
}
function parsePageSizeFromNestedFrame(iframe) {
try {
var nestedFrame = iframe.contentWindow.document.querySelector('iframe');
if (!nestedFrame || !nestedFrame.contentWindow || !nestedFrame.contentWindow.document) {
return null;
}
return parsePageSizeFromViewportMetaTag(nestedFrame) ||
parsePageSizeFromDocumentBox(nestedFrame) ||
parsePageSizeFromEmbeddedImage(nestedFrame);
} catch (error) {
return null;
}
}
function parsePageSizeFromEmbeddedImage(iframe) {
try {
var img = iframe.contentWindow.document.querySelector('img');
@@ -176,9 +230,14 @@
function onLoad() {
iframe.__ssPageSize =
parsePageSizeFromViewportMetaTag(iframe) ||
parsePageSizeFromDocumentBox(iframe) ||
parsePageSizeFromNestedFrame(iframe) ||
parsePageSizeFromEmbeddedImage(iframe) ||
pageViewportSize(iframe);
layoutPage(iframe);
window.setTimeout(function() {
layoutPage(iframe);
}, 250);
finalizeLoad();
}
@@ -204,4 +263,4 @@
})();
</script>
</body>
</html>
</html>
@@ -1,16 +1,18 @@
import Foundation
/// Builds diagnostics and human-readable summaries for a text book build.
struct RDEPUBBuildDiagnosticsReporter {
func phase7SemanticSummary(
title: String?,
diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
) -> String? {
guard !diagnostics.isEmpty else { return nil }
let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
let note = diagnostics
.flatMap(\.sampleNotes)
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
@@ -22,6 +24,7 @@ struct RDEPUBBuildDiagnosticsReporter {
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let note {
parts.append(note)
}
@@ -38,11 +41,14 @@ struct RDEPUBBuildDiagnosticsReporter {
title: title,
pageCount: pages.count,
breakReasons: pages.map(\.metadata.breakReason),
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
sampleNotes: Array(pages.flatMap(\.metadata.diagnostics).prefix(4))
)
}
@@ -1,12 +1,13 @@
import Foundation
/// Normalizes suspicious trailing or whitespace-only page frames after pagination.
struct RDEPUBChapterTailNormalizer {
func normalize(
_ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString,
href: String
) -> [RDEPUBTextLayoutFrame] {
guard frames.count > 1 else { return frames }
var normalized = frames
@@ -16,10 +17,12 @@ struct RDEPUBChapterTailNormalizer {
for frame in normalized {
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
if var previous = compacted.popLast() {
previous.diagnostics.append(note)
compacted.append(previous)
} else {
#if DEBUG
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
#endif
@@ -73,6 +76,7 @@ struct RDEPUBChapterTailNormalizer {
previousFrame: RDEPUBTextLayoutFrame,
in content: NSAttributedString
) -> Bool {
guard trailingFrame.contentRange.length > 0,
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
return false
@@ -80,6 +84,7 @@ struct RDEPUBChapterTailNormalizer {
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
guard visibleCount <= 2,
trailingFrame.contentRange.length <= 2,
visibleCount > 0 || trailingAttachmentCount > 0 else {
@@ -94,6 +99,7 @@ struct RDEPUBChapterTailNormalizer {
_ previousFrame: RDEPUBTextLayoutFrame,
with trailingFrame: RDEPUBTextLayoutFrame
) -> RDEPUBTextLayoutFrame {
let mergedRange = NSRange(
location: previousFrame.contentRange.location,
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
@@ -109,6 +115,7 @@ struct RDEPUBChapterTailNormalizer {
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
diagnostics: previousFrame.diagnostics
+ trailingFrame.diagnostics
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
@@ -121,6 +128,7 @@ struct RDEPUBChapterTailNormalizer {
) -> Int {
guard range.length > 0 else { return 0 }
let string = content.attributedSubstring(from: range).string
let filteredScalars = string.unicodeScalars.filter { scalar in
!CharacterSet.whitespacesAndNewlines.contains(scalar)
&& !CharacterSet.controlCharacters.contains(scalar)
@@ -1,8 +1,9 @@
import UIKit
/// Keeps pagination cache key generation and cache IO in one BuildPipeline role.
struct RDEPUBPaginationCacheCoordinator {
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
@@ -32,11 +33,13 @@ struct RDEPUBPaginationCacheCoordinator {
func save(chapters: [RDEPUBTextChapter], key: String?) {
guard let key else { return }
let paginationCache = chapters.map { chapter in
RDEPUBTextChapterPaginationCache(
href: chapter.href,
pageRanges: chapter.pages.map(\.contentRange),
breakReasons: chapter.pages.map(\.metadata.breakReason),
semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
)
}
@@ -1,6 +1,5 @@
import UIKit
/// EPUB EPUB publication `RDEPUBTextBook`
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
@@ -12,20 +11,14 @@ public final class RDEPUBTextBookBuilder {
private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
///
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
///
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
///
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
/// /
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
///
/// - Parameters:
/// - renderer:
/// - cache:
/// - layoutConfig:
public init(
renderer: RDEPUBTextRenderer,
cache: RDEPUBTextBookCache? = nil,
@@ -42,7 +35,6 @@ public final class RDEPUBTextBookBuilder {
self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
}
/// 使 DTCoreText
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
@@ -51,7 +43,6 @@ public final class RDEPUBTextBookBuilder {
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
}
/// Phase 7
public func phase7SemanticSummary(title: String? = nil) -> String? {
diagnosticsReporter.phase7SemanticSummary(
title: title,
@@ -59,16 +50,6 @@ public final class RDEPUBTextBookBuilder {
)
}
/// EPUB publication
///
///
/// 1.
/// 2. spine 线 HTML
/// 3. HTML NSAttributedString
/// 4. /
/// 5. 使 CoreText
/// 6.
/// 7. RDEPUBTextBook
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -88,7 +69,6 @@ public final class RDEPUBTextBookBuilder {
let buildStart = CFAbsoluteTimeGetCurrent()
// WXRead
let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheCoordinator.load(key: cacheKey)
@@ -134,7 +114,6 @@ public final class RDEPUBTextBookBuilder {
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
cacheCoordinator.save(chapters: chapters, key: cacheKey)
#if DEBUG
@@ -145,7 +124,6 @@ public final class RDEPUBTextBookBuilder {
return book
}
/// spine
public func buildChapter(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -192,6 +170,7 @@ public final class RDEPUBTextBookBuilder {
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: item.href,
spineIndex: spineIndex,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
@@ -281,11 +260,13 @@ public final class RDEPUBTextBookBuilder {
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
let normalizedFrames = tailNormalizer.normalize(
layoutFrames,
content: content,
href: item.href
)
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
@@ -339,6 +320,12 @@ public final class RDEPUBTextBookBuilder {
title: chapterTitle,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
cfiMap: RDEPUBCFITextNodeMapBuilder.makeMap(
href: item.href,
rawHTML: rawHTML,
chapterText: chapterAttributedContent.string,
fragmentOffsets: rendered.fragmentOffsets
),
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
@@ -365,9 +352,6 @@ public final class RDEPUBTextBookBuilder {
)
}
// MARK: -
/// 退 spine item title href
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
tocItem.href.components(separatedBy: "#").first == item.href
@@ -378,16 +362,12 @@ public final class RDEPUBTextBookBuilder {
return trimmedTitle.isEmpty ? item.href : trimmedTitle
}
///
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in
[item] + flattenedTOCItems(from: item.children)
}
}
// MARK: -
/// /
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
let lowercasedHref = item.href.lowercased()
var hasAttachment = false
@@ -404,9 +384,6 @@ public final class RDEPUBTextBookBuilder {
return false
}
// MARK: -
///
private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 }
var count = 0
@@ -418,7 +395,6 @@ public final class RDEPUBTextBookBuilder {
return count
}
/// NSRange
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] }
var ranges: [NSRange] = []
@@ -430,9 +406,6 @@ public final class RDEPUBTextBookBuilder {
return ranges
}
// MARK: -
/// href cover
private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem,
content: NSAttributedString,
@@ -445,6 +418,7 @@ public final class RDEPUBTextBookBuilder {
}
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
let collapsed = content.string
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
@@ -1,19 +1,14 @@
import Foundation
import CryptoKit
// MARK: - WXRead
///
///
/// WXRead WRChapterPageCount NSRange +
/// NSAttributedString HTML CoreText
public struct RDEPUBTextChapterPaginationCache: Equatable {
///
public var href: String
///
public var pageRanges: [NSRange]
///
public var breakReasons: [RDEPUBTextPageBreakReason]
public var semanticHints: [RDEPUBTextSemanticHint]
public init(
@@ -29,11 +24,8 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
}
}
// MARK: - NSCoding 使
/// NSKeyedArchiver
///
final class PaginationCacheArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let chapters: [ChapterPaginationArchive]
@@ -52,20 +44,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
}
}
/// NSRange location/length 便 NSSecureCoding
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let href: String
/// rangeLengths
let rangeLocations: [NSNumber]
///
let rangeLengths: [NSNumber]
///
let breakReasons: [String]
///
let semanticHints: [String]
///
init(from cache: RDEPUBTextChapterPaginationCache) {
self.href = cache.href
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
@@ -97,7 +89,6 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
self.semanticHints = semanticHints
}
///
func toCache() -> RDEPUBTextChapterPaginationCache {
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
NSRange(location: loc.intValue, length: len.intValue)
@@ -111,28 +102,14 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
}
}
// MARK: -
/// WXRead WRChapterPageCount
///
///
/// - = SHA256(ID + + + + + schema)
/// - = NSRange + NSKeyedArchiver
/// - HTML CoreText
/// - 线 serial DispatchQueue
public final class RDEPUBTextBookCache {
///
//
public var schemaVersion: Int = 6
/// 线
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
///
private let cacheDirectory: URL
///
/// - Parameter subdirectory: Caches
public init(subdirectory: String = "RDEPUBTextBookCache") {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent(subdirectory, isDirectory: true)
@@ -141,13 +118,6 @@ public final class RDEPUBTextBookCache {
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: -
// WRChapterPageCount.currentCacheKeyWithBookId
// bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
/// SHA256 + ".cache"
///
///
public func cacheKey(
bookID: String,
fontSize: CGFloat,
@@ -162,10 +132,6 @@ public final class RDEPUBTextBookCache {
return hex + ".cache"
}
// MARK: - /
/// href
/// nil
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -203,7 +169,6 @@ public final class RDEPUBTextBookCache {
}
}
///
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -223,9 +188,6 @@ public final class RDEPUBTextBookCache {
}
}
// MARK: -
///
public func invalidateAll() {
queue.sync {
let fileManager = FileManager.default
@@ -1,93 +1,99 @@
import UIKit
// MARK: -
///
/// /
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String
public var title: String
public var pageCount: Int
///
public var breakReasons: [RDEPUBTextPageBreakReason]
///
public var attachmentPageCount: Int
/// /
public var blockAdjustedPageCount: Int
public var blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 4
public var sampleNotes: [String]
}
// MARK: -
///
public struct RDEPUBTextChapterBuildResult {
public var chapter: RDEPUBTextChapter
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
public var performanceSample: RDEPUBTextPerformanceSample
public var cacheHit: Bool
}
// MARK: -
///
///
/// `RDEPUBTextPage` `RDEPUBTextLayoutFrame`
/// `contentRange`
public struct RDEPUBTextPage: Equatable {
/// 0
public var absolutePageIndex: Int
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var chapterTitle: String
/// 0
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
///
public var chapterContent: NSAttributedString
///
public var content: NSAttributedString
///
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
///
public var metadata: RDEPUBTextPageMetadata
}
// MARK: -
///
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var title: String
///
public var attributedContent: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
public var cfiMap: RDEPUBCFIMap?
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
// MARK: -
/// EPUB
///
/// EPUBTextRendering `RDEPUBTextBookBuilder.build()`
/// `chapterData(for:)` `chapterData(atChapterIndex:)` `RDEPUBChapterData`
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage]
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
/// WXRead <-> <->
public var positionConverter: RDEPUBTextPositionConverter {
RDEPUBTextPositionConverter(book: self)
}
@@ -102,31 +108,26 @@ public struct RDEPUBTextBook {
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
}
/// href 访
public func chapterData(for href: String) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
/// spine 访
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
/// 访
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
guard chapters.indices.contains(index) else { return nil }
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
}
/// 1 访
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
guard let page = page(at: pageNumber) else { return nil }
return chapterData(forSpineIndex: page.spineIndex)
}
/// 访
public func chapterData(
for location: RDEPUBLocation,
resolver: RDEPUBResourceResolver,
@@ -138,7 +139,6 @@ public struct RDEPUBTextBook {
return chapterData(for: normalizedLocation.href)
}
/// WXRead
public var chapterInfos: [EPUBChapterInfo] {
chapters.map { chapter in
EPUBChapterInfo(
@@ -149,7 +149,6 @@ public struct RDEPUBTextBook {
}
}
/// 1
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
@@ -157,12 +156,6 @@ public struct RDEPUBTextBook {
return pages[pageNumber - 1]
}
/// 1
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression 退
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapterData = chapterData(for: normalizedLocation.href) else {
@@ -182,7 +175,6 @@ public struct RDEPUBTextBook {
return chapterData.pageNumber(for: normalizedLocation)
}
/// RDEPUBLocation
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let chapterData = chapterData(forPageNumber: pageNumber),
let page = page(at: pageNumber) else {
@@ -1,10 +1,8 @@
// RDEPUBTextBuildPipelineInterfaces.swift
// EPUB 线线
import UIKit
/// EPUB EPUB
protocol RDEPUBTextBookBuilding {
func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -13,41 +11,25 @@ protocol RDEPUBTextBookBuilding {
) throws -> RDEPUBTextBook
}
/// 线
struct RDEPUBChapterRenderPipeline {
private let renderer: RDEPUBTextRenderer
/// 线
/// - Parameter renderer:
init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
}
///
/// - Parameter request:
/// - Returns:
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
try renderer.renderChapter(request: request)
}
}
/// 线
struct RDEPUBChapterPaginationPipeline {
private let frameFactory: RDEPUBPageFrameBuilding
/// 线
/// - Parameter frameFactory: 使 CoreText
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
self.frameFactory = frameFactory
}
///
/// - Parameters:
/// - content:
/// - pageSize:
/// - config:
/// - fragmentOffsets: fragment
/// - Returns:
func frames(
for content: NSAttributedString,
pageSize: CGSize,
@@ -1,20 +1,17 @@
import Foundation
// MARK: -
///
public struct RDEPUBTextPerformanceSample: Equatable {
///
public var chapterHref: String
/// HTML
public var renderDuration: TimeInterval
/// CoreText
public var paginateDuration: TimeInterval
///
public var pageCount: Int
///
public var attributedStringLength: Int
///
public var cacheHit: Bool
public init(
@@ -34,21 +31,14 @@ public struct RDEPUBTextPerformanceSample: Equatable {
}
}
// MARK: -
///
///
/// `RDEPUBTextBookBuilder` 使
///
public final class RDEPUBTextPerformanceSampler {
///
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
///
public var totalBuildDuration: TimeInterval = 0
public init() {}
///
public func record(_ sample: RDEPUBTextPerformanceSample) {
samples.append(sample)
#if DEBUG
@@ -56,7 +46,6 @@ public final class RDEPUBTextPerformanceSampler {
#endif
}
/// /
public func summary() -> String {
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
@@ -64,13 +53,11 @@ public final class RDEPUBTextPerformanceSampler {
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
}
///
public func reset() {
samples.removeAll()
totalBuildDuration = 0
}
///
private func formatMS(_ duration: TimeInterval) -> String {
String(format: "%.0fms", duration * 1000)
}
@@ -5,24 +5,20 @@ import UIKit
import DTCoreText
#endif
///
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
struct RDEPUBChapterPageCounter {
private let factory: RDEPUBCoreTextPageFrameFactory
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
private let framesetter: CTFramesetter
/// DTCoreText
private let dtLayoutRect: CGRect
init(factory: RDEPUBCoreTextPageFrameFactory) {
@@ -35,7 +31,6 @@ struct RDEPUBChapterPageCounter {
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
}
///
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@@ -48,8 +43,6 @@ struct RDEPUBChapterPageCounter {
#endif
}
// MARK: - CoreText 退
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@@ -129,9 +122,8 @@ struct RDEPUBChapterPageCounter {
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
@@ -252,9 +244,6 @@ struct RDEPUBChapterPageCounter {
}
#endif
// MARK: - WXRead
/// CoreText frame path
private func proposedVisibleRange(
from frame: CTFrame,
start location: Int,
@@ -5,13 +5,14 @@ import UIKit
import DTCoreText
#endif
/// CoreText
///
/// RDEPUBChapterPageCounter RDEPUBPageBreakPolicy
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let attributedString: NSAttributedString
let pageSize: CGSize
let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
@@ -21,13 +22,10 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
}
/// 便使
init() {
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
}
// MARK: - RDEPUBPageFrameBuilding
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
@@ -39,9 +37,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
// MARK: -
/// CGPath CTFramesetterCreateFrame
static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
@@ -55,9 +50,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return path
}
// MARK: -
/// CTFrame avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame,
proposed: NSRange
@@ -74,7 +66,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// CoreText keepWithNext
func trimmedRangeForKeepWithNext(
from frame: CTFrame,
proposed: NSRange
@@ -88,7 +79,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#if canImport(DTCoreText)
/// DTCoreText avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@@ -102,7 +93,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// DTCoreText keepWithNext
func trimmedRangeForKeepWithNext(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@@ -115,7 +105,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#endif
/// avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
proposed: NSRange,
lineRanges: [NSRange]
@@ -155,7 +144,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// keepWithNext
func trimmedRangeForKeepWithNext(
proposed: NSRange,
lineRanges: [NSRange]
@@ -195,7 +183,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// avoidWidows / avoidOrphans
func trimmedRangeForWidowAndOrphanControl(
proposed: NSRange,
lineRanges: [NSRange]
@@ -227,9 +214,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return adjusted
}
// MARK: -
/// CTFrame
static func lineRanges(from frame: CTFrame) -> [NSRange] {
let lines = CTFrameGetLines(frame) as! [CTLine]
return lines.map {
@@ -239,7 +223,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#if canImport(DTCoreText)
/// DTCoreTextLayoutFrame
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
@@ -248,9 +232,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#endif
// MARK: -
///
func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -260,7 +241,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return nil
}
///
func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -268,7 +248,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return RDEPUBTextBlockKind(rawValue: rawValue)
}
///
func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -276,7 +255,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
}
///
func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
@@ -284,7 +262,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
///
func attachmentRanges(in range: NSRange) -> [NSRange] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -297,7 +274,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return results
}
///
func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
guard location >= 0, location < attributedString.length else { return [] }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -307,8 +283,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
}
// MARK: - Widow / Orphan
private func trimmedRangeAvoidingWidow(
proposed: NSRange,
lineRanges: [NSRange]
@@ -408,7 +382,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return lineCount
}
///
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -425,7 +398,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return kinds
}
///
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -442,7 +414,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return kinds
}
///
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -457,7 +428,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return hints
}
///
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -474,7 +444,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return placements
}
/// attributedString
func clampedRange(_ range: NSRange) -> NSRange? {
guard range.location >= 0, range.length >= 0 else { return nil }
guard attributedString.length > 0 else {
@@ -486,7 +455,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: range.location, length: min(range.length, maxLength))
}
/// fragment ID
func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
@@ -497,9 +465,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.key
}
// MARK: -
///
func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
@@ -1,19 +1,14 @@
import Foundation
import UIKit
///
///
/// CoreText frame attributed string
struct RDEPUBPageBreakPolicy {
private let attributedString: NSAttributedString
init(attributedString: NSAttributedString) {
self.attributedString = attributedString
}
// MARK: -
/// avoidPageBreakInside
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
@@ -35,7 +30,6 @@ struct RDEPUBPageBreakPolicy {
return found
}
/// keepWithNext
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
@@ -54,14 +48,6 @@ struct RDEPUBPageBreakPolicy {
return found
}
// MARK: -
/// CoreText/DTCoreText
///
///
/// 1. chapterEnd
/// 2. pageRelate
/// 3. 使
func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
@@ -111,8 +97,6 @@ struct RDEPUBPageBreakPolicy {
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// WXRead CTFrame pageRelate
//
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: proposedRange.location + 1,
@@ -142,7 +126,6 @@ struct RDEPUBPageBreakPolicy {
)
}
//
return (
range: proposedRange,
breakReason: .frameLimit,
@@ -164,9 +147,6 @@ struct RDEPUBPageBreakPolicy {
)
}
// MARK: -
/// pageRelate
func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
@@ -192,8 +172,6 @@ struct RDEPUBPageBreakPolicy {
return lastLineStart
}
// MARK: -
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
return false
@@ -1,33 +1,27 @@
import Foundation
/// CoreText
///
/// `RDEPUBTextLayouter`
/// `RDEPUBTextBookBuilder` `RDEPUBTextLayoutFrame`
/// `RDEPUBTextPage` `RDEPUBTextBook`
struct RDEPUBTextLayoutFrame: Equatable {
///
var contentRange: NSRange
///
var breakReason: RDEPUBTextPageBreakReason
/// HTML
var blockRange: NSRange?
///
var attachmentRanges: [NSRange]
///
var attachmentKinds: [RDEPUBTextAttachmentKind]
/// HTML
var blockKinds: [RDEPUBTextBlockKind]
///
var semanticHints: [RDEPUBTextSemanticHint]
/// 线
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// fragment ID
var trailingFragmentID: String?
///
var diagnostics: [String]
///
var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: breakReason,
@@ -5,12 +5,6 @@ import UIKit
import DTCoreText
#endif
/// CoreText Facade
///
///
/// - RDEPUBCoreTextPageFrameFactory
/// - RDEPUBPageBreakPolicy keepWithNext
/// - RDEPUBChapterPageCounter
struct RDEPUBTextLayouter {
private let counter: RDEPUBChapterPageCounter
@@ -19,7 +13,6 @@ struct RDEPUBTextLayouter {
self.counter = RDEPUBChapterPageCounter(factory: factory)
}
///
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
@@ -1,30 +1,18 @@
// RDEPUBTextPaginationInterfaces.swift
// EPUB
import Foundation
import UIKit
// MARK: -
///
struct RDEPUBPageBreakDecision {
///
var range: NSRange
///
var reason: RDEPUBTextPageBreakReason
///
var diagnostics: [String]
}
///
protocol RDEPUBChapterPageCounting {
///
/// - Parameters:
/// - attributedString:
/// - pageSize:
/// - config:
/// - fragmentOffsets: fragment
/// - Returns:
func pageRanges(
for attributedString: NSAttributedString,
pageSize: CGSize,
@@ -33,15 +21,8 @@ protocol RDEPUBChapterPageCounting {
) -> [RDEPUBPageBreakDecision]
}
///
protocol RDEPUBPageFrameBuilding {
///
/// - Parameters:
/// - attributedString:
/// - pageSize:
/// - config:
/// - fragmentOffsets: fragment
/// - Returns:
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
@@ -1,18 +1,8 @@
import CoreText
import UIKit
// MARK: - NSAttributedString
/// NSAttributedString 便 CoreText
extension NSAttributedString {
///
///
/// `RDEPUBTextBookBuilder` `RDPlainTextBookBuilder`
/// - Parameters:
/// - size:
/// - fragmentOffsets: fragment ID
/// - config: avoidPageBreakInside
/// - Returns:
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:],
@@ -23,17 +13,13 @@ extension NSAttributedString {
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
/// NSRange
func ss_pageRanges(size: CGSize) -> [NSRange] {
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
// MARK: - UIColor CSS
/// UIColor CSS CSS
extension UIColor {
/// UIColor CSS rgba()
var ss_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -1,14 +1,9 @@
import UIKit
/// 访便
///
/// `RDEPUBTextChapter` `indexTable`
/// fragment API
/// `RDEPUBTextBook.chapterData(for:)` `chapterData(atChapterIndex:)`
public final class RDEPUBChapterData {
///
public let chapter: RDEPUBTextChapter
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
@@ -16,88 +11,66 @@ public final class RDEPUBChapterData {
self.indexTable = indexTable
}
// MARK: - 便 chapter
///
public var chapterIndex: Int { chapter.chapterIndex }
/// spine
public var spineIndex: Int { chapter.spineIndex }
/// "OEBPS/chapter1.xhtml"
public var href: String { chapter.href }
///
public var title: String { chapter.title }
///
public var attributedContent: NSAttributedString { chapter.attributedContent }
///
public var pages: [RDEPUBTextPage] { chapter.pages }
///
public var pageCount: Int { chapter.pages.count }
/// fragment ID
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
/// /
public var chapterInfo: EPUBChapterInfo {
EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
}
///
public var absolutePageRange: ClosedRange<Int>? {
guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
return firstPage.absolutePageIndex...lastPage.absolutePageIndex
}
// MARK: -
///
/// - Parameter absoluteOffset:
/// - Returns: nil
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
}
/// 0
public func pageNumber(containing absoluteOffset: Int) -> Int? {
page(containing: absoluteOffset)?.absolutePageIndex
}
///
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
}
/// 1
public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
return pages[pageNumber - 1]
}
// MARK: -
/// fileIndex/row/column
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
}
///
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
indexTable.anchor(forGlobalIndex: index)
}
///
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
let start = anchor(forAbsoluteIndex: absoluteRange.location)
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
return RDEPUBTextRangeAnchor(start: start, end: end)
}
///
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
indexTable.globalRange(for: rangeAnchor)
}
/// /
/// - Parameters:
/// - absoluteRange:
/// - bookIdentifier:
/// - Returns: nil
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
guard page(containing: absoluteRange.location) != nil else { return nil }
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
@@ -115,7 +88,6 @@ public final class RDEPUBChapterData {
)
}
/// RDEPUBLocation
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
indexTable.location(
for: rangeAnchor(for: absoluteRange),
@@ -124,32 +96,31 @@ public final class RDEPUBChapterData {
)
}
/// RDEPUBLocation
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
location(for: page.contentRange, bookIdentifier: bookIdentifier)
}
///
public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
guard let range = absoluteRange(for: location) else { return nil }
return page(containing: range.location)
}
///
public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
guard let range = absoluteRange(for: searchMatch) else { return nil }
return page(containing: range.location)
}
// MARK: - Location
///
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression退
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.chapterRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = indexTable.anchor(for: cfi) {
return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
}
if let rangeAnchor = location.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
@@ -164,16 +135,35 @@ public final class RDEPUBChapterData {
return NSRange(location: offset, length: 1)
}
///
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.chapterRange(for: rangeAnchor)
}
if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
return endpointRange
}
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
}
///
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.chapterRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
let anchor = indexTable.anchor(for: cfi) {
let location = indexTable.chapterOffset(for: anchor)
return NSRange(
location: location,
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
)
}
if let location = searchMatch.rangeLocation {
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
}
@@ -183,8 +173,17 @@ public final class RDEPUBChapterData {
return nil
}
///
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.globalRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = indexTable.anchor(for: cfi) {
return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
}
if let rangeAnchor = location.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
@@ -195,8 +194,16 @@ public final class RDEPUBChapterData {
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
///
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.globalRange(for: rangeAnchor)
}
if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
return endpointRange
}
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
@@ -207,8 +214,19 @@ public final class RDEPUBChapterData {
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
///
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.globalRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
let anchor = indexTable.anchor(for: cfi) {
let chapterLocation = indexTable.chapterOffset(for: anchor)
return NSRange(
location: indexTable.globalIndex(for: anchor),
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
)
}
if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
@@ -219,13 +237,14 @@ public final class RDEPUBChapterData {
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
// MARK: - /
///
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
let pageRange = absoluteOffsetRange(for: page)
return allHighlights.filter { highlight in
guard highlight.location.href == chapter.href else { return false }
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
}
if let anchor = highlight.location.rangeAnchor?.start {
return pageRange.contains(absoluteOffset(for: anchor))
}
@@ -236,7 +255,6 @@ public final class RDEPUBChapterData {
}
}
///
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
return matches.filter { match in
guard match.href == chapter.href else { return false }
@@ -247,26 +265,20 @@ public final class RDEPUBChapterData {
}
}
/// `searchMatches(on:from:)` /WXRead
public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
searchMatches(on: page, from: matches)
}
/// 1
public func pageNumber(for location: RDEPUBLocation) -> Int? {
guard let page = page(for: location) else { return nil }
return page.absolutePageIndex + 1
}
/// 1
public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let page = page(for: searchMatch) else { return nil }
return page.absolutePageIndex + 1
}
// MARK: -
/// TOC
public func contains(
tableOfContentsItem item: EPUBTableOfContentsItem,
normalizer: (String) -> String?
@@ -278,7 +290,6 @@ public final class RDEPUBChapterData {
return chapterHref == itemHref
}
///
public func tableOfContentsItems(
from items: [EPUBTableOfContentsItem],
normalizer: (String) -> String?
@@ -289,7 +300,6 @@ public final class RDEPUBChapterData {
}
}
///
public func primaryTableOfContentsItem(
from items: [EPUBTableOfContentsItem],
normalizer: (String) -> String?
@@ -297,10 +307,6 @@ public final class RDEPUBChapterData {
tableOfContentsItems(from: items, normalizer: normalizer).first
}
// MARK: - WXRead WRChapterData.addHighlightInRange:key:itemId:color:
/// /线 NSAttributedString
/// CoreText WXRead com.weread.highlight / com.weread.underline
public func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
@@ -324,17 +330,51 @@ public final class RDEPUBChapterData {
}
}
// MARK: -
/// [start, end+1)
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
/// 退 chapterOffset
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
indexTable.chapterOffset(for: anchor)
}
private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
let startAnchor = indexTable.anchor(for: startCFI),
let endAnchor = indexTable.anchor(for: endCFI),
startAnchor.fileIndex == endAnchor.fileIndex else {
return nil
}
let start = indexTable.chapterOffset(for: startAnchor)
let end = indexTable.chapterOffset(for: endAnchor)
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
}
private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
let startAnchor = indexTable.anchor(for: startCFI),
let endAnchor = indexTable.anchor(for: endCFI),
startAnchor.fileIndex == endAnchor.fileIndex else {
return nil
}
let start = indexTable.globalIndex(for: startAnchor)
let end = indexTable.globalIndex(for: endAnchor)
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
}
private func recoveredSearchRangeLength(
for searchMatch: RDEPUBSearchMatch,
cfi: RDEPUBCFI,
startOffset: Int
) -> Int {
let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
let fallbackLength = max(searchMatch.rangeLength, 1)
let candidateLength = exactLength > 0 ? exactLength : fallbackLength
let remainingLength = max(attributedContent.length - startOffset, 1)
return min(max(candidateLength, 1), remainingLength)
}
}
@@ -4,18 +4,9 @@ import UIKit
import DTCoreText
#endif
/// DTCoreText EPUB HTML NSAttributedString
///
/// `RDEPUBTextRenderer`
/// HTML DTCoreText NSAttributedString fragment /
///
/// DTCoreText HTML
/// `willFlushCallback` DOM
/// DTCoreText 退
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init() {}
/// DTCoreText
public static var isAvailable: Bool {
#if canImport(DTCoreText)
return true
@@ -24,7 +15,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// HTML NSAttributedString fragment
public func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent {
@@ -56,7 +46,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// 便 HTML
public func renderChapter(
html: String,
baseURL: URL?,
@@ -65,6 +54,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: "",
spineIndex: nil,
title: "",
rawHTML: html,
baseURL: baseURL,
@@ -75,7 +65,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return try renderChapter(request: request)
}
/// 退 DTCoreText HTML
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
@@ -93,7 +82,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
#if canImport(DTCoreText)
/// 使 DTCoreText HTML Data
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
let builder = DTHTMLAttributedStringBuilder(
html: data,
@@ -111,7 +100,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return builder?.generatedAttributedString()
}
/// DTCoreText
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style
let maxImageSize = resolvedMaxImageSize(for: request)
@@ -1,84 +1,46 @@
// RDEPUBTextPositionConverter.swift
// EPUB
import Foundation
/// WXRead `WREpubPositionConverter`
///
///
/// - `(fileIndex, row, column)`
/// -
/// -
/// - / `RDEPUBLocation`
public struct RDEPUBTextPositionConverter {
/// EPUB
public let book: RDEPUBTextBook
/// EPUB
/// - Parameter book: EPUB
public init(book: RDEPUBTextBook) {
self.book = book
}
///
public var totalCharacterCount: Int {
book.indexTable.totalCharacterCount
}
///
/// - Parameter anchor:
/// - Returns:
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
book.indexTable.globalIndex(for: anchor)
}
///
/// - Parameter rangeAnchor:
/// - Returns: `NSRange`
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
book.indexTable.globalRange(for: rangeAnchor)
}
///
/// - Parameter index:
/// - Returns: `nil`
public func fileIndex(forCharacterPosition index: Int) -> Int? {
book.indexTable.fileIndex(forCharacterPosition: index)
}
///
/// - Parameters:
/// - fileIndex:
/// - index:
/// - Returns: `nil`
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
}
///
/// - Parameter index:
/// - Returns: `nil`
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
book.indexTable.anchor(forGlobalIndex: index)
}
/// `RDEPUBLocation`
/// - Parameter location: EPUB
/// - Returns: `nil`
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
book.indexTable.anchor(for: location)
}
/// 1
/// - Parameter anchor:
/// - Returns: `nil`
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
}
/// 1
/// - Parameter index:
/// - Returns: `nil`
public func pageNumber(forCharacterPosition index: Int) -> Int? {
guard let anchor = anchor(forCharacterPosition: index) else {
return nil
@@ -86,11 +48,6 @@ public struct RDEPUBTextPositionConverter {
return pageNumber(for: anchor)
}
/// `RDEPUBLocation`
/// - Parameters:
/// - anchor:
/// - bookIdentifier: location `bookId`
/// - Returns: `RDEPUBLocation` `nil`
public func location(
for anchor: RDEPUBTextAnchor,
bookIdentifier: String?
@@ -101,11 +58,6 @@ public struct RDEPUBTextPositionConverter {
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
}
/// `RDEPUBLocation`
/// - Parameters:
/// - rangeAnchor:
/// - bookIdentifier: location `bookId`
/// - Returns: `RDEPUBLocation` `nil`
public func location(
for rangeAnchor: RDEPUBTextRangeAnchor,
bookIdentifier: String?
@@ -1,71 +1,54 @@
import UIKit
// MARK: -
/// EPUBTextRendering 使 NSAttributedString
/// `RDEPUBTextRendererSupport`
/// `RDEPUBTextLayouter`
public extension NSAttributedString.Key {
/// NSString NSRange
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
///
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
/// fragment ID
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
/// /
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
/// ///
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
///
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
/// /线/
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
}
// MARK: -
/// HTML
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
case paragraph // <p>
case list // <ul>, <ol>, <li>
case table // <table>
case code // <pre>, <code>
case blockquote // <blockquote>
case attachment // figure
case generic // <div>, <h1>-<h6>
case paragraph
case list
case table
case code
case blockquote
case attachment
case generic
}
// MARK: -
/// /
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
case avoidPageBreakInside //
case keepWithNext //
case pageBreakBefore //
case pageBreakAfter //
case pageRelate //
case avoidPageBreakInside
case keepWithNext
case pageBreakBefore
case pageBreakAfter
case pageRelate
}
// MARK: -
///
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
case inline //
case baseline // 线
case centered //
case inline
case baseline
case centered
}
// MARK: -
///
public struct RDEPUBTextRenderStyle {
/// EPUB
public var font: UIFont
/// pt
public var lineSpacing: CGFloat
/// nil 使 EPUB
public var textColor: UIColor?
/// nil
public var backgroundColor: UIColor?
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
@@ -76,31 +59,28 @@ public struct RDEPUBTextRenderStyle {
}
}
// MARK: -
///
public struct RDEPUBTextLayoutConfig: Equatable {
/// 0 退 pageSize.width
public var frameWidth: CGFloat
/// 0 退 pageSize.height
public var frameHeight: CGFloat
/// WXRead WRCoreTextLayoutConfig.edgeInsets
public var edgeInsets: UIEdgeInsets
/// WXRead numberOfColumns
public var numberOfColumns: Int
/// WXRead columnGap
public var columnGap: CGFloat
///
public var avoidOrphans: Bool
///
public var avoidWidows: Bool
/// avoidPageBreakInside WXRead 退
public var avoidPageBreakInsideEnabled: Bool
/// WXRead hyphenation
public var hyphenation: Bool
///
public var imageMaxHeightRatio: CGFloat
/// pageSize viewport
public var fallbackViewportSize: CGSize
public init(
@@ -129,10 +109,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
self.fallbackViewportSize = fallbackViewportSize
}
///
public static let `default` = RDEPUBTextLayoutConfig()
/// pageSize
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
CGSize(
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
@@ -140,13 +118,11 @@ public struct RDEPUBTextLayoutConfig: Equatable {
)
}
/// WXRead frame + edgeInsets
public func contentRect(fallback pageSize: CGSize) -> CGRect {
let size = resolvedFrameSize(fallback: pageSize)
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
}
///
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
let rect = contentRect(fallback: pageSize)
let columns = max(1, numberOfColumns)
@@ -161,7 +137,6 @@ public struct RDEPUBTextLayoutConfig: Equatable {
}
}
/// /使
public var cacheSignature: String {
[
String(format: "%.3f", frameWidth),
@@ -183,18 +158,14 @@ public struct RDEPUBTextLayoutConfig: Equatable {
}
}
// MARK: - CSS
/// CSS
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default` // marginpadding
case replace //
case dark //
case epub // EPUB
case user //
case `default`
case replace
case dark
case epub
case user
}
/// CSS CSS
public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String
@@ -205,9 +176,6 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
}
}
/// CSS CSS
///
/// CSS default replace dark epub user
public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer]
@@ -215,7 +183,6 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
self.layers = layers
}
/// CSS
public var combinedCSS: String {
layers
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
@@ -226,15 +193,11 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
}
}
// MARK: -
///
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet
case image
}
/// EPUB
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
@@ -260,9 +223,6 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
}
}
// MARK: -
/// HTML
public struct RDEPUBTextChapterContext: Equatable {
public var href: String
public var title: String
@@ -270,6 +230,7 @@ public struct RDEPUBTextChapterContext: Equatable {
public var baseURL: URL?
public var stylesheet: RDEPUBTextStyleSheetPackage
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
public init(
href: String,
@@ -277,7 +238,8 @@ public struct RDEPUBTextChapterContext: Equatable {
html: String,
baseURL: URL?,
stylesheet: RDEPUBTextStyleSheetPackage,
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic],
styleCompatibilityReport: RDEPUBCSSCompatibilityReport = RDEPUBCSSCompatibilityReport()
) {
self.href = href
self.title = title
@@ -285,16 +247,16 @@ public struct RDEPUBTextChapterContext: Equatable {
self.baseURL = baseURL
self.stylesheet = stylesheet
self.resourceDiagnostics = resourceDiagnostics
self.styleCompatibilityReport = styleCompatibilityReport
}
}
/// `RDEPUBTextRenderer`
public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle
/// page size
public var pageSize: CGSize?
/// WXRead
public var layoutConfig: RDEPUBTextLayoutConfig?
public init(
@@ -310,15 +272,12 @@ public struct RDEPUBTextChapterRenderRequest {
}
}
// MARK: -
/// fragment
public struct RDEPUBRenderedChapterContent {
///
public var attributedString: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
///
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init(
@@ -332,18 +291,12 @@ public struct RDEPUBRenderedChapterContent {
}
}
// MARK: -
/// EPUB HTML NSAttributedString
///
/// `RDEPUBDTCoreTextRenderer` DTCoreText
public protocol RDEPUBTextRenderer {
///
func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent
/// 便 HTML
func renderChapter(
html: String,
baseURL: URL?,
@@ -351,7 +304,6 @@ public protocol RDEPUBTextRenderer {
) throws -> RDEPUBRenderedChapterContent
}
/// 便
public extension RDEPUBTextRenderer {
func renderChapter(
html: String,
@@ -370,12 +322,9 @@ public extension RDEPUBTextRenderer {
}
}
// MARK: -
/// EPUB
public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed // HTML Data
case htmlImportFailed // HTML
case htmlEncodingFailed
case htmlImportFailed
public var errorDescription: String? {
switch self {
@@ -1,10 +1,5 @@
import Foundation
/// EPUB
///
/// `RDEPUBSearchEngine`
/// 使
///
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
private let textBook: RDEPUBTextBook
private let publication: RDEPUBPublication
@@ -14,14 +9,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
self.publication = publication
}
///
///
///
/// 1.
/// 2.
/// 3. progression
/// 4. 12
/// 5. /
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@@ -49,6 +36,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
@@ -57,7 +45,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
rangeAnchor: rangeAnchor,
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
)
)
@@ -73,7 +63,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return matches
}
/// 12
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
@@ -82,13 +71,16 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
/// publication txt
static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else { return [] }
var matches: [RDEPUBSearchMatch] = []
for chapter in textBook.chapters {
let chapterData = RDEPUBChapterData(
chapter: chapter,
indexTable: RDEPUBTextIndexTable(chapters: [chapter])
)
let source = chapter.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { continue }
@@ -102,6 +94,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start)
let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
let previewRadius = 12
let previewStart = max(foundRange.location - previewRadius, 0)
let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength)
@@ -116,7 +111,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: nil
rangeAnchor: rangeAnchor,
cfi: cfi?.rawValue ?? cfiRange?.start.rawValue,
rangeCFI: cfiRange?.rawValue
)
)
@@ -1,16 +1,5 @@
import UIKit
/// TXT RDEPUBTextBook EPUB 线
///
/// .txt EPUB
/// 使 EPUBReader
///
///
/// 1. UTF-8 GB18030 GBK
/// 2.
/// 3. HTML
/// 4. DTCoreText
/// 5. CoreText RDEPUBTextBook
public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let layoutConfig: RDEPUBTextLayoutConfig
@@ -23,13 +12,6 @@ public final class RDPlainTextBookBuilder {
self.layoutConfig = layoutConfig
}
///
///
/// - Parameters:
/// - textFileURL: URL
/// - pageSize:
/// - style:
/// - Returns:
public func build(
textFileURL: URL,
pageSize: CGSize,
@@ -109,6 +91,7 @@ public final class RDPlainTextBookBuilder {
title: spec.title ?? "\(index + 1)",
attributedContent: chapterAttributedContent,
fragmentOffsets: [:],
cfiMap: nil,
pageBreakReasons: pages.map { $0.metadata.breakReason },
pages: pages
)
@@ -119,16 +102,11 @@ public final class RDPlainTextBookBuilder {
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
}
// MARK: -
/// +
private struct ChapterSpec {
let title: String?
let content: String
}
/// "X///"
///
private func splitChapters(from text: String) -> [ChapterSpec] {
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
@@ -154,25 +132,18 @@ public final class RDPlainTextBookBuilder {
}
}
//
if specs.isEmpty {
return [ChapterSpec(title: nil, content: text)]
}
return specs
}
// MARK: - HTML
/// HTML `<p>`
private func wrapTextAsHTML(_ text: String) -> String {
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
return "<html><body>\(body)</body></html>"
}
// MARK: -
/// UTF-8 GB18030 GBK
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
@@ -0,0 +1,136 @@
import Foundation
struct RDEPUBCSSCompatibilityLayer {
var policy = RDEPUBStyleCompatibilityPolicy()
func sanitize(_ css: String) -> RDEPUBCSSCompatibilityResult {
var rewritten = css
var normalized: [String] = []
var unsupported: [String] = []
if policy.normalizeLineHeight {
let result = clampNumericProperty(
in: rewritten,
property: "line-height",
minValue: 0.9,
maxValue: 2.4
)
rewritten = result.css
normalized += result.normalizedRules
}
if policy.normalizeTextIndent {
let result = clampLengthProperty(
in: rewritten,
property: "text-indent",
maxAbsolutePX: 64
)
rewritten = result.css
normalized += result.normalizedRules
}
if !policy.allowPublisherMargins {
let result = clampLengthProperty(
in: rewritten,
property: "margin",
maxAbsolutePX: 64
)
rewritten = result.css
normalized += result.normalizedRules
}
if policy.fallbackUnsupportedWritingModes,
rewritten.range(of: "writing-mode", options: [.caseInsensitive]) != nil {
unsupported.append("writing-mode")
rewritten += "\n\nhtml, body { writing-mode: horizontal-tb !important; }"
normalized.append("writing-mode")
}
if policy.clampImagesToViewport {
rewritten += """
img, svg, table, video, canvas {
max-width: 100% !important;
height: auto !important;
box-sizing: border-box !important;
}
table {
overflow-wrap: anywhere;
border-collapse: collapse;
}
"""
normalized.append("media-size-clamp")
}
return RDEPUBCSSCompatibilityResult(
css: rewritten,
report: RDEPUBCSSCompatibilityReport(
unsupportedRules: unsupported,
normalizedRules: normalized,
fontFailures: []
)
)
}
private func clampNumericProperty(
in css: String,
property: String,
minValue: Double,
maxValue: Double
) -> (css: String, normalizedRules: [String]) {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*([0-9]*\.?[0-9]+)\s*;"#
) else {
return (css, [])
}
let nsCSS = css as NSString
var rewritten = css
var normalized: [String] = []
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
guard let value = Double(rawValue), value < minValue || value > maxValue else { continue }
let clamped = min(max(value, minValue), maxValue)
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.3f", clamped));")
normalized.append(property)
}
}
return (rewritten, normalized)
}
private func clampLengthProperty(
in css: String,
property: String,
maxAbsolutePX: Double
) -> (css: String, normalizedRules: [String]) {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*(-?[0-9]*\.?[0-9]+)px\s*;"#
) else {
return (css, [])
}
let nsCSS = css as NSString
var rewritten = css
var normalized: [String] = []
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
guard let value = Double(rawValue), abs(value) > maxAbsolutePX else { continue }
let clamped = value < 0 ? -maxAbsolutePX : maxAbsolutePX
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.0f", clamped))px;")
normalized.append(property)
}
}
return (rewritten, normalized)
}
}
@@ -0,0 +1,21 @@
import UIKit
struct RDEPUBFontFallbackResolver {
static func fallbackChain(requestedFamily: String?, embeddedFamily: String?) -> RDEPUBFontFallbackChain {
let preferredCJK = ["PingFang SC", "Heiti SC", "Songti SC"]
let preferredLatin = ["Times New Roman", "Georgia", "Helvetica Neue"]
return RDEPUBFontFallbackChain(
requestedFamily: requestedFamily,
embeddedFamily: embeddedFamily,
systemFallbacks: preferredCJK + preferredLatin,
finalFallback: UIFont.systemFont(ofSize: UIFont.systemFontSize).familyName
)
}
static func resolveFont(sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: baseFont)
}
}
@@ -0,0 +1,101 @@
import Foundation
public struct RDEPUBStyleCompatibilityPolicy: Equatable {
public var allowPublisherFonts: Bool
public var allowPublisherMargins: Bool
public var normalizeLineHeight: Bool
public var normalizeTextIndent: Bool
public var clampImagesToViewport: Bool
public var fallbackUnsupportedWritingModes: Bool
public init(
allowPublisherFonts: Bool = true,
allowPublisherMargins: Bool = true,
normalizeLineHeight: Bool = true,
normalizeTextIndent: Bool = true,
clampImagesToViewport: Bool = true,
fallbackUnsupportedWritingModes: Bool = true
) {
self.allowPublisherFonts = allowPublisherFonts
self.allowPublisherMargins = allowPublisherMargins
self.normalizeLineHeight = normalizeLineHeight
self.normalizeTextIndent = normalizeTextIndent
self.clampImagesToViewport = clampImagesToViewport
self.fallbackUnsupportedWritingModes = fallbackUnsupportedWritingModes
}
}
public struct RDEPUBFontFallbackChain: Codable, Equatable {
public var requestedFamily: String?
public var embeddedFamily: String?
public var systemFallbacks: [String]
public var finalFallback: String
public init(
requestedFamily: String? = nil,
embeddedFamily: String? = nil,
systemFallbacks: [String] = ["PingFang SC", "Heiti SC", "Times New Roman"],
finalFallback: String = ".AppleSystemUIFont"
) {
self.requestedFamily = requestedFamily
self.embeddedFamily = embeddedFamily
self.systemFallbacks = systemFallbacks
self.finalFallback = finalFallback
}
}
public struct RDEPUBEmbeddedFontDescriptor: Codable, Equatable {
public var family: String?
public var href: String
public var format: String?
public var weight: Int?
public var style: String?
}
public struct RDEPUBFontRegistrationResult: Codable, Equatable {
public var descriptor: RDEPUBEmbeddedFontDescriptor
public var fileURL: URL?
public var didRegister: Bool
public var errorDescription: String?
}
public struct RDEPUBCSSCompatibilityReport: Codable, Equatable {
public var unsupportedRules: [String]
public var normalizedRules: [String]
public var fontFailures: [String]
public init(unsupportedRules: [String] = [], normalizedRules: [String] = [], fontFailures: [String] = []) {
self.unsupportedRules = unsupportedRules
self.normalizedRules = normalizedRules
self.fontFailures = fontFailures
}
}
struct RDEPUBCSSCompatibilityResult {
var css: String
var report: RDEPUBCSSCompatibilityReport
}
@@ -4,14 +4,14 @@ import UIKit
import DTCoreText
#endif
///
struct RDEPUBAttachmentNormalizer {
///
private static var didLogFootnoteAttachment = false
///
private static var didLogCoverAttachment = false
#if canImport(DTCoreText)
func normalize(
_ attachment: DTTextAttachment,
fontPointSize: CGFloat,
@@ -25,23 +25,28 @@ struct RDEPUBAttachmentNormalizer {
}
#endif
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// DTTextAttachment
static func normalizeAttachmentLayoutForWXRead(
_ attachment: DTTextAttachment,
fontPointSize: CGFloat,
maxImageSize: CGSize? = nil
) {
let pointSize = max(fontPointSize, 1)
let originalSize = attachment.originalSize
if isFootnoteAttachment(attachment) {
let targetWidth = max(round(pointSize), 1)
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
attachment.verticalAlignment = .baseline
if !didLogFootnoteAttachment {
@@ -54,16 +59,20 @@ struct RDEPUBAttachmentNormalizer {
}
if isCoverAttachment(attachment) {
let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize)
if originalSize.width > 0, originalSize.height > 0 {
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
attachment.displaySize = CGSize(
width: round(originalSize.width * scale),
height: round(originalSize.height * scale)
)
} else {
attachment.displaySize = maxSize
}
attachment.verticalAlignment = .baseline
if !didLogCoverAttachment {
@@ -76,9 +85,11 @@ struct RDEPUBAttachmentNormalizer {
}
var resolvedSize = attachment.displaySize
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
resolvedSize = originalSize
}
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
resolvedSize = CGSize(width: pointSize, height: pointSize)
}
@@ -87,6 +98,7 @@ struct RDEPUBAttachmentNormalizer {
resolvedSize.width > 0,
resolvedSize.height > 0,
(resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
resolvedSize = CGSize(
width: round(resolvedSize.width * scale),
@@ -95,26 +107,31 @@ struct RDEPUBAttachmentNormalizer {
}
attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
attachment.verticalAlignment = .center
}
/// DTCoreText willFlushCallback
static func prepareHTMLElementForReaderRendering(
_ element: DTHTMLElement,
style: RDEPUBTextRenderStyle,
maxImageSize: CGSize? = nil
) {
guard let attachment = element.textAttachment else { return }
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
let fallbackSize = CGSize(
width: defaultMaxImageSize(fontPointSize: pointSize).width,
height: defaultMaxImageSize(fontPointSize: pointSize).height
)
normalizeAttachmentLayoutForWXRead(
attachment,
fontPointSize: pointSize,
maxImageSize: maxImageSize ?? fallbackSize
)
if isFootnoteAttachment(attachment) {
element.displayStyle = .inline
} else if isCoverAttachment(attachment) {
@@ -123,23 +140,31 @@ struct RDEPUBAttachmentNormalizer {
}
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png"
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
}
private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize {
let referenceViewport = CGSize(width: 375, height: 667)
let horizontalInset = max(round(fontPointSize), 16)
let verticalInset = max(round(fontPointSize * 1.5), 28)
return CGSize(
width: max(round(referenceViewport.width - horizontalInset * 2), 1),
height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1)
@@ -147,16 +172,15 @@ struct RDEPUBAttachmentNormalizer {
}
#endif
// MARK: -
/// NSAttributedString
static func normalizeAttachmentDisplayIfNeeded(
in attributes: inout [NSAttributedString.Key: Any],
font: UIFont
) {
guard let attachment = attributes[.attachment] else { return }
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment {
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
attributes[.attachment] = textAttachment
@@ -165,15 +189,17 @@ struct RDEPUBAttachmentNormalizer {
#endif
if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 {
let targetHeight = max(round(font.pointSize * 0.86), 1)
textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight)
attributes[.attachment] = textAttachment
}
}
///
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
if let attachment = attributes[.attachment] as? NSTextAttachment {
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
return .image
}
@@ -182,6 +208,7 @@ struct RDEPUBAttachmentNormalizer {
for value in attributes.values {
let typeName = String(describing: type(of: value)).lowercased()
if typeName.contains("attachment") {
return typeName.contains("image") ? .image : .generic
}
@@ -0,0 +1,59 @@
import Foundation
struct RDEPUBCFIMarkerInjector: RDEPUBTypesettingStage {
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
guard let regex = try? NSRegularExpression(
pattern: #"<([A-Za-z][A-Za-z0-9:_-]*)([^>]*\s(?:id|xml:id)\s*=\s*['"]([^'"]+)['"][^>]*)>"#,
options: [.caseInsensitive]
) else {
return html
}
let nsHTML = html as NSString
let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
guard !matches.isEmpty else { return html }
var rewritten = html
for match in matches.reversed() {
guard match.numberOfRanges > 3,
let fullRange = Range(match.range(at: 0), in: rewritten),
let tagNameRange = Range(match.range(at: 1), in: html),
let attributesRange = Range(match.range(at: 2), in: html),
let fragmentRange = Range(match.range(at: 3), in: html) else {
continue
}
let tagName = String(html[tagNameRange])
let attributes = String(html[attributesRange])
let fragmentID = String(html[fragmentRange])
guard attributes.range(of: "data-rd-cfi-marker", options: [.caseInsensitive]) == nil else {
continue
}
let marker = RDEPUBCFIGenerator.makeOffsetCFI(
href: context.href,
fileIndex: context.spineIndex ?? 0,
chapterOffset: 0,
fragmentID: fragmentID
).rawValue
let replacement = "<\(tagName)\(attributes) data-rd-cfi-marker=\"\(Self.escapeAttribute(marker))\">"
rewritten.replaceSubrange(fullRange, with: replacement)
}
return rewritten
}
private static func escapeAttribute(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
}
@@ -1,16 +1,18 @@
import UIKit
import CoreText
/// EPUB
struct RDEPUBFontNormalizer {
/// CTFontManager
private static var registeredFontPaths = Set<String>()
@discardableResult
func registerEmbeddedFonts(
html: String,
inlinedCSS: String,
input: RDEPUBTypesettingInput
) {
) -> [RDEPUBFontRegistrationResult] {
Self.registerEmbeddedFonts(
in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
chapterHref: input.href,
@@ -18,70 +20,119 @@ struct RDEPUBFontNormalizer {
)
}
// MARK: -
/// CSS @font-face
@discardableResult
static func registerEmbeddedFonts(
in css: String,
chapterHref: String,
resourceResolver: RDEPUBResourceResolver?
) {
) -> [RDEPUBFontRegistrationResult] {
guard let resourceResolver,
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
return
return []
}
var results: [RDEPUBFontRegistrationResult] = []
let nsCSS = css as NSString
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
guard faceMatch.numberOfRanges > 1 else { continue }
let block = nsCSS.substring(with: faceMatch.range(at: 1))
let nsBlock = block as NSString
let family = declarationValue(named: "font-family", in: block)?
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
let weight = declarationValue(named: "font-weight", in: block).flatMap(Int.init)
let style = declarationValue(named: "font-style", in: block)
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
guard urlMatch.numberOfRanges > 1 else { continue }
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
let descriptor = RDEPUBEmbeddedFontDescriptor(
family: family,
href: rawReference,
format: nil,
weight: weight,
style: style
)
guard !rawReference.isEmpty,
!rawReference.hasPrefix("data:"),
!rawReference.hasPrefix("http://"),
!rawReference.hasPrefix("https://"),
!rawReference.hasPrefix("http:"),
!rawReference.hasPrefix("https:"),
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
results.append(
RDEPUBFontRegistrationResult(
descriptor: descriptor,
fileURL: nil,
didRegister: false,
errorDescription: "Font URL could not be resolved"
)
)
continue
}
registerFontIfNeeded(at: fileURL)
let didRegister = registerFontIfNeeded(at: fileURL)
results.append(
RDEPUBFontRegistrationResult(
descriptor: descriptor,
fileURL: fileURL,
didRegister: didRegister,
errorDescription: didRegister ? nil : "Font was already registered or registration failed"
)
)
}
}
return results
}
/// CTFontManager
/// - Parameter fileURL: URL
static func registerFontIfNeeded(at fileURL: URL) {
@discardableResult
static func registerFontIfNeeded(at fileURL: URL) -> Bool {
let standardizedPath = fileURL.standardizedFileURL.path
guard !registeredFontPaths.contains(standardizedPath) else { return }
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
registeredFontPaths.insert(standardizedPath)
guard !registeredFontPaths.contains(standardizedPath) else { return true }
let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
if registered {
registeredFontPaths.insert(standardizedPath)
}
return registered
}
// MARK: -
/// EPUB /
static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
guard let sourceFont else {
return baseFont
}
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
}
return baseFont
}
/// HTML <style> CSS
static func inlineStyleCSS(in html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
return ""
}
let nsHTML = html as NSString
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
.compactMap { match in
guard match.numberOfRanges > 1 else { return nil }
@@ -89,4 +140,21 @@ struct RDEPUBFontNormalizer {
}
.joined(separator: "\n")
}
private static func declarationValue(named name: String, in block: String) -> String? {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: name) + #"\s*:\s*([^;]+)"#
) else {
return nil
}
let nsBlock = block as NSString
guard let match = regex.firstMatch(in: block, range: NSRange(location: 0, length: nsBlock.length)),
match.numberOfRanges > 1 else {
return nil
}
return nsBlock.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -1,26 +1,16 @@
/// RDEPUBFragmentMarkerInjector - Fragment
import Foundation
/// Fragment HTML fragment
///
///
/// 1. `process` HTML `id` `${id=xxx}`
/// 2. `extractOffsets` NSAttributedString
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
/// HTML id fragment 使
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.injectFragmentMarkers(into: html)
}
/// `${id=xxx}` fragment ID
func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
Self.extractFragmentOffsets(from: attributedString)
}
// MARK: - Fragment
/// HTML id fragment
/// `<tag id="xxx" ...>` `${id=xxx}<tag id="xxx" ...>`
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html
@@ -33,32 +23,43 @@ struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
)
}
// MARK: - Fragment
/// fragment
/// `${id=xxx}`
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
let markerPattern = #"\$\{id=([^}]+)\}"#
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
return [:]
}
let mutableString = NSMutableString(string: attributedString.string)
var fragmentOffsets: [String: Int] = [:]
var searchRange = NSRange(location: 0, length: mutableString.length)
var offsetAdjustment = 0
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let fullMatch = mutableString.substring(with: match.range) as NSString
let fragmentID = fullMatch
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
fragmentOffsets[fragmentID] = adjustedLocation
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
offsetAdjustment -= match.range.length
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
}
@@ -1,25 +1,18 @@
import Foundation
/// HTML HTML
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
/// HTML
/// - Parameters:
/// - html: HTML
/// - context:
/// - Returns: HTML
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.normalizeHTML(html)
}
// MARK: - HTML
/// CR HTML
static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
]
for replacement in replacements {
@@ -37,9 +30,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return cleanedHTML
}
// MARK: - HTML
/// bodyPic div img h1+img HTML
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
var normalized = html
@@ -51,10 +41,12 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
using: bodyPicContainerRegex,
in: normalized
) { tag in
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]) else {
return tag
}
return replaceMatches(
using: imageTagRegex,
in: tag
@@ -75,6 +67,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
}
}
if let footnoteRegex = try? NSRegularExpression(
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
options: [.caseInsensitive]
@@ -83,6 +76,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
using: footnoteRegex,
in: normalized
) { tag in
mergeHTMLAttributes(
into: tag,
requiredClass: nil,
@@ -96,6 +90,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
}
}
if let coverRegex = try? NSRegularExpression(
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
options: [.caseInsensitive]
@@ -104,6 +99,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
using: coverRegex,
in: normalized
) { tag in
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
let imageMatch = imageTagRegex.firstMatch(
in: tag,
@@ -133,15 +129,13 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return normalized
}
// MARK: - HTML
/// transform
static func replaceMatches(
using regex: NSRegularExpression,
in source: String,
transform: (String) -> String
) -> String {
let nsSource = source as NSString
let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
guard !matches.isEmpty else { return source }
@@ -154,7 +148,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return rewritten
}
/// HTML class style
static func mergeHTMLAttributes(
into tag: String,
requiredClass: String?,
@@ -194,7 +187,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return rewritten
}
/// `<base>`
static func injectBaseHref(into html: String, baseURL: URL?) -> String {
guard let baseURL else {
return html
@@ -213,7 +205,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return "<head>\n\(baseTag)\n</head>\n" + html
}
/// CGSize
static func string(from size: CGSize) -> String {
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
}
@@ -1,17 +1,16 @@
import Foundation
/// HTML
struct RDEPUBRenderDiagnosticsCollector {
///
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
///
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
/// HTML
/// - Parameters:
/// - html: HTML
/// - input:
/// - Returns:
func collect(
in html: String,
input: RDEPUBTypesettingInput
@@ -24,9 +23,9 @@ struct RDEPUBRenderDiagnosticsCollector {
)
}
// MARK: -
/// HTML <img>
static func collectImageDiagnostics(
in html: String,
chapterHref: String,
@@ -50,9 +49,9 @@ struct RDEPUBRenderDiagnosticsCollector {
}
}
// MARK: -
/// `<link rel=stylesheet>` CSS
static func inlineLinkedStyleSheets(
in html: String,
chapterHref: String,
@@ -69,10 +68,14 @@ struct RDEPUBRenderDiagnosticsCollector {
return (html, "", [])
}
var rewrittenHTML = html
var inlinedCSSBlocks: [String] = []
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let href = nsHTML.substring(with: match.range(at: 1))
@@ -103,9 +106,9 @@ struct RDEPUBRenderDiagnosticsCollector {
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
}
// MARK: - CSS URL
/// CSS url()
static func rewriteCSSResourceURLs(
in css: String,
styleSheetFileURL: URL
@@ -121,18 +124,25 @@ struct RDEPUBRenderDiagnosticsCollector {
}
var rewrittenCSS = css
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
guard !rawValue.isEmpty else { continue }
if rawValue.hasPrefix("data:") || rawValue.hasPrefix("http://") || rawValue.hasPrefix("https://") || rawValue.hasPrefix("file://") || rawValue.hasPrefix("#") {
if rawValue.hasPrefix("data:")
|| rawValue.hasPrefix("http:")
|| rawValue.hasPrefix("https:") {
continue
}
guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
continue
}
let replacement = "url(\"\(resolvedURL.absoluteString)\")"
if let range = Range(match.range, in: rewrittenCSS) {
rewrittenCSS.replaceSubrange(range, with: replacement)
@@ -141,8 +151,6 @@ struct RDEPUBRenderDiagnosticsCollector {
return rewrittenCSS
}
// MARK: -
static func resolveReference(
_ reference: String,
kind: RDEPUBTextResourceReferenceKind,
@@ -150,11 +158,16 @@ struct RDEPUBRenderDiagnosticsCollector {
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
kind: kind,
chapterHref: chapterHref,
@@ -1,29 +1,19 @@
/// RDEPUBSemanticMarkerInjector -
import Foundation
import UIKit
/// HTML
///
///
/// 1. HTML `process` `${rd-sem-start/end}`
/// 2. `apply` NSAttributedString
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
/// ${rd-sem-start:...} / ${rd-sem-end:...}
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
/// HTML `${rd-sem-start/end}`
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.injectPaginationSemanticMarkers(into: html)
}
/// NSAttributedString
func apply(to attributedString: NSMutableAttributedString) {
Self.applyPaginationSemantics(in: attributedString)
}
// MARK: - HTML
/// HTML ${rd-sem-start/end}
static func injectPaginationSemanticMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
return html
@@ -36,8 +26,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
}
var output = ""
var cursor = 0
var openTagStack: [(name: String, id: String)] = []
var nextMarkerID = 0
for match in matches {
@@ -47,9 +40,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
let tag = nsHTML.substring(with: tagRange)
let loweredTag = tag.lowercased()
let tagName = htmlTagName(from: loweredTag)
if loweredTag.hasPrefix("</"), let tagName {
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
let markerID = openTagStack.remove(at: index).id
output += semanticEndMarker(id: markerID)
@@ -57,16 +52,20 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
output += tag
} else if let tagName,
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
nextMarkerID += 1
let markerID = String(nextMarkerID)
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
output += startMarker + tag + semanticEndMarker(id: markerID)
} else {
openTagStack.append((name: tagName, id: markerID))
output += tag + startMarker
}
} else {
output += tag
}
@@ -77,30 +76,34 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return output
}
// MARK: -
/// HTML NSAttributedString
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return
}
let mutableString = NSMutableString(string: attributedString.string)
var searchRange = NSRange(location: 0, length: mutableString.length)
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let kind = mutableString.substring(with: match.range(at: 1))
let payload = mutableString.substring(with: match.range(at: 2))
let markerLocation = match.range.location
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
if kind == "start" {
let semantics = parseSemanticMarkerPayload(payload)
openRanges[semantics.id] = (markerLocation, semantics)
} else {
let markerID = parseSemanticEndID(payload)
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
let length = max(markerLocation - opened.location, 0)
@@ -114,8 +117,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
}
}
// MARK: -
static func htmlTagName(from loweredTag: String) -> String? {
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("<") else { return nil }
@@ -297,7 +298,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
attributedString.addAttributes(attributes, range: range)
}
///
static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
if let rawValue = attributes[.rdPageBlockKind] as? String,
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
@@ -306,7 +306,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return nil
}
///
static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
if let rawValue = attributes[.rdPageSemanticHints] as? String {
let hints = rawValue
@@ -317,7 +316,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return nil
}
///
static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
@@ -329,9 +327,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return nil
}
// MARK: -
/// HTML
struct RDPaginationSemantics {
var id: String
var blockKind: RDEPUBTextBlockKind?
@@ -1,24 +1,20 @@
import UIKit
/// HTMLCSS
struct RDEPUBStyleSheetComposition {
/// HTML
var html: String
/// CSS default/replace/dark/epub/user
var layers: [RDEPUBTextStyleSheetLayer]
/// CSS
var inlinedCSS: String
///
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
var compatibilityReport: RDEPUBCSSCompatibilityReport
}
/// EPUB CSS HTML
struct RDEPUBStyleSheetComposer {
/// CSS HTML
/// - Parameters:
/// - html: HTML
/// - input:
/// - Returns: HTMLCSS
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
in: html,
@@ -26,9 +22,10 @@ struct RDEPUBStyleSheetComposer {
baseURL: input.baseURL,
resourceResolver: input.resourceResolver
)
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
let layers = Self.makeStyleSheetLayers(
style: input.style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
epubCSS: compatibility.css,
contentLanguageCode: input.contentLanguageCode,
sourceHTML: input.rawHTML
)
@@ -61,14 +58,12 @@ struct RDEPUBStyleSheetComposer {
return RDEPUBStyleSheetComposition(
html: composedHTML,
layers: layers,
inlinedCSS: stylesheetHrefReplacements.inlinedCSS,
diagnostics: stylesheetHrefReplacements.diagnostics
inlinedCSS: compatibility.css,
diagnostics: stylesheetHrefReplacements.diagnostics,
compatibilityReport: compatibility.report
)
}
// MARK: - CSS
/// CSS default/replace/dark/epub/user
static func makeStyleSheetLayers(
style: RDEPUBTextRenderStyle,
epubCSS: String,
@@ -93,17 +88,13 @@ struct RDEPUBStyleSheetComposer {
return layers
}
// MARK: - Style
/// `<style>`
enum StyleInjectionPosition {
/// `<head>`
case headStart
/// `</head>`
case headEnd
}
/// HTML <style>
static func injectStyleTag(
into html: String,
styleID: String,
@@ -133,8 +124,6 @@ struct RDEPUBStyleSheetComposer {
return styleTag + "\n" + html
}
// MARK: - CSS
private static func defaultCSS() -> String {
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
}
@@ -186,8 +175,6 @@ struct RDEPUBStyleSheetComposer {
return luminance < 0.5
}
// MARK: -
private static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
@@ -5,25 +5,8 @@ import CoreText
import DTCoreText
#endif
/// Facade stage HTML
///
/// 线 stage
/// `RDEPUBDTCoreTextRenderer` stage
enum RDEPUBTextRendererSupport {
// MARK: - 线
/// HTML
///
///
/// 1. HTMLNormalizer.normalizeHTML
/// 2. SemanticMarkerInjector.injectPaginationSemanticMarkers
/// 3. DiagnosticsCollector.inlineLinkedStyleSheets
/// 4. StyleSheetComposer.makeStyleSheetLayers + injectStyleTag
/// 5. FontNormalizer.registerEmbeddedFonts
/// 6. HTMLNormalizer.injectBaseHref
/// 7. FragmentMarkerInjector.injectFragmentMarkers
/// 8. DiagnosticsCollector.collectImageDiagnostics
static func makeChapterRenderRequest(
href: String,
title: String,
@@ -35,27 +18,33 @@ enum RDEPUBTextRendererSupport {
pageSize: CGSize? = nil,
layoutConfig: RDEPUBTextLayoutConfig? = nil
) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers(
into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML)
)
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
in: normalizedHTML,
chapterHref: href,
baseURL: baseURL,
resourceResolver: resourceResolver
)
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
contentLanguageCode: contentLanguageCode,
sourceHTML: rawHTML
)
RDEPUBFontNormalizer.registerEmbeddedFonts(
in: stylesheetHrefReplacements.inlinedCSS + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
chapterHref: href,
resourceResolver: resourceResolver
)
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithBase,
styleID: "rd-native-default-replace-dark",
@@ -65,19 +54,23 @@ enum RDEPUBTextRendererSupport {
.joined(separator: "\n\n"),
position: .headStart
)
let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithDefaultLayers,
styleID: "rd-native-epub",
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
position: .headEnd
)
let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithEPUBLayer,
styleID: "rd-native-user",
css: layers.first(where: { $0.kind == .user })?.css ?? "",
position: .headEnd
)
let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML)
let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics(
in: markedHTML,
chapterHref: href,
@@ -101,59 +94,70 @@ enum RDEPUBTextRendererSupport {
)
}
// MARK: - RDEPUBDTCoreTextRenderer
///
static func normalizeReadingAttributes(
in attributedString: NSMutableAttributedString,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
let sourceText = attributedString.string as NSString
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
let sourceFont = attributes[.font] as? UIFont
let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font)
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
paragraph.lineSpacing = style.lineSpacing
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
var updatedAttributes = attributes
updatedAttributes[.font] = normalizedFont
updatedAttributes[.paragraphStyle] = paragraph
if let textColor = style.textColor {
updatedAttributes[.foregroundColor] = textColor
}
RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
.flatMap(NSRangeFromString)
.flatMap { $0.length > 0 ? $0 : nil }
let paragraphRange = sourceText.length > 0
? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
: range
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
updatedAttributes[.rdPageBlockIndex] = blockIndex
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) {
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
}
if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) {
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
}
if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) {
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
}
if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty {
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
}
attributedString.setAttributes(updatedAttributes, range: range)
blockIndex += 1
}
}
/// 退 DTCoreText HTML
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: style.font,
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
@@ -162,7 +166,6 @@ enum RDEPUBTextRendererSupport {
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
}
///
static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
let style = NSMutableParagraphStyle()
style.lineSpacing = lineSpacing
@@ -1,69 +1,77 @@
import UIKit
/// HTML
struct RDEPUBTypesettingInput {
///
var href: String
///
var spineIndex: Int?
var title: String
/// HTML
var rawHTML: String
/// HTML URL
var baseURL: URL?
///
var style: RDEPUBTextRenderStyle
///
var resourceResolver: RDEPUBResourceResolver?
/// zh-CN
var contentLanguageCode: String?
///
var pageSize: CGSize?
///
var layoutConfig: RDEPUBTextLayoutConfig?
}
///
struct RDEPUBTypesettingOutput {
///
var request: RDEPUBTextChapterRenderRequest
///
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
}
/// HTML 线
protocol RDEPUBTypesettingStage {
/// HTML
/// - Parameters:
/// - html: HTML
/// - context:
/// - Returns: HTML
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
}
/// 线 HTML
struct RDEPUBTextTypesetterPipeline {
///
/// - Parameter input:
/// - Returns:
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
let htmlNormalizer = RDEPUBHTMLNormalizer()
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
let cfiMarkerInjector = RDEPUBCFIMarkerInjector()
let styleSheetComposer = RDEPUBStyleSheetComposer()
let fontNormalizer = RDEPUBFontNormalizer()
let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
let normalizedHTML = semanticMarkerInjector.process(
htmlNormalizer.process(input.rawHTML, context: input),
let normalizedHTML = cfiMarkerInjector.process(
semanticMarkerInjector.process(
htmlNormalizer.process(input.rawHTML, context: input),
context: input
),
context: input
)
let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
fontNormalizer.registerEmbeddedFonts(
let fontResults = fontNormalizer.registerEmbeddedFonts(
html: normalizedHTML,
inlinedCSS: styleSheetComposition.inlinedCSS,
input: input
)
let compatibilityReport = Self.mergedCompatibilityReport(
styleSheetComposition.compatibilityReport,
fontResults: fontResults
)
let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
let context = RDEPUBTextChapterContext(
@@ -72,7 +80,8 @@ struct RDEPUBTextTypesetterPipeline {
html: markedHTML,
baseURL: input.baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
resourceDiagnostics: diagnostics
resourceDiagnostics: diagnostics,
styleCompatibilityReport: compatibilityReport
)
let request = RDEPUBTextChapterRenderRequest(
context: context,
@@ -82,7 +91,27 @@ struct RDEPUBTextTypesetterPipeline {
)
return RDEPUBTypesettingOutput(
request: request,
diagnostics: diagnostics
diagnostics: diagnostics,
styleCompatibilityReport: compatibilityReport
)
}
private static func mergedCompatibilityReport(
_ report: RDEPUBCSSCompatibilityReport,
fontResults: [RDEPUBFontRegistrationResult]
) -> RDEPUBCSSCompatibilityReport {
let fontFailures = fontResults.compactMap { result -> String? in
guard result.didRegister == false else { return nil }
let family = result.descriptor.family ?? "unknown-family"
let href = result.descriptor.href
let reason = result.errorDescription ?? "Font registration failed"
return "\(family) <\(href)>: \(reason)"
}
return RDEPUBCSSCompatibilityReport(
unsupportedRules: report.unsupportedRules,
normalizedRules: report.normalizedRules,
fontFailures: report.fontFailures + fontFailures
)
}
}
@@ -0,0 +1,24 @@
import UIKit
final class RDEPUBNotePopupCoordinator {
static func present(
_ note: RDEPUBResolvedNote,
from controller: UIViewController,
onReturnToSource: (() -> Void)? = nil,
onOpenNoteLocation: (() -> Void)? = nil
) {
let popup = RDEPUBNotePopupViewController(
note: note,
onReturnToSource: onReturnToSource,
onOpenNoteLocation: onOpenNoteLocation
)
let navigationController = UINavigationController(rootViewController: popup)
navigationController.modalPresentationStyle = .pageSheet
if let sheet = navigationController.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
controller.present(navigationController, animated: true)
}
}
@@ -0,0 +1,145 @@
import UIKit
final class RDEPUBNotePopupViewController: UIViewController {
private let note: RDEPUBResolvedNote
private let onReturnToSource: (() -> Void)?
private let onOpenNoteLocation: (() -> Void)?
private let textView = UITextView()
private let actionsStackView = UIStackView()
init(
note: RDEPUBResolvedNote,
onReturnToSource: (() -> Void)? = nil,
onOpenNoteLocation: (() -> Void)? = nil
) {
self.note = note
self.onReturnToSource = onReturnToSource
self.onOpenNoteLocation = onOpenNoteLocation
super.init(nibName: nil, bundle: nil)
title = note.title ?? "注释"
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done,
target: self,
action: #selector(close)
)
textView.translatesAutoresizingMaskIntoConstraints = false
textView.isEditable = false
textView.alwaysBounceVertical = true
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18)
textView.attributedText = attributedContent()
textView.accessibilityIdentifier = "epub.reader.note.text"
actionsStackView.translatesAutoresizingMaskIntoConstraints = false
actionsStackView.axis = .horizontal
actionsStackView.spacing = 12
actionsStackView.distribution = .fillEqually
actionsStackView.accessibilityIdentifier = "epub.reader.note.actions"
configureActionButtons()
view.addSubview(actionsStackView)
view.addSubview(textView)
NSLayoutConstraint.activate([
actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18),
actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18),
actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36),
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func configureActionButtons() {
if note.sourceLocation != nil, onReturnToSource != nil {
actionsStackView.addArrangedSubview(
makeActionButton(title: "返回原文", action: #selector(returnToSource))
)
}
if onOpenNoteLocation != nil {
actionsStackView.addArrangedSubview(
makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation))
)
}
actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty
}
private func makeActionButton(title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .medium
configuration.title = title
configuration.baseBackgroundColor = .secondarySystemBackground
configuration.baseForegroundColor = .label
button.configuration = configuration
if title == "返回原文" {
button.accessibilityIdentifier = "epub.reader.note.return"
} else if title == "打开注释原文" {
button.accessibilityIdentifier = "epub.reader.note.open"
}
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func attributedContent() -> NSAttributedString {
let html = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font: -apple-system-body;
color: #1f1f1f;
line-height: 1.55;
}
a { color: #3b6ea8; }
img, svg, table { max-width: 100%; height: auto; }
</style>
</head>
<body>\(note.html)</body>
</html>
"""
guard let data = html.data(using: .utf8),
let attributed = try? NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else {
return NSAttributedString(string: note.plainText)
}
return attributed
}
@objc private func close() {
dismiss(animated: true)
}
@objc private func returnToSource() {
dismiss(animated: true) { [onReturnToSource] in
onReturnToSource?()
}
}
@objc private func openNoteLocation() {
dismiss(animated: true) { [onOpenNoteLocation] in
onOpenNoteLocation?()
}
}
}
@@ -1,27 +1,17 @@
import UIKit
// MARK: -
///
///
/// configuration
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
// MARK:
///
var onShowTableOfContents: (() -> Void)?
///
var onShowBookmarks: (() -> Void)?
///
var onShowHighlights: (() -> Void)?
///
var onAddHighlight: (() -> Void)?
///
var onShowSettings: (() -> Void)?
// MARK: UI
///
private let stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
@@ -31,21 +21,20 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
return view
}()
///
private let chapterButton = RDEPUBReaderTintButton(type: .system)
///
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
///
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
///
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
///
private let settingsButton = RDEPUBReaderTintButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.bottomToolbar"
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(chapterButton)
@@ -99,9 +88,6 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
}
}
// MARK:
///
func setAddHighlightEnabled(_ isEnabled: Bool) {
addHighlightButton.isEnabled = isEnabled
addHighlightButton.alpha = isEnabled ? 1 : 0.45
@@ -1,15 +1,13 @@
import UIKit
// MARK: -
/// EPUB
///
final class RDEPUBReaderChapterListController: UITableViewController {
///
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem]
private let currentItem: RDEPUBReaderTableOfContentsItem?
private let theme: RDEPUBReaderTheme
init(
@@ -1,19 +1,8 @@
// RDEPUBReaderController+ContentDelegates.swift
// EPUB
// Web RDEPUBWebContentViewDelegate Native Text
// RDEPUBTextContentViewDelegate
import UIKit
/// RDEPUBReaderController
///
/// EPUB Web Native Text
///
// MARK: - Web EPUB /Web
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
@@ -35,7 +24,6 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
@@ -44,13 +32,15 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
return
}
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
@@ -62,13 +52,11 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
openExternalURLIfAllowed(url)
}
/// Web JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
@@ -76,10 +64,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
// MARK: - Native Text
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
@@ -88,7 +74,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
updateCurrentSelection(selection)
}
///
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
readerView.handleContentTap(at: point, in: contentView)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@@ -115,8 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
/// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
@@ -169,38 +156,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
///
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let fragmentID = nearestFragmentID(
beforeOrAt: startOffset,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
)
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: resolvedPage.page.href,
progression: Double(startOffset) / Double(chapterLength),
lastProgression: Double(endOffset) / Double(chapterLength),
fragment: fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(
start: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: startOffset,
fragmentID: fragmentID
),
end: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: endOffset,
fragmentID: fragmentID
)
)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
@@ -231,7 +194,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return location
}
/// spine
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
@@ -259,7 +221,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
/// +
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
@@ -280,6 +241,15 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
return offset
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
@@ -308,8 +278,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return bestID
}
// MARK: -
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
@@ -340,6 +308,52 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
present(alert, animated: true)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
guard let publication else { return false }
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
let sourceLocation = currentVisibleLocation()
let sourceCFI = sourceLocation?.cfi
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
guard let note = resolver.resolveInternalLink(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetLocation: location,
sourceLocation: sourceLocation,
relativeToSpineIndex: fromSpineIndex
) else {
return false
}
RDEPUBNotePopupCoordinator.present(
note,
from: self,
onReturnToSource: { [weak self] in
guard let self, let sourceLocation = note.sourceLocation else { return }
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
},
onOpenNoteLocation: { [weak self] in
guard let self else { return }
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
}
)
return true
}
private func navigateToLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int?,
animated: Bool
) {
guard let pageNumber = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: spineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
@@ -415,7 +429,28 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
}
private extension RDEPUBReaderController {
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
@@ -449,17 +484,26 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView {
}
private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -468,6 +512,7 @@ private final class RDEPUBAttachmentTooltipView: UIView {
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
@@ -1,35 +1,34 @@
// RDEPUBReaderController+DataSource.swift
// EPUB
// RDReaderDataSource RDReaderDelegate
//
import UIKit
/// RDEPUBReaderController
///
/// RDReaderDataSource RDReaderDelegate
///
// MARK: - RDReaderView
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
///
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
}
/// 退 Web
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configure(
page: resolvedPage.page,
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration,
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
highlights: textHighlights(for: resolvedPage.page),
searchState: searchState(for: resolvedPage.page)
)
@@ -40,11 +39,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configure(
page: page,
pageNumber: pageNum + 1,
totalPages: textBook.pages.count,
configuration: configuration,
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
highlights: textHighlights(for: page),
searchState: searchState(for: page)
)
@@ -68,14 +78,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
return contentView
}
/// Web
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
(textBook == nil && readerContext.bookPageMap == nil)
? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self)
}
///
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
@@ -91,9 +99,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
}
///
/// currentMatchIndex
/// href
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil }
@@ -237,6 +242,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
@@ -274,31 +280,25 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
return publication.resourceResolver.normalizedHref(href) ?? href
}
/// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href)
}
///
public func topToolView(readerView: RDReaderView) -> UIView? {
topToolView
}
///
public func bottomToolView(readerView: RDReaderView) -> UIView? {
bottomToolView
}
///
public func pageNum(readerView: RDReaderView, pageNum: Int) {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
//
guard !isRepaginating else { return }
// map
let previousCurrentPage = readerView.currentPage
runtime.applyPendingFullPageMapIfNeeded()
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
@@ -320,7 +320,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
// JumpSession
runtime.locationCoordinator.recordPageChangeIfNeeded()
return
}
@@ -332,17 +332,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
readingSession?.transition(to: .idle)
}
// JumpSession
runtime.locationCoordinator.recordPageChangeIfNeeded()
}
///
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
_ = isLandscape
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
}
///
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil || readerContext.bookPageMap != nil,
!isRepaginating,
@@ -365,12 +362,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
isReconcilingTextPaginationSize = true
// 使
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self else { return }
self.isReconcilingTextPaginationSize = false
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
//
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
guard currentSize.width > 0, currentSize.height > 0 else { return }
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
@@ -1,54 +1,34 @@
// RDEPUBReaderController+PublicAPI.swift
// RDEPUBReaderController API
import UIKit
// MARK: - Public Reader Commands
extension RDEPUBReaderController {
/// EPUB
public func reloadBook() {
runtime.reloadBook()
}
///
/// - Parameter location:
public func go(to location: RDEPUBLocation) {
guard publication != nil else { return }
_ = runtime.go(to: location)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
runtime.go(toPageNumber: pageNumber, animated: animated)
}
///
public func clearSelection() {
runtime.clearSelection()
}
///
/// - Parameter id: ID
/// - Returns: nil
public func bookmark(withID id: String) -> RDEPUBBookmark? {
runtime.bookmark(withID: id)
}
///
/// - Parameter id: ID
/// - Returns: nil
public func highlight(withID id: String) -> RDEPUBHighlight? {
runtime.highlight(withID: id)
}
/// 访
/// - Returns: nil
public func nativeTextSemanticSummary() -> String? {
let resolvedPage: RDEPUBTextPage?
if let textBook {
@@ -79,12 +59,6 @@ extension RDEPUBReaderController {
return parts.joined(separator: " · ")
}
///
/// - Parameters:
/// - selection: 使 currentSelection
/// - color: CSS
/// - note:
/// - Returns: nil
@discardableResult
public func addHighlight(
from selection: RDEPUBSelection? = nil,
@@ -94,13 +68,6 @@ extension RDEPUBReaderController {
runtime.addHighlight(from: selection, color: color, note: note)
}
///
/// - Parameters:
/// - selection: 使 currentSelection
/// - style: 线
/// - color: CSS
/// - note:
/// - Returns: nil
@discardableResult
public func addAnnotation(
from selection: RDEPUBSelection? = nil,
@@ -111,72 +78,40 @@ extension RDEPUBReaderController {
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
}
///
/// - Parameter highlight:
/// - Returns:
@discardableResult
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
runtime.upsertHighlight(highlight)
}
///
/// - Parameter id: ID
/// - Returns: nil
@discardableResult
public func removeHighlight(id: String) -> RDEPUBHighlight? {
runtime.removeHighlight(id: id)
}
///
/// - Parameters:
/// - id: ID
/// - note: nil
/// - Returns: nil
@discardableResult
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
runtime.updateHighlightNote(id: id, note: note)
}
///
/// - Parameters:
/// - id: ID
/// - animated:
/// - Returns:
@discardableResult
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
runtime.go(toHighlightID: id, animated: animated)
}
///
public func removeAllHighlights() {
runtime.removeAllHighlights()
}
///
/// - Parameters:
/// - item: EPUB
/// - animated:
/// - Returns:
@discardableResult
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
///
/// - Parameters:
/// - item: RDReader
/// - animated:
/// - Returns:
@discardableResult
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
/// href href
/// - Parameters:
/// - href: #fragment
/// - animated:
/// - Returns:
@discardableResult
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
guard publication != nil else { return false }
@@ -193,61 +128,40 @@ extension RDEPUBReaderController {
return restoreReadingLocation(location, animated: animated)
}
///
/// - Parameter note:
/// - Returns: nil
@discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.addBookmark(note: note)
}
///
/// - Parameter note:
/// - Returns:
@discardableResult
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.toggleBookmark(note: note)
}
///
/// - Parameter id: ID
/// - Returns: nil
@discardableResult
public func removeBookmark(id: String) -> RDEPUBBookmark? {
runtime.removeBookmark(id: id)
}
///
/// - Parameters:
/// - id: ID
/// - animated:
/// - Returns:
@discardableResult
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
runtime.go(toBookmarkID: id, animated: animated)
}
///
/// - Parameter keyword:
public func search(keyword: String) {
runtime.search(keyword: keyword)
}
///
/// - Returns:
@discardableResult
public func searchNext() -> Bool {
runtime.searchNext()
}
///
/// - Returns:
@discardableResult
public func searchPrevious() -> Bool {
runtime.searchPrevious()
}
///
public func clearSearch() {
runtime.clearSearch()
}
@@ -1,47 +1,32 @@
// RDEPUBReaderController+RenderSupport.swift
// EPUB
//
// 宿退
import UIKit
/// RDEPUBReaderController
///
///
/// 退
extension RDEPUBReaderController {
///
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
readerContext.currentLayoutContext()
}
///
func currentPreferences() -> RDEPUBPreferences {
readerContext.currentPreferences()
}
///
func currentTextPageSize() -> CGSize {
readerContext.currentTextPageSize()
}
///
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
readerContext.currentTextRenderStyle()
}
///
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
readerContext.currentTextLayoutConfig(pageSize: pageSize)
}
///
func resolvedTextRenderer() -> RDEPUBTextRenderer {
readerContext.resolvedTextRenderer()
}
/// 宿
func ensurePaginationHostView() -> UIView {
let viewportSize = currentLayoutContext().viewportSize
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
@@ -53,7 +38,6 @@ extension RDEPUBReaderController {
return paginationHostView
}
///
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
guard let publication, activePages.indices.contains(pageIndex) else {
return nil
@@ -75,13 +59,11 @@ extension RDEPUBReaderController {
)
}
/// 退使
func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
guard activePages.indices.contains(pageIndex) else { return nil }
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
}
///
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
guard let publication else { return [] }
if let spread = page.fixedSpread {
@@ -1,17 +1,8 @@
// RDEPUBReaderController+RuntimeBridge.swift
// EPUB
// runtime
//
import UIKit
/// RDEPUBReaderController
///
/// runtime
///
extension RDEPUBReaderController {
/// //
func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection()
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
@@ -32,17 +23,14 @@ extension RDEPUBReaderController {
}
}
///
func startInitialLoadIfNeeded() {
runtime.startInitialLoadIfNeeded()
}
/// EPUB
func loadPublication() {
runtime.loadPublication()
}
///
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -61,17 +49,14 @@ extension RDEPUBReaderController {
)
}
///
func paginatePublication(restoreLocation: RDEPUBLocation?) {
runtime.paginatePublication(restoreLocation: restoreLocation)
}
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
}
///
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -79,17 +64,14 @@ extension RDEPUBReaderController {
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
runtime.finishPagination(restoreLocation: restoreLocation)
}
///
func repaginatePreservingCurrentLocation() {
runtime.repaginatePreservingCurrentLocation()
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -103,27 +85,22 @@ extension RDEPUBReaderController {
)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
runtime.currentVisibleLocation()
}
///
func persistenceLocation() -> RDEPUBLocation? {
readerContext.persistenceLocation()
}
///
func persist(location: RDEPUBLocation) {
readerContext.persist(location: location)
}
///
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
runtime.annotationCoordinator.updateCurrentSelection(selection)
}
/// spine
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
@@ -131,60 +108,49 @@ extension RDEPUBReaderController {
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
}
///
func refreshVisibleContentPreservingLocation() {
runtime.refreshVisibleContentPreservingLocation()
}
///
func rebuildExternalTextBook() {
runtime.rebuildExternalTextBook()
}
/// UI
func updateReaderChrome() {
runtime.updateReaderChrome()
}
///
func presentBookmarksManager() {
runtime.presentBookmarksManager()
}
///
func presentHighlightsManager() {
runtime.presentHighlightsManager()
}
///
func presentAnnotationCreation() {
runtime.presentAnnotationCreation()
}
///
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
runtime.handleSelectionMenuAction(action, selection: selection)
}
///
func presentSettings() {
runtime.presentSettings()
}
/// 使
func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
var nextConfiguration = configuration
update(&nextConfiguration)
configuration = nextConfiguration
}
///
func setScreenBrightness(_ brightness: CGFloat) {
currentBrightness = max(0, min(1, brightness))
persistReaderSettingsIfNeeded()
}
///
func persistReaderSettingsIfNeeded() {
let settings = RDEPUBReaderSettings.capture(
configuration: configuration,
@@ -193,7 +159,6 @@ extension RDEPUBReaderController {
persistence?.saveReaderSettings(settings)
}
///
func requiresRepagination(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
@@ -210,7 +175,6 @@ extension RDEPUBReaderController {
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
}
///
func requiresVisibleRefresh(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
@@ -220,17 +184,14 @@ extension RDEPUBReaderController {
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
}
///
func presentTableOfContents() {
runtime.presentTableOfContents()
}
///
func handleBackAction() {
runtime.handleBackAction()
}
///
func handle(error: Error) {
isRepaginating = false
hideLoading()
@@ -243,23 +204,19 @@ extension RDEPUBReaderController {
delegate?.epubReader(self, didFailWithError: error)
}
///
func showLoading() {
errorLabel.isHidden = true
loadingIndicator.startAnimating()
}
///
func hideLoading() {
loadingIndicator.stopAnimating()
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
runtime.currentViewportSignature()
}
///
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -267,21 +224,17 @@ extension RDEPUBReaderController {
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
///
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
runtime.searchPresentation(for: page)
}
///
private func resolvedPageDirection() -> RDReaderView.PageDirection {
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
}
}
// MARK: -
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
/// true
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
@@ -1,17 +1,8 @@
// RDEPUBReaderController+TableOfContents.swift
// EPUB
// Table of Contents
// 线
import Foundation
/// RDEPUBReaderController
///
/// Table of Contents
/// 线
extension RDEPUBReaderController {
/// href
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContentsItems(
from: publication?.tableOfContents ?? [],
@@ -51,7 +42,6 @@ extension RDEPUBReaderController {
}
}
/// 线
func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0,
@@ -1,32 +1,9 @@
import UIKit
// MARK: -
/// EPUB
/// EPUBUI
/// EPUB URL TextBook
///
/// **** EPUBUI Core Parser Foundation
///
/// ****
/// - EPUB
/// -
/// -
/// - UI
/// -
///
/// **** init(epubURL:) viewDidLoad RDEPUBParser.parse paginatePublication readerView.reloadData restoreLocation
// MARK: -
public final class RDEPUBReaderController: UIViewController {
// MARK: -
///
public weak var delegate: RDEPUBReaderDelegate?
///
public var configuration: RDEPUBReaderConfiguration {
didSet {
readerContext.configuration = configuration
@@ -54,65 +31,58 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
/// 1 nil
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
/// /
public internal(set) var currentSelection: RDEPUBSelection? {
get { readerContext.currentSelection }
set { readerContext.currentSelection = newValue }
}
///
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
///
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
/// WXRead WRBookmark
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
///
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
///
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
// MARK: -
/// EPUB URL
let epubURL: URL
let persistence: RDEPUBReaderPersistence?
let dependencies: RDEPUBReaderDependencies
let readerView = RDReaderView()
let loadingIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .large)
}()
let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -121,97 +91,115 @@ public final class RDEPUBReaderController: UIViewController {
label.isHidden = true
return label
}()
let paginationHostView = UIView()
lazy var readerContext = RDEPUBReaderContext(controller: self)
var parser: RDEPUBParser? {
get { readerContext.parser }
set { readerContext.parser = newValue }
}
var publication: RDEPUBPublication? {
get { readerContext.publication }
set { readerContext.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { readerContext.readingSession }
set { readerContext.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { readerContext.textBook }
set { readerContext.textBook = newValue }
}
var activePages: [EPUBPage] {
readerContext.activePages
}
var activeChapters: [EPUBChapterInfo] {
readerContext.activeChapters
}
var activeBookmarks: [RDEPUBBookmark] {
get { readerContext.activeBookmarks }
set { readerContext.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { readerContext.activeHighlights }
set { readerContext.activeHighlights = newValue }
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
///
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
}
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
var currentBrightness: CGFloat {
get { readerContext.currentBrightness }
set { readerContext.currentBrightness = newValue }
}
var didStartInitialLoad: Bool {
get { readerContext.didStartInitialLoad }
set { readerContext.didStartInitialLoad = newValue }
}
var isRepaginating: Bool {
get { readerContext.isRepaginating }
set { readerContext.isRepaginating = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { readerContext.lastTextPaginationPageSize }
set { readerContext.lastTextPaginationPageSize = newValue }
}
var isReconcilingTextPaginationSize = false
var paginationToken: UUID {
get { readerContext.paginationToken }
set { readerContext.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { readerContext.paginator }
set { readerContext.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { readerContext.searchState }
set { readerContext.searchState = newValue }
}
var isExternalTextBook: Bool {
get { readerContext.isExternalTextBook }
set { readerContext.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { readerContext.textFileURL }
set { readerContext.textFileURL = newValue }
}
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
// MARK: -
/// 使 EPUB URL
///
/// - Parameters:
/// - epubURL: EPUB URL
/// - configuration: 使 .default
/// - persistence: 使 UserDefaults
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
@@ -225,7 +213,7 @@ public final class RDEPUBReaderController: UIViewController {
self.dependencies = dependencies
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
super.init(nibName: nil, bundle: nil)
// Sync context after super.init
readerContext.dependencies = dependencies
readerContext.configuration = self.configuration
readerContext.epubURL = epubURL
@@ -245,15 +233,6 @@ public final class RDEPUBReaderController: UIViewController {
)
}
/// 使 TextBook EPUB
/// TXT TextBook
/// - Parameters:
/// - textBook: TextBook
/// - bookIdentifier:
/// - title:
/// - textFileURL: URL TextBook
/// - configuration:
/// - persistence:
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
@@ -317,10 +296,6 @@ public final class RDEPUBReaderController: UIViewController {
runtime.viewportMonitor.viewWillTransition(with: coordinator)
}
// MARK: -
///
/// `handleToolViewVisibilityChanged`
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
@@ -367,7 +342,6 @@ public final class RDEPUBReaderController: UIViewController {
}
}
/// readerView
private func installSearchBarView() {
guard searchBarView.superview == nil else { return }
searchBarView.apply(theme: configuration.theme)
@@ -403,8 +377,6 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
/// - Parameter clearSearch:
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
@@ -425,7 +397,6 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
@@ -438,7 +409,6 @@ public final class RDEPUBReaderController: UIViewController {
)
}
/// RDReaderView
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if isSearchBarVisible {
@@ -456,6 +426,7 @@ public final class RDEPUBReaderController: UIViewController {
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
@@ -1,93 +1,34 @@
import UIKit
// MARK: -
/// EPUB
///
///
public protocol RDEPUBReaderDelegate: AnyObject {
///
/// - Parameters:
/// - reader:
/// - publication:
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
///
/// - Parameters:
/// - reader:
/// - location:
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
///
/// - Parameter reader:
func epubReaderDidReachEnd(_ reader: UIViewController)
///
/// - Parameters:
/// - reader:
/// - selection: nil
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
///
/// - Parameters:
/// - reader:
/// - highlights:
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
///
/// - Parameters:
/// - reader:
/// - bookmarks:
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
///
/// - Parameters:
/// - reader:
/// - result: nil
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
///
/// - Parameters:
/// - reader:
/// - match: nil
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
///
/// - Parameters:
/// - reader:
/// - item:
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
///
/// - Parameters:
/// - reader:
/// - url: URL
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// false
/// - Parameters:
/// - reader:
/// - url: URL
/// - Returns:
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
///
/// - Parameters:
/// - reader:
/// - error:
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
///
/// viewDidLoad
/// - Parameters:
/// - reader:
/// - topToolView:
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
// MARK: -
///
public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
@@ -1,24 +1,19 @@
import UIKit
// MARK: -
/// EPUB
/// //
final class RDEPUBReaderHighlightsViewController: UITableViewController {
///
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
///
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
///
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
///
private var highlights: [RDEPUBHighlight]
///
private let theme: RDEPUBReaderTheme
///
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
///
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
private var filteredHighlights: [RDEPUBHighlight] {
@@ -50,6 +45,8 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.highlights.panel"
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
configureFilterControl()
@@ -87,6 +84,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func configureFilterControl() {
filterControl.selectedSegmentIndex = 0
filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
navigationItem.titleView = filterControl
}
@@ -96,6 +94,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
}
private func updateEmptyState() {
@@ -109,6 +108,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.highlights.empty"
tableView.backgroundView = label
}
@@ -216,18 +216,16 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
}
}
// MARK: -
/// EPUB
///
final class RDEPUBReaderBookmarksViewController: UITableViewController {
///
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
///
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
private var bookmarks: [RDEPUBBookmark]
private let theme: RDEPUBReaderTheme
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
@@ -298,6 +296,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
tableView.backgroundView = label
return
}
@@ -1,45 +1,24 @@
import Foundation
// MARK: -
///
///
///
public protocol RDEPUBReaderPersistence: AnyObject {
///
/// - Parameter bookIdentifier:
/// - Returns: nil
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
///
/// - Parameters:
/// - location:
/// - bookIdentifier:
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
///
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
///
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
///
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
///
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
///
func loadReaderSettings() -> RDEPUBReaderSettings?
///
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
}
// MARK: -
/// //
/// DEBUG no-op 便
public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
#if DEBUG
@@ -68,30 +47,18 @@ public extension RDEPUBReaderPersistence {
}
}
// MARK: - UserDefaults
/// UserDefaults
/// JSON
/// SQLite
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
/// UserDefaults
private let defaults: UserDefaults
/// key
private let locationPrefix: String
/// key
private let bookmarksPrefix: String
/// key
private let highlightsPrefix: String
/// key
private let settingsKey: String
/// UserDefaults
/// - Parameters:
/// - defaults: UserDefaults .standard
/// - locationPrefix: key
/// - bookmarksPrefix: key
/// - highlightsPrefix: key
/// - settingsKey: key
public init(
defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.",
@@ -1,26 +1,33 @@
import UIKit
struct RDEPUBReaderSearchSection: Equatable {
struct Item: Equatable {
let matchIndex: Int
let previewText: String
let isCurrent: Bool
}
let title: String
let items: [Item]
}
// MARK: -
///
///
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
var onSearchSubmit: ((String) -> Void)?
var onSearchTextChanged: ((String) -> Void)?
var onSearchPrevious: (() -> Void)?
var onSearchNext: (() -> Void)?
var onSelectMatch: ((Int) -> Void)?
var onClose: (() -> Void)?
let textField: UITextField = {
@@ -39,23 +46,35 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}()
private let backgroundButton = UIButton(type: .custom)
private let panelView = UIView()
private let grabberView = UIView()
private let searchRowView = UIView()
private let searchFieldContainer = UIView()
private let searchIcon = UIImageView()
private let searchFieldDivider = UIView()
private let cancelButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyStateLabel = UILabel()
// Preserve legacy accessibility hooks used by existing tests and demo logic.
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel()
private var searchSections: [RDEPUBReaderSearchSection] = []
private var keyword = ""
private var currentMatchIndex: Int?
override init(frame: CGRect) {
@@ -221,7 +240,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
// Legacy shims
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
@@ -405,6 +423,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}
private extension UIColor {
var rd_searchIsDarkBackground: Bool {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -419,6 +438,7 @@ private extension UIColor {
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
searchSections.count
}
@@ -485,6 +505,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
}
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
func textFieldShouldClear(_ textField: UITextField) -> Bool {
DispatchQueue.main.async { [weak self] in
self?.onSearchTextChanged?("")
@@ -494,15 +515,21 @@ extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
}
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
static var highlightTextColor = UIColor.systemBlue
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
private let cardView = UIView()
private let previewLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@@ -1,17 +1,13 @@
import Foundation
// MARK: -
///
/// depth
public struct RDEPUBReaderTableOfContentsItem: Equatable {
///
public var title: String
/// href EPUB
public var href: String
/// 0
public var depth: Int
/// 1 nil
public var pageNumber: Int?
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
@@ -1,13 +1,9 @@
import UIKit
// MARK: -
///
/// 线
open class RDEPUBReaderToolView: UIView {
/// 线
private let lineView = UIView()
/// 线
private let lineHeight: CGFloat = 0.5
override init(frame: CGRect) {
@@ -25,24 +21,15 @@ open class RDEPUBReaderToolView: UIView {
lineView.frame = lineFrame(in: bounds)
}
///
/// - Parameter theme:
open func apply(theme: RDEPUBReaderTheme) {
backgroundColor = .white
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
}
/// 线线
/// - Parameter bounds: bounds
/// - Returns: 线 frame
open func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
}
}
// MARK: -
///
/// UIButton
final class RDEPUBReaderTintButton: UIButton {
}
@@ -1,16 +1,11 @@
import UIKit
// MARK: -
///
///
/// iOS 13+ SF Symbols 退
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
///
var onBack: (() -> Void)?
///
var onToggleBookmark: (() -> Void)?
///
var onSearch: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system)
@@ -1,19 +1,17 @@
import UIKit
///
///
struct RDEPUBViewportSignature: Equatable {
/// pt
let width: CGFloat
/// pt
let height: CGFloat
///
let safeTop: CGFloat
///
let safeLeft: CGFloat
///
let safeBottom: CGFloat
///
let safeRight: CGFloat
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
@@ -26,13 +24,11 @@ struct RDEPUBViewportSignature: Equatable {
}
}
///
enum RDEPUBViewportChangeReason {
/// Safe Area
case viewLayout
///
case orientationTransition
}
///
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
@@ -1,29 +1,20 @@
import UIKit
// MARK: - Web
/// Web
/// WebView
protocol RDEPUBWebContentViewDelegate: AnyObject {
/// WebView
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// WebView
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
/// WebView JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
}
// MARK: - Web
/// EPUB Web
/// RDEPUBWebView/
/// EPUB Native Text
final class RDEPUBWebContentView: UIView {
weak var delegate: RDEPUBWebContentViewDelegate?
@@ -1,8 +1,5 @@
import UIKit
/// EPUB WebView
/// WebView 线
/// WXRead JS rect CGContext
final class RDEPUBWebDecorationOverlayView: UIView {
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let verticalAdjustment: CGFloat = -1
@@ -18,8 +15,6 @@ final class RDEPUBWebDecorationOverlayView: UIView {
fatalError("init(coder:) has not been implemented")
}
///
/// - Parameter decorations: 线
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations.filter { !$0.rects.isEmpty }
setNeedsDisplay()
@@ -1,29 +1,6 @@
//
// RDURLReaderController.swift
// RDReaderDemo
//
// URL
// ReadViewSDK 使
// - .epub RDEPUBReaderController
// - RDPlainTextBookBuilder RDEPUBReaderController
// - 退 UITextView
//
// RDReaderView UI
//
import UIKit
/// URL
/// URL
/// - EPUB 使 RDEPUBReaderController
/// - RDPlainTextBookBuilder 使 RDEPUBReaderController
/// - 退 UITextView
///
/// 使
/// ```swift
/// let controller = RDURLReaderController(bookURL: fileURL)
/// navigationController?.pushViewController(controller, animated: true)
/// ```
public final class RDURLReaderController: UIViewController {
private struct PendingDemoPageRequest {
let pageNumber: Int
@@ -31,11 +8,10 @@ public final class RDURLReaderController: UIViewController {
var attemptCount: Int
}
/// URL
private let bookURL: URL
/// EPUB
private let epubConfiguration: RDEPUBReaderConfiguration
///
private var embeddedController: UIViewController?
private let demoStateLabel: UILabel = {
let label = UILabel()
@@ -58,10 +34,6 @@ public final class RDURLReaderController: UIViewController {
private var lastActivatedExternalURL: URL?
private var lastReaderErrorDescription = "none"
///
/// - Parameters:
/// - bookURL: URL
/// - epubConfiguration: EPUB 使
public init(
bookURL: URL,
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
@@ -76,7 +48,6 @@ public final class RDURLReaderController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
///
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
@@ -101,18 +72,11 @@ public final class RDURLReaderController: UIViewController {
stopDemoStateTimer()
}
/// Demo
/// - Parameter displayType:
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
readerController?.configuration.displayType = displayType
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
}
/// Demo
/// - Parameters:
/// - pageNumber:
/// - animated:
/// - Returns:
@discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
let moved = performDemoPageNavigation(pageNumber, animated: animated)
@@ -125,12 +89,6 @@ public final class RDURLReaderController: UIViewController {
return moved
}
/// Demo
///
/// - Parameters:
/// - displayTypes:
/// - initialPageNumber:
/// - stepDelay:
public func runDemoDisplaySequence(
_ displayTypes: [RDReaderView.DisplayType],
initialPageNumber: Int? = nil,
@@ -151,9 +109,6 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
/// `didUpdateLocation`
/// - Parameter keyword:
public func performDemoSearch(keyword: String) {
guard let readerController else { return }
readerController.showSearchBar()
@@ -187,8 +142,6 @@ public final class RDURLReaderController: UIViewController {
submitSearchAfterDelay(keyword: keyword, retries: 5)
}
///
/// Child View Controller
private func embedReaderController() {
let controller: UIViewController
if bookURL.pathExtension.lowercased() == "epub" {
@@ -224,7 +177,7 @@ public final class RDURLReaderController: UIViewController {
configuration: epubConfiguration
)
} else {
// 退
let fallback = UIViewController()
let textView = UITextView()
textView.isEditable = false
@@ -248,7 +201,6 @@ public final class RDURLReaderController: UIViewController {
emitDemoState()
}
/// EPUB
private var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController
}
@@ -322,7 +274,6 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
private func logDemoState(prefix: String) {
guard let readerController else { return }
let location = readerController.currentLocation
@@ -372,6 +323,9 @@ public final class RDURLReaderController: UIViewController {
let location = readerController?.currentLocation
let href = encodedDemoLocationHref(location?.href)
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let cfi = encodedDemoField(location?.cfi)
let lastCFI = encodedDemoField(location?.lastCFI)
let rangeCFI = encodedDemoField(location?.rangeCFI)
let mapSnapshot = demoPaginationSnapshot()
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
@@ -388,6 +342,9 @@ public final class RDURLReaderController: UIViewController {
"selection=\(selection)",
"href=\(href)",
"progression=\(progression)",
"cfi=\(cfi)",
"lastCFI=\(lastCFI)",
"rangeCFI=\(rangeCFI)",
"mode=\(mapSnapshot.mode)",
"pagination=\(mapSnapshot.phase)",
"knownPages=\(mapSnapshot.knownPages)",
@@ -464,14 +421,10 @@ public final class RDURLReaderController: UIViewController {
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
}
///
/// 使 viewport layoutConfig.edgeInsets
///
private func currentTextPageSize() -> CGSize {
UIScreen.main.bounds.size
}
///
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
@@ -483,8 +436,6 @@ public final class RDURLReaderController: UIViewController {
)
}
///
/// UTF-8 GBK GB2312
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
@@ -536,9 +487,8 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
}
}
/// Demo
private extension RDReaderView.DisplayType {
/// Demo
var demoArgumentValue: String {
switch self {
case .pageCurl:
@@ -1,22 +1,30 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let threadName = resolvedThreadName()
let queueLabel = resolvedQueueLabel()
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
}
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
let startedAt = CFAbsoluteTimeGetCurrent()
log(scope, "START \(message)")
do {
let result = try work()
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
return result
} catch {
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
throw error
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
}
private static func resolvedThreadName() -> String {
if let name = Thread.current.name, !name.isEmpty {
return name
}
if Thread.isMainThread {
return "main"
}
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
}

Some files were not shown because too many files have changed in this diff Show More