Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import UIKit
|
||||
|
||||
struct EPUBTOCDisplayItem {
|
||||
var title: String
|
||||
var href: String
|
||||
var depth: Int
|
||||
var pageNumber: Int?
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextPage {
|
||||
var absolutePageIndex: Int
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var chapterTitle: String
|
||||
var pageIndexInChapter: Int
|
||||
var totalPagesInChapter: Int
|
||||
var content: NSAttributedString
|
||||
var contentRange: NSRange
|
||||
var pageStartOffset: Int
|
||||
var pageEndOffset: Int
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextChapter {
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var title: String
|
||||
var attributedContent: NSAttributedString
|
||||
var fragmentOffsets: [String: Int]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
|
||||
func page(at pageNumber: Int) -> LegacyRDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class LegacyRDEPUBTextBookBuilder {
|
||||
static func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter] = []
|
||||
var flatPages: [LegacyRDEPUBTextPage] = []
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let normalizedHTML = normalizeHTML(rawHTML)
|
||||
let markedHTML = injectFragmentMarkers(into: normalizedHTML)
|
||||
guard let attributedContent = attributedHTML(
|
||||
html: markedHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let mutableContent = NSMutableAttributedString(attributedString: attributedContent)
|
||||
let fragmentOffsets = extractFragmentOffsets(from: mutableContent)
|
||||
normalizeReadingAttributes(in: mutableContent, font: font, lineSpacing: lineSpacing)
|
||||
|
||||
let plainText = mutableContent.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldSkipChapter(item: item, text: plainText) {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let pageRanges = mutableContent.length > 0 ? mutableContent.pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && mutableContent.length > 0
|
||||
? [NSRange(location: 0, length: mutableContent.length)]
|
||||
: pageRanges
|
||||
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
LegacyRDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: mutableContent.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = LegacyRDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: mutableContent.copy() as! NSAttributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
pages: pages
|
||||
)
|
||||
chapters.append(chapter)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return LegacyRDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private static 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
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
private static func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private static func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private 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")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
private static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
private static func attributedHTML(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> NSAttributedString? {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue,
|
||||
NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption"): baseURL as Any
|
||||
]
|
||||
|
||||
if let attributed = try? NSMutableAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributed
|
||||
}
|
||||
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: lineSpacing)
|
||||
]
|
||||
return NSAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
private static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, font: UIFont, lineSpacing: CGFloat) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: lineSpacing)
|
||||
paragraph.lineSpacing = lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: font)
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
|
||||
private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// RDReaderBottomToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
public class RDReaderBottomToolView: RDReaderToolView, SSEventTrigger {
|
||||
enum Event {
|
||||
case chapterList, highlight, settings, darkAndLight
|
||||
}
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
|
||||
lazy var chapterListButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_chapterlist", fallbackSystemName: "list.bullet"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(chapterListAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var settingsButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_font", fallbackSystemName: "textformat"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var highlightButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(UIImage(systemName: "highlighter"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
|
||||
lazy var lightModeButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_night", fallbackSystemName: "moon.fill"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(lightAndDarkModeAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func makeUI() {
|
||||
super.makeUI()
|
||||
addSubview(containerView)
|
||||
containerView.addArrangedSubview(chapterListButton)
|
||||
containerView.addArrangedSubview(highlightButton)
|
||||
containerView.addArrangedSubview(settingsButton)
|
||||
containerView.addArrangedSubview(lightModeButton)
|
||||
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.left.equalTo(16)
|
||||
make.top.equalTo(0)
|
||||
make.right.equalTo(-16)
|
||||
make.bottom.equalTo(-RDReaderCommon.safeAreaInsets.bottom)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@objc private func chapterListAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chapterList)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func settingsAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.settings)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func highlightAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.highlight)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func lightAndDarkModeAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.darkAndLight)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//
|
||||
// RDReaderCommon.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
struct RDReaderCommon {
|
||||
static var safeAreaInsets: UIEdgeInsets = {
|
||||
guard #available(iOS 11.0, *) else {
|
||||
return .zero
|
||||
}
|
||||
return UIApplication.shared.windows[0].safeAreaInsets
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
enum SpaceType: Int, RDReaderObserverType {
|
||||
case min = 0
|
||||
case meduim
|
||||
case max
|
||||
var mulitiple: CGFloat {
|
||||
switch self {
|
||||
case .min: return 1.6
|
||||
case .meduim: return 2.0
|
||||
case .max: return 3.0
|
||||
}
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
switch self {
|
||||
case .min:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_3", fallbackSystemName: "text.justify.leading")
|
||||
case .meduim:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_2", fallbackSystemName: "text.justify")
|
||||
case .max:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_1", fallbackSystemName: "text.justify.right")
|
||||
}
|
||||
}
|
||||
|
||||
var cachesValue: Int {
|
||||
return rawValue
|
||||
}
|
||||
var value: CGFloat {
|
||||
return mulitiple
|
||||
}
|
||||
func transform(value: Any?) -> RDReaderCommon.SpaceType {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.SpaceType(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
enum PageCurlType: Int, RDReaderObserverType {
|
||||
case pageCurl
|
||||
case horizontalScroll
|
||||
case verticalScroll
|
||||
case horizontalCoverScroll
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return "仿真"
|
||||
case .horizontalScroll:
|
||||
return "左右滑动"
|
||||
case .verticalScroll:
|
||||
return "上下滚动"
|
||||
case .horizontalCoverScroll:
|
||||
return "左右覆盖"
|
||||
}
|
||||
}
|
||||
|
||||
var cachesValue: Int {
|
||||
return rawValue
|
||||
}
|
||||
var value: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return RDReaderView.DisplayType.pageCurl
|
||||
case .horizontalScroll:
|
||||
return RDReaderView.DisplayType.horizontalScroll
|
||||
case .verticalScroll:
|
||||
return RDReaderView.DisplayType.verticalScroll
|
||||
case .horizontalCoverScroll:
|
||||
return RDReaderView.DisplayType.horizontalScroll
|
||||
}
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> RDReaderCommon.PageCurlType {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.PageCurlType(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
|
||||
enum Colors: Int, RDReaderObserverType{
|
||||
|
||||
case white, dark, pink, yellow, green, blue
|
||||
var value: RDReaderTheme {
|
||||
switch self {
|
||||
case .white:
|
||||
return whiteTheme
|
||||
case .dark:
|
||||
return darkTheme
|
||||
case .pink:
|
||||
return pinkTheme
|
||||
case .yellow:
|
||||
return yellowTheme
|
||||
case .green:
|
||||
return greenTheme
|
||||
case .blue:
|
||||
return blueTheme
|
||||
}
|
||||
}
|
||||
var cachesValue: Int {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .white:
|
||||
return UIColor(red: 0.98, green: 0.98, blue: 0.99, alpha: 1)
|
||||
case .yellow:
|
||||
return UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
case .green:
|
||||
return UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
case .pink:
|
||||
return UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
case .blue:
|
||||
return UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
case .dark:
|
||||
return UIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> RDReaderCommon.Colors {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.Colors(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
static let whiteTheme = WhiteTheme()
|
||||
static let darkTheme = DarkTheme()
|
||||
static let pinkTheme = PinkTheme()
|
||||
static let yellowTheme = YellowTheme()
|
||||
static let greenTheme = GreenTheme()
|
||||
static let blueTheme = BlueTheme()
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension NSAttributedString {
|
||||
func pageRanges(size: CGSize) -> [NSRange] {
|
||||
var ranges = [NSRange]()
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(self)
|
||||
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
|
||||
var range = CFRangeMake(0, 0)
|
||||
var loc = 0
|
||||
while range.location + range.length < length {
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(loc, 0), path, nil)
|
||||
range = CTFrameGetVisibleStringRange(frame)
|
||||
ranges.append(NSMakeRange(loc, range.length))
|
||||
loc += range.length
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension String {
|
||||
static func encodeTextFile(url: URL?) -> String {
|
||||
var content: String? = nil
|
||||
guard let url = url else {
|
||||
return content ?? ""
|
||||
}
|
||||
content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String
|
||||
if content == nil {
|
||||
content = try? NSString(contentsOf: url, encoding: 0x80000632) as String
|
||||
}
|
||||
if content == nil {
|
||||
content = try? NSString(contentsOf: url, encoding: 0x80000631) as String
|
||||
}
|
||||
return content ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// RDReaderContentView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderContentView: UIView {
|
||||
lazy var textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textLabel)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
textLabel.text = """
|
||||
孩子为了不进职校,压力之大,我甚至听说,有初三学生晚上八点睡觉,凌晨两点起床读书做作业,忙到天亮,吃好早饭赶往学校。
|
||||
|
||||
平心静气地讲,一个孩子如果不是读书的料,完全可以去职校学一门手艺。但家长却有三重焦虑:
|
||||
|
||||
第一,一个普通高校的学生可以轻松去读职校的课程,但职校学生要去读普通高校的课程,难上加难。同一年级的各类学校和专业,在学习上是有难易梯度的。家长多不会同意让孩子一开始就选择容易的学校和专业,否则在未来竞争中,将处于不利地位。
|
||||
|
||||
第二,初中毕业生社会经验匮乏,实际上根本没有能力做人生规划。选择职校学一门技术,同时也就意味着,将来从事其他工作的门槛是很高的。如果没有继续学习的能力,改行的成本之高,难以想象。
|
||||
|
||||
以我的个人经验来讲,初中阶段,一度想去学习屠宰的手艺,毕业时也有上建筑类职高的机会,但都没去,在普通高中混到高三才决定考大学,如果去上职高,很可能就是个小包工头。我不知道这是不是我的真实意愿,但我觉得当下的工作更符合秉性。
|
||||
|
||||
第三,中国的一些职校,风评并不十分好,家长并不十分放心把十五六岁的孩子送入这些学校,他们不担心孩子学艺不成,而是担心孩子“学坏了”。
|
||||
|
||||
并且,随着科技加速进步,很多好端端的传统职业,忽然消失了。以汽修为例,现在的汽修专业毕业生,谁能保证他的精湛技术在10年之后不会归零?一些新职业出现没几年又消失了,谁能保证中高职教育能够跟上科技潮流?
|
||||
"""
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(-20)
|
||||
make.right.equalTo(-30)
|
||||
}
|
||||
}
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
final class RDReaderEPUBTextContentView: UIView {
|
||||
private(set) lazy var textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
|
||||
private(set) lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textLabel)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(-20)
|
||||
make.right.equalTo(-30)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(page: LegacyRDEPUBTextPage, pageNumber: Int, totalPages: Int, theme: RDReaderTheme) {
|
||||
backgroundColor = theme.contentBackgroudColor
|
||||
pageNumLabel.textColor = theme.contentTextColor
|
||||
pageNumLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(.foregroundColor, value: theme.contentTextColor ?? UIColor.black, range: fullRange)
|
||||
textLabel.attributedText = displayContent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import UIKit
|
||||
|
||||
extension RDReaderController {
|
||||
func currentEPUBTextPageSize() -> CGSize {
|
||||
let pageWidth: CGFloat
|
||||
if readerView.pagesPerScreen > 1 {
|
||||
pageWidth = view.frame.width / CGFloat(readerView.pagesPerScreen) - 16 * 2
|
||||
} else {
|
||||
pageWidth = view.frame.width - 16 * 2
|
||||
}
|
||||
return CGSize(width: max(pageWidth, 1), height: max(view.frame.height - 40 * 2, 1))
|
||||
}
|
||||
|
||||
func loadEPUBTextBook(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
let pageSize = currentEPUBTextPageSize()
|
||||
let font = UIFont.systemFont(ofSize: RDReaderManager.shared.fontValue.currentType)
|
||||
let lineSpacing = RDReaderManager.shared.lineSpaceType.currentType.mulitiple * 15
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
let textBook = LegacyRDEPUBTextBookBuilder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyEPUBTextBook(textBook, publication: publication, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyEPUBTextBook(
|
||||
_ textBook: LegacyRDEPUBTextBook,
|
||||
publication: RDEPUBPublication,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
epubTextBook = textBook
|
||||
epubTOCItems = flattenedTOCItems(from: publication.tableOfContents, textBook: textBook)
|
||||
print("[EPUB] Text book ready chapters=\(textBook.chapters.count) pages=\(textBook.pages.count) toc=\(epubTOCItems.count)")
|
||||
let resolvedRestorePage = restoreLocation.flatMap {
|
||||
textBook.pageNumber(
|
||||
for: $0,
|
||||
resolver: epubResolver ?? publicationFallbackResolver(),
|
||||
bookIdentifier: currentEPUBBookIdentifier
|
||||
)
|
||||
}
|
||||
storeEPUBPaginationValidation(
|
||||
mode: "textReflowable",
|
||||
stage: "final",
|
||||
pageCount: textBook.pages.count,
|
||||
chapterCount: textBook.chapters.count,
|
||||
tocCount: epubTOCItems.count,
|
||||
restoreLocation: restoreLocation,
|
||||
resolvedRestorePage: resolvedRestorePage
|
||||
)
|
||||
isReaderContentReady = true
|
||||
hideLoading()
|
||||
readerView.reloadData()
|
||||
restoreEPUBLocation(restoreLocation)
|
||||
if restoreLocation == nil {
|
||||
epubSession?.transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
func flattenedTOCItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0,
|
||||
textBook: LegacyRDEPUBTextBook? = nil
|
||||
) -> [EPUBTOCDisplayItem] {
|
||||
var result: [EPUBTOCDisplayItem] = []
|
||||
for item in items {
|
||||
let pageNumber = textBook.flatMap { book in
|
||||
book.pageNumber(
|
||||
for: RDEPUBLocation(bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0),
|
||||
resolver: epubResolver ?? publicationFallbackResolver(),
|
||||
bookIdentifier: currentEPUBBookIdentifier
|
||||
)
|
||||
}
|
||||
result.append(EPUBTOCDisplayItem(title: item.title, href: item.href, depth: depth, pageNumber: pageNumber))
|
||||
result.append(contentsOf: flattenedTOCItems(from: item.children, depth: depth + 1, textBook: textBook))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func publicationFallbackResolver() -> RDEPUBResourceResolver {
|
||||
epubPublication?.resourceResolver ?? RDEPUBResourceResolver(parser: epubParser ?? RDEPUBParser())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// RDReaderCoverView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderCoverView: UIView {
|
||||
lazy var textLabel: UILabel = {
|
||||
let textLabel = UILabel()
|
||||
textLabel.numberOfLines = 0
|
||||
return textLabel
|
||||
}()
|
||||
|
||||
lazy var imageV: UIImageView = {
|
||||
let imageV = UIImageView()
|
||||
imageV.image = UIImage(named: "cover")
|
||||
return imageV
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(imageV)
|
||||
addSubview(textLabel)
|
||||
|
||||
imageV.snp.makeConstraints { make in
|
||||
make.top.equalTo(60)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 88 * 1.5, height: 120 * 1.5))
|
||||
}
|
||||
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.top.equalTo(imageV.snp.bottom).offset(20)
|
||||
make.bottom.equalTo(-30)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
protocol RDReaderEPUBContentViewDelegate: AnyObject {
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateExternalLink url: URL)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
final class RDReaderEPUBContentView: UIView {
|
||||
weak var delegate: RDReaderEPUBContentViewDelegate?
|
||||
|
||||
lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
private let epubWebView = RDEPUBWebView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.masksToBounds = true
|
||||
addSubview(epubWebView)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
epubWebView.delegate = self
|
||||
epubWebView.clipsToBounds = true
|
||||
epubWebView.layer.masksToBounds = true
|
||||
|
||||
epubWebView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.right.equalToSuperview().offset(-30)
|
||||
make.bottom.equalToSuperview().offset(-20)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
epubWebView.load(publication: publication, request: request)
|
||||
}
|
||||
|
||||
func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
pageIndex: Int,
|
||||
totalPagesInChapter: Int,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String?,
|
||||
themeTextColor: String?,
|
||||
targetLocation: RDEPUBLocation?,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
epubWebView.loadPage(
|
||||
parser: parser,
|
||||
spineIndex: spineIndex,
|
||||
pageIndex: pageIndex,
|
||||
totalPagesInChapter: totalPagesInChapter,
|
||||
viewportSize: viewportSize,
|
||||
padding: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor,
|
||||
targetLocation: targetLocation,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
viewportSize: CGSize,
|
||||
contentInset: UIEdgeInsets,
|
||||
backgroundColor: UIColor?
|
||||
) {
|
||||
epubWebView.loadFixedSpread(
|
||||
parser: parser,
|
||||
spread: spread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: contentInset,
|
||||
backgroundColor: backgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
epubWebView.reset()
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderEPUBContentView: RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
delegate?.epubContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
delegate?.epubContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
delegate?.epubContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubContentView(self, didActivateExternalLink: url)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
||||
delegate?.epubContentView(self, didLogJavaScriptError: message)
|
||||
}
|
||||
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//
|
||||
// RDReaderEditView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/19.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 亮度
|
||||
class RDReaderBrightSlider: UISlider, SSEventTrigger {
|
||||
enum Event {
|
||||
case valueDidChange(value: CGFloat)
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
override func setValue(_ value: Float, animated: Bool) {
|
||||
super.setValue(value, animated: animated)
|
||||
UIScreen.main.brightness = CGFloat(value)
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.valueDidChange(value: CGFloat(value)))
|
||||
}
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
let color = UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1)
|
||||
thumbTintColor = color
|
||||
let image = UIImage.rdToolbarImage(named: "read_edit_slide", fallbackSystemName: "circle.fill")
|
||||
setThumbImage(image, for: .normal)
|
||||
setThumbImage(image, for: .highlighted)
|
||||
minimumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_min", fallbackSystemName: "sun.min.fill")
|
||||
maximumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_max", fallbackSystemName: "sun.max.fill")
|
||||
}
|
||||
|
||||
override func trackRect(forBounds bounds: CGRect) -> CGRect {
|
||||
self.layer.cornerRadius = 2.5 / 2
|
||||
return CGRect(x: 30, y: (bounds.height - 2.5)/2, width: bounds.width - 30 * 2, height: 2.5)
|
||||
}
|
||||
|
||||
override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
|
||||
// print(value)
|
||||
return CGRect(x: 30 + (bounds.width - 30 * 2 - 16) * CGFloat(value), y: (bounds.height - 16)/2, width: 16, height: 16)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 字号
|
||||
class RDReaderFontView: UIView, SSEventTrigger {
|
||||
enum Event {
|
||||
case fontSize(size: CGFloat)
|
||||
}
|
||||
var maxFontSize: CGFloat = 30
|
||||
var minFontSize: CGFloat = 15
|
||||
var currentFontSize: CGFloat = 15 {
|
||||
didSet {
|
||||
fontLabel.text = "\(Int(currentFontSize))"
|
||||
if currentFontSize != oldValue {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.fontSize(size: currentFontSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lazy var reduceButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_reduce", fallbackSystemName: "textformat.size.smaller"), for: .normal)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var increaseButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_increase", fallbackSystemName: "textformat.size.larger"), for: .normal)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var fontLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 17)
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = 0
|
||||
return stackView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(containerView)
|
||||
containerView.addArrangedSubview(reduceButton)
|
||||
containerView.addArrangedSubview(fontLabel)
|
||||
containerView.addArrangedSubview(increaseButton)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
reduceButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(increaseButton.snp.width).priority(.high)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
increaseButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
fontLabel.snp.makeConstraints { make in
|
||||
make.width.equalTo(50)
|
||||
}
|
||||
|
||||
reduceButton.addTarget(self, action: #selector(reduceAction(button:)), for: .touchUpInside)
|
||||
increaseButton.addTarget(self, action: #selector(increaseAction(button:)), for: .touchUpInside)
|
||||
fontLabel.text = "\(Int(currentFontSize))"
|
||||
}
|
||||
|
||||
@objc func reduceAction(button: UIButton) {
|
||||
if currentFontSize == minFontSize {
|
||||
return
|
||||
}
|
||||
currentFontSize -= 1
|
||||
}
|
||||
|
||||
@objc func increaseAction(button: UIButton) {
|
||||
if currentFontSize == maxFontSize {
|
||||
return
|
||||
}
|
||||
currentFontSize += 1
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 行距
|
||||
class RDReaderLineSpaceView: UIView, SSEventTrigger {
|
||||
|
||||
enum Event {
|
||||
case chose(type: RDReaderCommon.SpaceType)
|
||||
}
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = 10
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.SpaceType] = [.min, .meduim, .max]
|
||||
var currentType: RDReaderCommon.SpaceType = .min {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(containerView)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
button.setImage(type.image?.withRenderingMode(.alwaysOriginal), for: .normal)
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .min {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderColor = UIColor.black.cgColor
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(type: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页方式
|
||||
class RDReaderPageCurlTypeView: UIView, SSEventTrigger {
|
||||
|
||||
enum Event {
|
||||
case chose(type: RDReaderCommon.PageCurlType)
|
||||
}
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .equalSpacing
|
||||
stackView.spacing = 10
|
||||
return stackView
|
||||
}()
|
||||
|
||||
var currentType: RDReaderCommon.PageCurlType = .pageCurl {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.PageCurlType] = [.pageCurl, .horizontalScroll, .verticalScroll, .horizontalCoverScroll]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(containerView)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.layer.borderWidth = 1
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
|
||||
button.setTitleColor(UIColor.black, for: .normal)
|
||||
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
button.setTitle(type.title, for: .normal)
|
||||
let size = button.titleLabel!.sizeThatFits(CGSize(width: CGFloat(MAXFLOAT), height: 37))
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
make.width.equalTo(size.width + 30)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .pageCurl {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderColor = UIColor.black.cgColor
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(type: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题颜色
|
||||
class RDReaderThemeColorsView: UIView, SSEventTrigger {
|
||||
enum Event {
|
||||
case chose(color: RDReaderCommon.Colors)
|
||||
}
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .equalSpacing
|
||||
stackView.spacing = 30
|
||||
return stackView
|
||||
}()
|
||||
|
||||
var currentType: RDReaderCommon.Colors = .white {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.Colors] = [.white, .yellow, .green, .pink, .blue, .dark]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(containerView)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
|
||||
button.layer.borderColor = UIColor.black.cgColor
|
||||
button.backgroundColor = type.color
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.width.equalTo(37)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .white {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderWidth = 1
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(color: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderWidth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let space = (frame.width - CGFloat(allTypes.count) * 37) / CGFloat(allTypes.count - 1)
|
||||
containerView.spacing = space
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 编辑弹窗
|
||||
class RDReaderEditView: UIView {
|
||||
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.lightGray
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var brightSlider: RDReaderBrightSlider = {
|
||||
let slider = RDReaderBrightSlider()
|
||||
return slider
|
||||
}()
|
||||
|
||||
lazy var fontView: RDReaderFontView = {
|
||||
let view = RDReaderFontView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var lineSpaceView: RDReaderLineSpaceView = {
|
||||
let view = RDReaderLineSpaceView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var pageCurlTypeView: RDReaderPageCurlTypeView = {
|
||||
let view = RDReaderPageCurlTypeView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var colorView: RDReaderThemeColorsView = {
|
||||
let view = RDReaderThemeColorsView()
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
backgroundColor = .white
|
||||
addSubview(lineView)
|
||||
addSubview(brightSlider)
|
||||
addSubview(fontView)
|
||||
addSubview(lineSpaceView)
|
||||
addSubview(pageCurlTypeView)
|
||||
addSubview(colorView)
|
||||
|
||||
lineView.snp.makeConstraints { make in
|
||||
make.left.right.top.equalTo(0)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
brightSlider.snp.makeConstraints { make in
|
||||
make.top.equalTo(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(20)
|
||||
}
|
||||
|
||||
fontView.snp.makeConstraints { make in
|
||||
make.top.equalTo(brightSlider.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
lineSpaceView.snp.makeConstraints { make in
|
||||
make.top.equalTo(fontView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
pageCurlTypeView.snp.makeConstraints { make in
|
||||
make.top.equalTo(lineSpaceView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
colorView.snp.makeConstraints { make in
|
||||
make.top.equalTo(pageCurlTypeView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// RDReaderEndView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderEndView: UIView {
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
let textLabel = UILabel()
|
||||
textLabel.text = "结束页"
|
||||
textLabel.backgroundColor = .red
|
||||
textLabel.textAlignment = .center
|
||||
addSubview(textLabel)
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 100, height: 100))
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//
|
||||
// RDReaderManager.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
struct Book {
|
||||
var desc: String?
|
||||
var chapters: [Chapter]
|
||||
}
|
||||
|
||||
struct Chapter {
|
||||
/// 第几章
|
||||
var sort: Int
|
||||
/// 章节名称
|
||||
var title: String?
|
||||
/// 划分了多少页
|
||||
var pageCount: Int
|
||||
/// 内容
|
||||
var content: String
|
||||
/// 分页
|
||||
var pages: [ChapterPage]
|
||||
|
||||
/// 文字的长度
|
||||
var range: NSRange
|
||||
}
|
||||
|
||||
struct ChapterPage {
|
||||
/// 第几章
|
||||
var chapterSort: Int
|
||||
/// 第几页
|
||||
var pageNum: Int
|
||||
/// 内容
|
||||
var content: String
|
||||
/// 内容
|
||||
var attrContent: NSAttributedString
|
||||
/// 文字的长度
|
||||
var range: NSRange
|
||||
}
|
||||
|
||||
public struct DemoBookItem {
|
||||
public var title: String
|
||||
public var source: BookSource
|
||||
|
||||
public init(title: String, source: BookSource) {
|
||||
self.title = title
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension RDReaderManager {
|
||||
static let themeTypeKey = "themeType.key"
|
||||
static let brightValueKey = "brightValue.key"
|
||||
static let fontValueKey = "fontValue.key"
|
||||
static let lineSpaceKey = "lineSpace.key"
|
||||
static let pageCurlTypeKey = "pageCurlType.key"
|
||||
static let readProgressKey = "readProgress.key"
|
||||
}
|
||||
|
||||
typealias BrightValue = CGFloat
|
||||
extension BrightValue: RDReaderObserverType {
|
||||
var cachesValue: CGFloat {
|
||||
return self
|
||||
}
|
||||
var value: CGFloat {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> BrightValue {
|
||||
return (value as? BrightValue) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension Int: RDReaderObserverType {
|
||||
var cachesValue: Int {
|
||||
return self
|
||||
}
|
||||
var value: Int {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> Int {
|
||||
return (value as? Int) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension String: RDReaderObserverType {
|
||||
var cachesValue: String {
|
||||
return self
|
||||
}
|
||||
|
||||
var value: String {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> String {
|
||||
return (value as? String) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
public class RDReaderManager: NSObject {
|
||||
public static let shared = RDReaderManager()
|
||||
let themeType = RDReaderObserver(key: RDReaderManager.themeTypeKey, defaultType: RDReaderCommon.Colors.white)
|
||||
let brightValue = RDReaderObserver(key: RDReaderManager.brightValueKey, defaultType: UIScreen.main.brightness)
|
||||
let fontValue = RDReaderObserver(key: RDReaderManager.fontValueKey, defaultType: CGFloat(15.0))
|
||||
let lineSpaceType = RDReaderObserver(key: RDReaderManager.lineSpaceKey, defaultType: RDReaderCommon.SpaceType.min)
|
||||
let pageCurlTypeType = RDReaderObserver(key: RDReaderManager.pageCurlTypeKey, defaultType: RDReaderCommon.PageCurlType.pageCurl)
|
||||
let readProgress = RDReaderObserver(key: RDReaderManager.readProgressKey, defaultType: 0)
|
||||
let epubReadLocation = RDReaderObserver(key: "epubReadLocation.key", defaultType: "")
|
||||
let epubSelection = RDReaderObserver(key: "epubSelection.key", defaultType: "")
|
||||
let epubHighlights = RDReaderObserver(key: "epubHighlights.key", defaultType: "")
|
||||
|
||||
}
|
||||
|
||||
protocol RDReaderObserverType {
|
||||
associatedtype Value
|
||||
associatedtype CacheValue
|
||||
associatedtype CurrentType
|
||||
var cachesValue: CacheValue { get }
|
||||
var value: Value { get }
|
||||
func transform(value: Any?) -> CurrentType
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RDReaderObserver<CurrentType> where CurrentType: RDReaderObserverType {
|
||||
private var _currentType: CurrentType? = nil
|
||||
private var key: String
|
||||
private var defaultType: CurrentType
|
||||
typealias Observer = ((CurrentType.Value) -> Void)
|
||||
private var observers = [WeakObserver<Observer>]()
|
||||
var currentType: CurrentType {
|
||||
get {
|
||||
if _currentType != nil {
|
||||
return _currentType!
|
||||
} else {
|
||||
_currentType = (self.defaultType.transform(value: UserDefaults.standard.object(forKey: key)) as? CurrentType) ?? self.defaultType
|
||||
return _currentType!
|
||||
}
|
||||
}
|
||||
set {
|
||||
_currentType = newValue
|
||||
UserDefaults.standard.set(newValue.cachesValue, forKey: key)
|
||||
UserDefaults.standard.synchronize()
|
||||
observers.forEach { observer in
|
||||
if let observer = observer.observer?.observer {
|
||||
observer(_currentType!.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init(key: String, defaultType: CurrentType) {
|
||||
self.key = key
|
||||
self.defaultType = defaultType
|
||||
}
|
||||
|
||||
func observeChange(onNext: Observer?) {
|
||||
if let onNext = onNext {
|
||||
onNext(currentType.value)
|
||||
observers.append(WeakObserver(observer: onNext))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class WeakObserver<T> {
|
||||
class _Observer<T>: NSObject {
|
||||
var observer: T
|
||||
init(observer: T) {
|
||||
self.observer = observer
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
weak var observer: _Observer<T>?
|
||||
private var _strongObserver: _Observer<T>?
|
||||
init(observer: T) {
|
||||
let t = _Observer(observer: observer)
|
||||
_strongObserver = t
|
||||
self.observer = _strongObserver
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension RDReaderManager {
|
||||
func epubReaderConfiguration() -> RDEPUBReaderConfiguration {
|
||||
let theme = themeType.currentType.value
|
||||
return RDEPUBReaderConfiguration(
|
||||
fontSize: fontValue.currentType,
|
||||
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
|
||||
displayType: pageCurlTypeType.currentType.value,
|
||||
landscapeDualPageEnabled: true,
|
||||
reflowableContentInsets: UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
||||
fixedContentInset: .zero,
|
||||
theme: RDEPUBReaderTheme(
|
||||
contentBackgroundColor: theme.contentBackgroudColor ?? .white,
|
||||
contentTextColor: theme.contentTextColor ?? .black,
|
||||
toolBackgroundColor: theme.toolBackgroudColor ?? .white,
|
||||
toolControlTextColor: theme.toolControlTextColor ?? .black,
|
||||
toolControlBorderUnselectColor: theme.toolControlBorderUnSelectColor ?? UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: theme.toolLineColor ?? UIColor.lightGray.withAlphaComponent(0.5)
|
||||
),
|
||||
fixedLayoutFit: .page,
|
||||
fixedLayoutSpreadMode: .automatic,
|
||||
textRenderingEngine: .dtCoreText
|
||||
)
|
||||
}
|
||||
|
||||
func epubPreferences(
|
||||
theme: RDReaderTheme? = nil,
|
||||
reflowableInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets
|
||||
) -> RDEPUBPreferences {
|
||||
let resolvedTheme = theme ?? themeType.currentType.value
|
||||
let backgroundColorCSS = resolvedTheme.contentBackgroudColor?.ss_cssString
|
||||
return RDEPUBPreferences(
|
||||
fontSize: fontValue.currentType,
|
||||
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
|
||||
reflowableContentInsets: reflowableInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
themeBackgroundColor: backgroundColorCSS,
|
||||
themeTextColor: resolvedTheme.contentTextColor?.ss_cssString,
|
||||
fixedBackgroundColor: backgroundColorCSS,
|
||||
fixedLayoutFit: .page,
|
||||
fixedLayoutSpreadMode: .automatic
|
||||
)
|
||||
}
|
||||
|
||||
func bundledEPUBURLs(in bundle: Bundle = .main) -> [URL] {
|
||||
(bundle.urls(forResourcesWithExtension: "epub", subdirectory: nil) ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
lhs.deletingPathExtension().lastPathComponent.localizedStandardCompare(rhs.deletingPathExtension().lastPathComponent) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
public func demoBookItems(in bundle: Bundle = .main) -> [DemoBookItem] {
|
||||
let textDemo = DemoBookItem(title: "宠她.txt", source: .textFile)
|
||||
let epubItems = bundledEPUBURLs(in: bundle).map { url in
|
||||
DemoBookItem(title: url.deletingPathExtension().lastPathComponent, source: .epubFile(url: url))
|
||||
}
|
||||
return [textDemo] + epubItems
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func startReading(book item: DemoBookItem, from navigationController: UINavigationController?, animated: Bool = true) -> UIViewController? {
|
||||
let readerController: UIViewController
|
||||
switch item.source {
|
||||
case .textFile:
|
||||
guard let textURL = Bundle.main.url(forResource: "宠她", withExtension: "txt") else {
|
||||
return nil
|
||||
}
|
||||
readerController = RDURLReaderController(bookURL: textURL)
|
||||
case .epubFile(let url):
|
||||
readerController = RDURLReaderController(
|
||||
bookURL: url,
|
||||
epubConfiguration: epubReaderConfiguration()
|
||||
)
|
||||
}
|
||||
readerController.title = item.title
|
||||
navigationController?.pushViewController(readerController, animated: animated)
|
||||
return readerController
|
||||
}
|
||||
|
||||
func encodeTextFile(font: UIFont, lineSapce: CGFloat, size: CGSize) -> Book {
|
||||
let decodeString = String.encodeTextFile(url: URL(fileURLWithPath: Bundle.main.path(forResource: "宠她", ofType: ".txt")!))
|
||||
let parten = "第[0-9一二三四五六七八九十百千]*[章回].*"
|
||||
var chapters = [Chapter]()
|
||||
var desc: String?
|
||||
if let expression = try? NSRegularExpression(pattern: parten, options: .caseInsensitive) {
|
||||
let results = expression.matches(in: decodeString, options: .reportCompletion, range: NSMakeRange(0, decodeString.count))
|
||||
var startCount = -1
|
||||
var lastTitle: String?
|
||||
for result in results {
|
||||
let index = results.firstIndex(of: result)
|
||||
let range = result.range
|
||||
|
||||
if startCount != -1 && index! > 0 {
|
||||
let contentRange = NSMakeRange(startCount, range.location - startCount)
|
||||
|
||||
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
|
||||
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
|
||||
let chapter = Chapter(sort: index!,title: lastTitle, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
|
||||
chapters.append(chapter)
|
||||
if index == results.count - 1 {
|
||||
let contentRange = NSMakeRange(range.location, decodeString.count - range.location)
|
||||
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
|
||||
let title = decodeString.substring(with: Range(range, in: decodeString)!)
|
||||
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
|
||||
let chapter = Chapter(sort: index! + 1, title: title, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
|
||||
chapters.append(chapter)
|
||||
}
|
||||
}
|
||||
startCount = range.location
|
||||
lastTitle = decodeString.substring(with: Range(range, in: decodeString)!)
|
||||
if index == 0 && range.location > 0 {
|
||||
desc = decodeString.substring(with: Range(NSMakeRange(0, range.location), in: decodeString)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Book(desc: desc, chapters: chapters)
|
||||
}
|
||||
|
||||
func chapterPages(content: String, font: UIFont, lineSapce: CGFloat, size: CGSize, chapterSort: Int) -> [ChapterPage] {
|
||||
let attrString = NSMutableAttributedString(string: content)
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSapce
|
||||
attrString.addAttributes([NSAttributedString.Key.font : font, NSAttributedString.Key.paragraphStyle: style], range: NSMakeRange(0, content.count))
|
||||
let pageRanges = attrString.pageRanges(size: size)
|
||||
let pages = pageRanges.map({
|
||||
pageRange -> ChapterPage in
|
||||
let pageContent = content.substring(with: Range(pageRange, in: content)!)
|
||||
let pageAttrContent = attrString.attributedSubstring(from: pageRange)
|
||||
return ChapterPage(chapterSort: chapterSort, pageNum: pageRanges.firstIndex(of: pageRange)!, content: pageContent, attrContent: pageAttrContent, range: pageRange)
|
||||
})
|
||||
|
||||
return pages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// RDReaderTheme.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
|
||||
public protocol RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? { get }
|
||||
var contentTextColor: UIColor? { get }
|
||||
var toolBackgroudColor: UIColor? { get }
|
||||
var toolControlTextColor: UIColor? { get }
|
||||
var toolControlBorderUnSelectColor: UIColor? { get }
|
||||
var toolLineColor: UIColor? { get }
|
||||
}
|
||||
|
||||
struct WhiteTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor.white
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor.white
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct DarkTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor.black
|
||||
var contentTextColor: UIColor? = UIColor.white
|
||||
var toolBackgroudColor: UIColor? = UIColor.black
|
||||
var toolControlTextColor: UIColor? = UIColor.white
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct YellowTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
|
||||
struct GreenTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct PinkTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
|
||||
struct BlueTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// RDReaderToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class RDReaderToolView: UIView {
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
var lineHeight: CGFloat = 0.5
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
|
||||
func makeUI() {
|
||||
addSubview(lineView)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = CGRect(x: 0, y: 0, width: frame.width, height: lineHeight)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public class TintColorButton: UIButton {
|
||||
public override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
if let image = self.currentImage {
|
||||
let image = image.tintColorImage(color: tintColor)
|
||||
setImage(image, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension UIImage {
|
||||
static func rdToolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
|
||||
func tintColorImage(color: UIColor) -> UIImage? {
|
||||
UIGraphicsBeginImageContextWithOptions(size, false, scale)
|
||||
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||
color.set()
|
||||
UIRectFill(rect)
|
||||
draw(at: .zero, blendMode: .destinationIn, alpha: 1)
|
||||
let newImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return newImage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// RDReaderTopToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
|
||||
|
||||
public class RDReaderTopToolView: RDReaderToolView,SSEventTrigger {
|
||||
|
||||
|
||||
enum Event {
|
||||
case back
|
||||
}
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = CGRect(x: 0, y: frame.height - lineHeight, width: frame.width, height: lineHeight)
|
||||
}
|
||||
|
||||
lazy var backButton: TintColorButton = {
|
||||
let backButton = TintColorButton(type: .system)
|
||||
backButton.setImage(toolbarImage(named: "arrow_left", fallbackSystemName: "chevron.left"), for: .normal)
|
||||
return backButton
|
||||
}()
|
||||
|
||||
override func makeUI() {
|
||||
super.makeUI()
|
||||
addSubview(backButton)
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.left.equalTo(0)
|
||||
make.bottom.equalTo(0)
|
||||
make.height.equalTo(44)
|
||||
make.width.equalTo(50)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.back)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// SSChapterListController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/19.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
class SSChapterListController: UITableViewController {
|
||||
var selectedChapter: ((Int) -> Void)?
|
||||
var chapters: [Chapter]
|
||||
var currentChapterNum: Int
|
||||
init(chapters:[Chapter], currentChapterNum: Int) {
|
||||
self.chapters = chapters
|
||||
self.currentChapterNum = currentChapterNum
|
||||
super.init(style: .plain)
|
||||
|
||||
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 0))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return self.chapters.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell")
|
||||
cell?.textLabel?.text = self.chapters[indexPath.row].title
|
||||
cell?.textLabel?.textColor = indexPath.row == currentChapterNum ? UIColor.red : UIColor.black
|
||||
return cell!
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if let selectedChapter = selectedChapter {
|
||||
selectedChapter(indexPath.row)
|
||||
}
|
||||
}
|
||||
|
||||
func selectedChapter(onTrigger: @escaping (Int) -> Void) {
|
||||
self.selectedChapter = onTrigger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// EventTrigger.swift
|
||||
//
|
||||
//
|
||||
// Created by yangsq on 2020/11/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol SSEventTrigger {
|
||||
associatedtype Event
|
||||
typealias TriggerEvent = (Event) -> Void
|
||||
var triggerEvent: TriggerEvent? { get set }
|
||||
func trigger(event: TriggerEvent?)
|
||||
}
|
||||
|
||||
private var triggerEventKey: UInt8 = 0
|
||||
extension SSEventTrigger {
|
||||
private var _triggerEvent:TriggerEvent? {
|
||||
get {return objc_getAssociatedObject(self, &triggerEventKey) as? Self.TriggerEvent}
|
||||
set {objc_setAssociatedObject(self, &triggerEventKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)}
|
||||
}
|
||||
|
||||
var triggerEvent: TriggerEvent? {
|
||||
get{_triggerEvent}
|
||||
set{
|
||||
if newValue != nil {
|
||||
_triggerEvent = newValue!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trigger(event: TriggerEvent?) {
|
||||
if event != nil {
|
||||
objc_setAssociatedObject(self, &triggerEventKey, event!, .OBJC_ASSOCIATION_COPY_NONATOMIC)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user