feat(epub): align wxread css and overlay pagination behavior

This commit is contained in:
shen
2026-05-25 22:15:24 +08:00
parent c0aac56083
commit 23182e8b5a
31 changed files with 2585 additions and 1691 deletions
@@ -177,6 +177,7 @@ public struct RDEPUBTextBook {
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
private let sampler: RDEPUBTextPerformanceSampler
///
@@ -188,9 +189,14 @@ public final class RDEPUBTextBookBuilder {
/// /
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
public init(
renderer: RDEPUBTextRenderer,
cache: RDEPUBTextBookCache? = nil,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer
self.cache = cache
self.layoutConfig = layoutConfig
self.sampler = RDEPUBTextPerformanceSampler()
}
@@ -268,7 +274,8 @@ public final class RDEPUBTextBookBuilder {
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style,
resourceResolver: publication.resourceResolver
resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language
)
// HTML NSAttributedString
@@ -339,7 +346,7 @@ public final class RDEPUBTextBookBuilder {
} else {
// CoreText
layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets, config: layoutConfig)
: []
isCacheHit = false
}
@@ -711,7 +718,8 @@ public final class RDEPUBTextBookBuilder {
fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing,
contentInsets: .zero,
pageSize: pageSize
pageSize: pageSize,
layoutConfigSignature: layoutConfig.cacheSignature
)
}
}
@@ -152,9 +152,10 @@ public final class RDEPUBTextBookCache {
fontSize: CGFloat,
lineHeightMultiple: CGFloat,
contentInsets: UIEdgeInsets,
pageSize: CGSize
pageSize: CGSize,
layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
) -> String {
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)"
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)"
let digest = SHA256.hash(data: Data(raw.utf8))
let hex = digest.map { String(format: "%02x", $0) }.joined()
return hex + ".cache"
@@ -27,6 +27,8 @@ struct RDEPUBTextLayouter {
private let framesetter: CTFramesetter
/// CTFrame
private let path: CGPath
/// DTCoreText
private let dtLayoutRect: CGRect
/// avoidPageBreakInside
private let config: RDEPUBTextLayoutConfig
@@ -35,7 +37,8 @@ struct RDEPUBTextLayouter {
self.pageSize = pageSize
self.config = config
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
self.dtLayoutRect = config.contentRect(fallback: pageSize)
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
}
///
@@ -119,6 +122,10 @@ struct RDEPUBTextLayouter {
#if canImport(DTCoreText)
/// 使 DTCoreTextLayouter
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
@@ -127,7 +134,7 @@ struct RDEPUBTextLayouter {
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let pageRect = CGRect(origin: .zero, size: pageSize)
let pageRect = dtLayoutRect
while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
@@ -180,6 +187,19 @@ struct RDEPUBTextLayouter {
}
#endif
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
}
let path = CGMutablePath()
for rect in columnRects {
path.addRect(rect)
}
return path
}
// MARK: -
/// CoreText
@@ -80,29 +80,101 @@ public struct RDEPUBTextRenderStyle {
///
public struct RDEPUBTextLayoutConfig: Equatable {
/// 0 退 pageSize.width
public var frameWidth: CGFloat
/// 0 退 pageSize.height
public var frameHeight: CGFloat
/// WXRead WRCoreTextLayoutConfig.edgeInsets
public var edgeInsets: UIEdgeInsets
/// WXRead numberOfColumns
public var numberOfColumns: Int
/// WXRead columnGap
public var columnGap: CGFloat
///
public var avoidOrphans: Bool
///
public var avoidWidows: Bool
/// avoidPageBreakInside WXRead 退
public var avoidPageBreakInsideEnabled: Bool
/// WXRead hyphenation
public var hyphenation: Bool
///
public var imageMaxHeightRatio: CGFloat
public init(
frameWidth: CGFloat = 0,
frameHeight: CGFloat = 0,
edgeInsets: UIEdgeInsets = .zero,
numberOfColumns: Int = 1,
columnGap: CGFloat = 20,
avoidOrphans: Bool = true,
avoidWidows: Bool = true,
avoidPageBreakInsideEnabled: Bool = true,
hyphenation: Bool = true,
imageMaxHeightRatio: CGFloat = 0.85
) {
self.frameWidth = frameWidth
self.frameHeight = frameHeight
self.edgeInsets = edgeInsets
self.numberOfColumns = max(1, numberOfColumns)
self.columnGap = max(0, columnGap)
self.avoidOrphans = avoidOrphans
self.avoidWidows = avoidWidows
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
self.hyphenation = hyphenation
self.imageMaxHeightRatio = imageMaxHeightRatio
}
///
public static let `default` = RDEPUBTextLayoutConfig()
/// pageSize
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
CGSize(
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
)
}
/// WXRead frame + edgeInsets
public func contentRect(fallback pageSize: CGSize) -> CGRect {
let size = resolvedFrameSize(fallback: pageSize)
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
}
///
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
let rect = contentRect(fallback: pageSize)
let columns = max(1, numberOfColumns)
guard columns > 1 else { return [rect] }
let totalGap = CGFloat(columns - 1) * columnGap
let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
return (0..<columns).map { index in
let originX = rect.minX + CGFloat(index) * (columnWidth + columnGap)
return CGRect(x: originX, y: rect.minY, width: columnWidth, height: rect.height)
}
}
/// /使
public var cacheSignature: String {
[
String(format: "%.3f", frameWidth),
String(format: "%.3f", frameHeight),
String(format: "%.3f", edgeInsets.top),
String(format: "%.3f", edgeInsets.left),
String(format: "%.3f", edgeInsets.bottom),
String(format: "%.3f", edgeInsets.right),
String(numberOfColumns),
String(format: "%.3f", columnGap),
avoidOrphans ? "1" : "0",
avoidWidows ? "1" : "0",
avoidPageBreakInsideEnabled ? "1" : "0",
hyphenation ? "1" : "0",
String(format: "%.3f", imageMaxHeightRatio)
].joined(separator: "|")
}
}
// MARK: - CSS
@@ -193,7 +193,8 @@ enum RDEPUBTextRendererSupport {
rawHTML: String,
baseURL: URL?,
style: RDEPUBTextRenderStyle,
resourceResolver: RDEPUBResourceResolver?
resourceResolver: RDEPUBResourceResolver?,
contentLanguageCode: String? = nil
) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
@@ -204,7 +205,9 @@ enum RDEPUBTextRendererSupport {
)
let layers = makeStyleSheetLayers(
style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS
epubCSS: stylesheetHrefReplacements.inlinedCSS,
contentLanguageCode: contentLanguageCode,
sourceHTML: rawHTML
)
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
let htmlWithDefaultLayers = injectStyleTag(
@@ -293,11 +296,17 @@ enum RDEPUBTextRendererSupport {
private static func makeStyleSheetLayers(
style: RDEPUBTextRenderStyle,
epubCSS: String
epubCSS: String,
contentLanguageCode: String?,
sourceHTML: String
) -> [RDEPUBTextStyleSheetLayer] {
let useLatinReplace = prefersLatinLanguageCSS(
languageCode: contentLanguageCode,
sourceHTML: sourceHTML
)
var layers: [RDEPUBTextStyleSheetLayer] = [
.init(kind: .default, css: defaultCSS()),
.init(kind: .replace, css: replaceCSS())
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
]
if isDarkTheme(style: style) {
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
@@ -310,65 +319,12 @@ enum RDEPUBTextRendererSupport {
}
private static func defaultCSS() -> String {
"""
html, body {
margin: 0;
padding: 0;
background: transparent;
}
body {
word-break: break-word;
overflow-wrap: break-word;
-webkit-text-size-adjust: 100%;
}
p, div, li, blockquote {
margin-top: 0;
margin-bottom: 0.75em;
}
"""
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
}
private static func replaceCSS() -> String {
"""
img, svg, video, canvas {
max-width: 100%;
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 {
page-break-after: avoid;
break-after: avoid;
}
pre, code {
white-space: pre-wrap;
word-break: break-word;
}
table {
max-width: 100%;
}
"""
private static func replaceCSS(useLatinVariant: Bool) -> String {
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
return RDEPUBAssetRepository.string(for: asset)
}
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
@@ -517,7 +473,7 @@ enum RDEPUBTextRendererSupport {
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
return """
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
html, body {
background: \(background) !important;
color: \(text) !important;
@@ -556,6 +512,118 @@ enum RDEPUBTextRendererSupport {
return luminance < 0.5
}
private static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool {
let candidateCodes = inferredLanguageCodes(
explicitLanguageCode: languageCode,
sourceHTML: sourceHTML
)
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
return true
}
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
return false
}
let textSample = plainTextSample(from: sourceHTML)
guard !textSample.isEmpty else { return false }
var alphabeticCount = 0
var latinCount = 0
for scalar in textSample.unicodeScalars {
guard CharacterSet.letters.contains(scalar) else { continue }
alphabeticCount += 1
if isLatinScalar(scalar) {
latinCount += 1
}
}
guard alphabeticCount >= 80 else { return false }
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
}
private static func inferredLanguageCodes(
explicitLanguageCode: String?,
sourceHTML: String
) -> [String] {
var codes: [String] = []
if let explicitLanguageCode {
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if !normalized.isEmpty {
codes.append(normalized)
}
}
if let regex = try? NSRegularExpression(
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
options: [.caseInsensitive]
) {
let nsHTML = sourceHTML as NSString
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
for match in regex.matches(in: sourceHTML, options: [], range: range) {
guard match.numberOfRanges > 1 else { continue }
let code = nsHTML.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if !code.isEmpty {
codes.append(code)
}
}
}
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
}
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
let normalized = code.lowercased()
if normalized.contains("latn") {
return true
}
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
let latinPrefixes: Set<String> = [
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
]
return latinPrefixes.contains(prefix)
}
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
return ["zh", "ja", "ko"].contains(prefix)
}
private static func plainTextSample(from html: String) -> String {
let maxLength = min(html.count, 20_000)
let sample = String(html.prefix(maxLength))
let withoutTags = sample.replacingOccurrences(
of: #"<[^>]+>"#,
with: " ",
options: .regularExpression
)
return withoutTags.replacingOccurrences(
of: #"&[A-Za-z0-9#]+;"#,
with: " ",
options: .regularExpression
)
}
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
switch scalar.value {
case 0x0041...0x007A,
0x00C0...0x00FF,
0x0100...0x024F,
0x1E00...0x1EFF:
return true
default:
return false
}
}
private static func injectBaseHref(into html: String, baseURL: URL?) -> String {
guard let baseURL else {
return html
@@ -13,9 +13,14 @@ import UIKit
/// 5. CoreText RDEPUBTextBook
public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let layoutConfig: RDEPUBTextLayoutConfig
public init(renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer()) {
public init(
renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer
self.layoutConfig = layoutConfig
}
///
@@ -41,7 +46,7 @@ public final class RDPlainTextBookBuilder {
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize) : []
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(