feat(epub): align wxread css and overlay pagination behavior
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user