目前阅读稳定版本,需要修改图片阅读方式
This commit is contained in:
parent
d7339aa255
commit
3e2669d8a8
@ -8,56 +8,78 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>DTCoreText.xcscheme</key>
|
<key>DTCoreText.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>DTFoundation.xcscheme</key>
|
<key>DTFoundation.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Pods-ReadViewDemo.xcscheme</key>
|
<key>Pods-ReadViewDemo.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>RDReaderView-RDReaderViewAssets.xcscheme</key>
|
<key>RDReaderView-RDReaderViewAssets.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>5</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>RDReaderView.xcscheme</key>
|
<key>RDReaderView.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>4</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SSAlertSwift.xcscheme</key>
|
<key>SSAlertSwift.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>8</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SnapKit-SnapKit_Privacy.xcscheme</key>
|
<key>SnapKit-SnapKit_Privacy.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>7</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SnapKit.xcscheme</key>
|
<key>SnapKit.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>6</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
|
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>10</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>ZIPFoundation.xcscheme</key>
|
<key>ZIPFoundation.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>9</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|||||||
@ -185,10 +185,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
||||||
|
|||||||
Binary file not shown.
@ -77,18 +77,27 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
options: dtOptions(request: request),
|
options: dtOptions(request: request),
|
||||||
documentAttributes: nil
|
documentAttributes: nil
|
||||||
)
|
)
|
||||||
|
builder?.willFlushCallback = { element in
|
||||||
|
guard let element else { return }
|
||||||
|
RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering(
|
||||||
|
element,
|
||||||
|
style: request.style
|
||||||
|
)
|
||||||
|
}
|
||||||
return builder?.generatedAttributedString()
|
return builder?.generatedAttributedString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||||
let style = request.style
|
let style = request.style
|
||||||
|
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
||||||
var options: [AnyHashable: Any] = [
|
var options: [AnyHashable: Any] = [
|
||||||
NSTextSizeMultiplierDocumentOption: 1.0,
|
NSTextSizeMultiplierDocumentOption: 1.0,
|
||||||
DTDefaultFontFamily: style.font.familyName,
|
DTDefaultFontFamily: style.font.familyName,
|
||||||
DTDefaultFontName: style.font.fontName,
|
DTDefaultFontName: style.font.fontName,
|
||||||
DTDefaultFontSize: style.font.pointSize,
|
DTDefaultFontSize: style.font.pointSize,
|
||||||
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
||||||
DTUseiOS6Attributes: true
|
DTUseiOS6Attributes: true,
|
||||||
|
DTMaxImageSize: NSValue(cgSize: screenBounds.size)
|
||||||
]
|
]
|
||||||
|
|
||||||
if let baseURL = request.context.baseURL {
|
if let baseURL = request.context.baseURL {
|
||||||
|
|||||||
@ -159,15 +159,43 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
||||||
|
|
||||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if shouldSkipChapter(item: item, text: plainText) {
|
if item.href.lowercased().contains("cover") {
|
||||||
|
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||||
|
}
|
||||||
|
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||||
|
if item.href.lowercased().contains("cover") {
|
||||||
|
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let chapterIndex = chapters.count
|
let chapterIndex = chapters.count
|
||||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||||
let layoutFrames = content.length > 0
|
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||||
: []
|
layoutFrames = [
|
||||||
|
RDEPUBTextLayoutFrame(
|
||||||
|
contentRange: NSRange(location: 0, length: content.length),
|
||||||
|
breakReason: .chapterEnd,
|
||||||
|
blockRange: nil,
|
||||||
|
attachmentRanges: attachmentRanges(in: content),
|
||||||
|
attachmentKinds: [],
|
||||||
|
blockKinds: [],
|
||||||
|
semanticHints: [],
|
||||||
|
attachmentPlacements: [],
|
||||||
|
trailingFragmentID: nil,
|
||||||
|
diagnostics: [
|
||||||
|
"page break: chapterEnd",
|
||||||
|
"cover fallback: single attachment page",
|
||||||
|
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
layoutFrames = content.length > 0
|
||||||
|
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
||||||
|
: []
|
||||||
|
}
|
||||||
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
|
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
|
||||||
? [
|
? [
|
||||||
RDEPUBTextLayoutFrame(
|
RDEPUBTextLayoutFrame(
|
||||||
@ -187,6 +215,9 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
: layoutFrames
|
: layoutFrames
|
||||||
|
if item.href.lowercased().contains("cover") {
|
||||||
|
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||||
|
}
|
||||||
|
|
||||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||||
let range = frame.contentRange
|
let range = frame.contentRange
|
||||||
@ -258,14 +289,55 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
|
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
|
||||||
let lowercasedHref = item.href.lowercased()
|
let lowercasedHref = item.href.lowercased()
|
||||||
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
var hasAttachment = false
|
||||||
|
if content.length > 0 {
|
||||||
|
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
||||||
|
guard value != nil else { return }
|
||||||
|
hasAttachment = true
|
||||||
|
stop.pointee = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||||
|
guard content.length > 0 else { return 0 }
|
||||||
|
var count = 0
|
||||||
|
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
|
||||||
|
if value != nil {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||||
|
guard content.length > 0 else { return [] }
|
||||||
|
var ranges: [NSRange] = []
|
||||||
|
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
|
||||||
|
if value != nil {
|
||||||
|
ranges.append(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAttachmentOnlyCoverChapter(
|
||||||
|
item: RDEPUBSpineItem,
|
||||||
|
content: NSAttributedString,
|
||||||
|
plainText: String
|
||||||
|
) -> Bool {
|
||||||
|
let lowercasedHref = item.href.lowercased()
|
||||||
|
guard lowercasedHref.contains("cover") else { return false }
|
||||||
|
let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||||
|
}
|
||||||
|
|
||||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||||
values.reduce(into: [T]()) { result, value in
|
values.reduce(into: [T]()) { result, value in
|
||||||
if !result.contains(value) {
|
if !result.contains(value) {
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
#endif
|
||||||
|
|
||||||
enum RDEPUBTextRendererSupport {
|
enum RDEPUBTextRendererSupport {
|
||||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||||
|
private static var didLogFootnoteAttachment = false
|
||||||
|
private static var didLogCoverAttachment = false
|
||||||
|
|
||||||
static func injectFragmentMarkers(into html: String) -> String {
|
static func injectFragmentMarkers(into html: String) -> String {
|
||||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||||
@ -84,16 +90,18 @@ enum RDEPUBTextRendererSupport {
|
|||||||
var blockIndex = 0
|
var blockIndex = 0
|
||||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||||
let sourceFont = attributes[.font] as? UIFont
|
let sourceFont = attributes[.font] as? UIFont
|
||||||
|
let normalizedFont = normalizedFont(from: sourceFont, baseFont: style.font)
|
||||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||||
paragraph.lineSpacing = style.lineSpacing
|
paragraph.lineSpacing = style.lineSpacing
|
||||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||||
|
|
||||||
var updatedAttributes = attributes
|
var updatedAttributes = attributes
|
||||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: style.font)
|
updatedAttributes[.font] = normalizedFont
|
||||||
updatedAttributes[.paragraphStyle] = paragraph
|
updatedAttributes[.paragraphStyle] = paragraph
|
||||||
if let textColor = style.textColor {
|
if let textColor = style.textColor {
|
||||||
updatedAttributes[.foregroundColor] = textColor
|
updatedAttributes[.foregroundColor] = textColor
|
||||||
}
|
}
|
||||||
|
normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
|
||||||
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range)
|
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range)
|
||||||
updatedAttributes[.rdPageBlockIndex] = blockIndex
|
updatedAttributes[.rdPageBlockIndex] = blockIndex
|
||||||
if let attachmentKind = attachmentKind(for: attributes) {
|
if let attachmentKind = attachmentKind(for: attributes) {
|
||||||
@ -201,9 +209,59 @@ enum RDEPUBTextRendererSupport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML)
|
||||||
return cleanedHTML
|
return cleanedHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
static func prepareHTMLElementForReaderRendering(
|
||||||
|
_ element: DTHTMLElement,
|
||||||
|
style: RDEPUBTextRenderStyle
|
||||||
|
) {
|
||||||
|
guard let attachment = element.textAttachment else { return }
|
||||||
|
|
||||||
|
let lowercasedClasses = (((attachment.attributes["class"] as? String) ?? (element.attributes["class"] as? String) ?? "")).lowercased()
|
||||||
|
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||||
|
?? ((attachment.attributes["src"] as? String) ?? (element.attributes["src"] as? String) ?? "").lowercased()
|
||||||
|
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
|
||||||
|
|
||||||
|
if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
|
||||||
|
let targetHeight = max(round(pointSize * 0.54), 1)
|
||||||
|
let originalSize = attachment.originalSize
|
||||||
|
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
|
||||||
|
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
|
||||||
|
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
|
||||||
|
attachment.verticalAlignment = .center
|
||||||
|
element.displayStyle = .inline
|
||||||
|
if !didLogFootnoteAttachment {
|
||||||
|
didLogFootnoteAttachment = true
|
||||||
|
print("[EPUB][Attachment] footnote classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" {
|
||||||
|
let maxSize = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).size
|
||||||
|
let originalSize = attachment.originalSize
|
||||||
|
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 = CGSize(width: round(maxSize.width), height: round(maxSize.height))
|
||||||
|
}
|
||||||
|
attachment.verticalAlignment = .baseline
|
||||||
|
element.displayStyle = .block
|
||||||
|
if !didLogCoverAttachment {
|
||||||
|
didLogCoverAttachment = true
|
||||||
|
print("[EPUB][Attachment] cover classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private static func makeStyleSheetLayers(
|
private static func makeStyleSheetLayers(
|
||||||
style: RDEPUBTextRenderStyle,
|
style: RDEPUBTextRenderStyle,
|
||||||
epubCSS: String
|
epubCSS: String
|
||||||
@ -244,10 +302,32 @@ enum RDEPUBTextRendererSupport {
|
|||||||
private static func replaceCSS() -> String {
|
private static func replaceCSS() -> String {
|
||||||
"""
|
"""
|
||||||
img, svg, video, canvas {
|
img, svg, video, canvas {
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
.frontCover,
|
||||||
|
.bodyPic,
|
||||||
|
.qrbodyPic,
|
||||||
|
figure {
|
||||||
|
text-align: center;
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
.frontCover img,
|
||||||
|
.rd-front-cover-image,
|
||||||
|
.bodyPic img,
|
||||||
|
.qrbodyPic img,
|
||||||
|
figure img {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.qqreader-footnote,
|
||||||
|
.s-pic,
|
||||||
|
.h-pic,
|
||||||
|
.g-pic {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
page-break-after: avoid;
|
page-break-after: avoid;
|
||||||
break-after: avoid;
|
break-after: avoid;
|
||||||
@ -262,6 +342,168 @@ enum RDEPUBTextRendererSupport {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||||
|
var normalized = html
|
||||||
|
|
||||||
|
if let footnoteRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
) {
|
||||||
|
normalized = replaceMatches(
|
||||||
|
using: footnoteRegex,
|
||||||
|
in: normalized
|
||||||
|
) { tag in
|
||||||
|
mergeHTMLAttributes(
|
||||||
|
into: tag,
|
||||||
|
requiredClass: nil,
|
||||||
|
styleFragments: [
|
||||||
|
"width:1em",
|
||||||
|
"height:1em",
|
||||||
|
"vertical-align:middle",
|
||||||
|
"display:inline-block"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let coverRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
) {
|
||||||
|
normalized = replaceMatches(
|
||||||
|
using: coverRegex,
|
||||||
|
in: normalized
|
||||||
|
) { tag in
|
||||||
|
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
|
||||||
|
let imageMatch = imageTagRegex.firstMatch(
|
||||||
|
in: tag,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(location: 0, length: (tag as NSString).length)
|
||||||
|
),
|
||||||
|
let imageRange = Range(imageMatch.range, in: tag) else {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageTag = String(tag[imageRange])
|
||||||
|
let normalizedImageTag = mergeHTMLAttributes(
|
||||||
|
into: imageTag,
|
||||||
|
requiredClass: "rd-front-cover-image",
|
||||||
|
styleFragments: [
|
||||||
|
"display:block",
|
||||||
|
"width:100%",
|
||||||
|
"height:auto",
|
||||||
|
"margin-left:auto",
|
||||||
|
"margin-right:auto"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return tag.replacingCharacters(in: imageRange, with: normalizedImageTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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 }
|
||||||
|
|
||||||
|
var rewritten = source
|
||||||
|
for match in matches.reversed() {
|
||||||
|
guard let range = Range(match.range, in: rewritten) else { continue }
|
||||||
|
let original = String(rewritten[range])
|
||||||
|
rewritten.replaceSubrange(range, with: transform(original))
|
||||||
|
}
|
||||||
|
return rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func string(from size: CGSize) -> String {
|
||||||
|
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergeHTMLAttributes(
|
||||||
|
into tag: String,
|
||||||
|
requiredClass: String?,
|
||||||
|
styleFragments: [String]
|
||||||
|
) -> String {
|
||||||
|
var rewritten = tag
|
||||||
|
if let requiredClass {
|
||||||
|
if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
|
||||||
|
let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
|
||||||
|
match.numberOfRanges > 1 {
|
||||||
|
let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1))
|
||||||
|
if !existingClasses.localizedCaseInsensitiveContains(requiredClass) {
|
||||||
|
let replacement = #"class="\#(existingClasses) \#(requiredClass)""#
|
||||||
|
if let range = Range(match.range, in: rewritten) {
|
||||||
|
rewritten.replaceSubrange(range, with: replacement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let closing = rewritten.lastIndex(of: ">") {
|
||||||
|
rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let styleValue = styleFragments.joined(separator: ";") + ";"
|
||||||
|
if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
|
||||||
|
let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
|
||||||
|
match.numberOfRanges > 1 {
|
||||||
|
let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue
|
||||||
|
let replacement = #"style="\#(merged)""#
|
||||||
|
if let range = Range(match.range, in: rewritten) {
|
||||||
|
rewritten.replaceSubrange(range, with: replacement)
|
||||||
|
}
|
||||||
|
} else if let closing = rewritten.lastIndex(of: ">") {
|
||||||
|
rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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 {
|
||||||
|
let lowercasedClasses = ((textAttachment.attributes["class"] as? String) ?? "").lowercased()
|
||||||
|
let lowercasedPath = textAttachment.contentURL?.lastPathComponent.lowercased() ?? ""
|
||||||
|
if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
|
||||||
|
let targetHeight = max(round(font.pointSize * 0.14), 1)
|
||||||
|
let originalSize = textAttachment.originalSize
|
||||||
|
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
|
||||||
|
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
|
||||||
|
textAttachment.displaySize = CGSize(width: targetWidth, height: round(targetHeight))
|
||||||
|
textAttachment.verticalAlignment = .center
|
||||||
|
} else if lowercasedClasses.contains("rd-front-cover-image") {
|
||||||
|
let originalSize = textAttachment.originalSize
|
||||||
|
let maxWidth = max(UIScreen.main.bounds.width - 48, font.lineHeight * 8)
|
||||||
|
if originalSize.width > 0, originalSize.height > 0 {
|
||||||
|
let scaledHeight = round(originalSize.height * (maxWidth / originalSize.width))
|
||||||
|
textAttachment.displaySize = CGSize(width: round(maxWidth), height: scaledHeight)
|
||||||
|
} else {
|
||||||
|
textAttachment.displaySize = CGSize(width: round(maxWidth), height: round(maxWidth * 1.4))
|
||||||
|
}
|
||||||
|
textAttachment.verticalAlignment = .baseline
|
||||||
|
}
|
||||||
|
attributes[.attachment] = textAttachment
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||||
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||||
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
import DTCoreText
|
||||||
|
#endif
|
||||||
|
|
||||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||||
@ -50,6 +54,13 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private let coverImageView: UIImageView = {
|
||||||
|
let view = UIImageView()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.isHidden = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
private let pageNumberLabel: UILabel = {
|
private let pageNumberLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = UIFont.systemFont(ofSize: 13)
|
label.font = UIFont.systemFont(ofSize: 13)
|
||||||
@ -58,6 +69,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
addSubview(coverImageView)
|
||||||
addSubview(textView)
|
addSubview(textView)
|
||||||
addSubview(pageNumberLabel)
|
addSubview(pageNumberLabel)
|
||||||
textView.delegate = self
|
textView.delegate = self
|
||||||
@ -80,6 +92,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
textView.frame = bounds.inset(by: contentInsets)
|
textView.frame = bounds.inset(by: contentInsets)
|
||||||
|
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||||
|
|
||||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||||
pageNumberLabel.frame = CGRect(
|
pageNumberLabel.frame = CGRect(
|
||||||
@ -105,6 +118,17 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||||
|
|
||||||
|
if configureCoverIfNeeded(for: page) {
|
||||||
|
textView.attributedText = nil
|
||||||
|
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||||
|
delegate?.textContentView(self, didChangeSelection: nil)
|
||||||
|
setNeedsLayout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coverImageView.isHidden = true
|
||||||
|
coverImageView.image = nil
|
||||||
|
|
||||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||||
displayContent.addAttribute(
|
displayContent.addAttribute(
|
||||||
@ -112,6 +136,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
value: configuration.theme.contentTextColor,
|
value: configuration.theme.contentTextColor,
|
||||||
range: fullRange
|
range: fullRange
|
||||||
)
|
)
|
||||||
|
normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize)
|
||||||
applyHighlights(to: displayContent, page: page)
|
applyHighlights(to: displayContent, page: page)
|
||||||
applySearchHighlights(to: displayContent, page: page, searchState: searchState)
|
applySearchHighlights(to: displayContent, page: page, searchState: searchState)
|
||||||
textView.tintColor = configuration.theme.toolControlTextColor
|
textView.tintColor = configuration.theme.toolControlTextColor
|
||||||
@ -189,6 +214,73 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
return lowerBound..<max(upperBound, lowerBound)
|
return lowerBound..<max(upperBound, lowerBound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||||
|
guard page.pageIndexInChapter == 0,
|
||||||
|
page.href.lowercased().contains("cover"),
|
||||||
|
let image = coverImage(from: page.content) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
coverImageView.image = image
|
||||||
|
coverImageView.isHidden = false
|
||||||
|
textView.attributedText = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func coverImage(from content: NSAttributedString) -> UIImage? {
|
||||||
|
guard content.length > 0 else { return nil }
|
||||||
|
var resolvedImage: UIImage?
|
||||||
|
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
||||||
|
guard let image = image(from: value) else { return }
|
||||||
|
resolvedImage = image
|
||||||
|
stop.pointee = true
|
||||||
|
}
|
||||||
|
return resolvedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
private func image(from attachmentValue: Any?) -> UIImage? {
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
if let attachment = attachmentValue as? DTTextAttachment,
|
||||||
|
let url = attachment.contentURL {
|
||||||
|
return UIImage(contentsOfFile: url.path)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if let attachment = attachmentValue as? NSTextAttachment {
|
||||||
|
if let image = attachment.image {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
if let data = attachment.contents {
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
if let fileWrapper = attachment.fileWrapper,
|
||||||
|
let data = fileWrapper.regularFileContents {
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizeInlineAttachments(in content: NSMutableAttributedString, basePointSize: CGFloat) {
|
||||||
|
guard content.length > 0 else { return }
|
||||||
|
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
if let attachment = value as? DTTextAttachment {
|
||||||
|
let classes = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||||
|
let source = (attachment.contentURL?.lastPathComponent ?? (attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||||
|
guard classes.contains("qqreader-footnote") || source.contains("note.png") else { return }
|
||||||
|
|
||||||
|
let pointSize = max(basePointSize, 1)
|
||||||
|
let targetHeight = max(round(pointSize * 0.14), 1)
|
||||||
|
let aspectRatio = attachment.originalSize.height > 0 ? attachment.originalSize.width / attachment.originalSize.height : 1
|
||||||
|
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
|
||||||
|
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
|
||||||
|
attachment.verticalAlignment = .center
|
||||||
|
content.addAttribute(.attachment, value: attachment, range: range)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RDEPUBTextContentView: UITextViewDelegate {
|
extension RDEPUBTextContentView: UITextViewDelegate {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user