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
@@ -11,6 +11,14 @@ enum RDEPUBAsset: String {
case bridgeScript = "epub-bridge"
/// HTML epub-fixed-layout.html pre-paginated EPUB
case fixedLayoutTemplate = "epub-fixed-layout"
/// WXRead default.css
case wxReadDefaultCSS = "wxread-default"
/// WXRead replace.css
case wxReadReplaceCSS = "wxread-replace"
/// WXRead dark.css
case wxReadDarkCSS = "wxread-dark"
/// WXRead replaceForLatinLanguageBook.css
case wxReadLatinReplaceCSS = "wxread-replace-latin"
///
var fileExtension: String {
@@ -19,6 +27,8 @@ enum RDEPUBAsset: String {
return "js"
case .fixedLayoutTemplate:
return "html"
case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS:
return "css"
}
}
}
@@ -65,4 +75,4 @@ enum RDEPUBAssetRepository {
}
/// Bundle
private final class RDEPUBAssetBundleToken {}
private final class RDEPUBAssetBundleToken {}
@@ -81,163 +81,22 @@ enum RDEPUBJavaScriptBridge {
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """
(function() {
var payload = \(payload);
function normalizeHref(rawHref) {
if (!rawHref) { return ''; }
var value = String(rawHref);
value = value.replace(/^.*?:\\/\\/[^/]+\\//, '');
value = value.split('#')[0];
value = value.replace(/^\\/+/, '');
try {
value = decodeURIComponent(value);
} catch (error) {
}
return value;
}
function ensureStyle(doc) {
if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; }
var style = doc.createElement('style');
style.id = 'ss-reader-search-style';
style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}';
doc.head.appendChild(style);
}
function clearMarks(doc) {
if (!doc || !doc.body) { return; }
var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active');
marks.forEach(function(mark) {
var textNode = doc.createTextNode(mark.textContent || '');
if (mark.parentNode) {
mark.parentNode.replaceChild(textNode, mark);
}
});
doc.body.normalize();
}
function searchableDocuments(rootDoc) {
var items = [];
if (!rootDoc) { return items; }
items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) });
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
frames.forEach(function(frame) {
try {
if (frame.contentDocument) {
items.push({
doc: frame.contentDocument,
href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href)
});
}
} catch (error) {
}
});
return items;
}
function textNodes(doc) {
if (!doc || !doc.body) { return []; }
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
return NodeFilter.FILTER_REJECT;
}
var parent = node.parentNode;
if (!parent || !parent.nodeName) {
return NodeFilter.FILTER_REJECT;
}
var name = parent.nodeName.toLowerCase();
if (['script', 'style', 'noscript'].indexOf(name) >= 0) {
return NodeFilter.FILTER_REJECT;
}
if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
});
var nodes = [];
while (walker.nextNode()) {
nodes.push(walker.currentNode);
}
return nodes;
}
function applyToDocument(doc, resourceState, keyword) {
clearMarks(doc);
ensureStyle(doc);
if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) {
return;
}
var lowerKeyword = String(keyword).toLowerCase();
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
var currentLocalIndex = 0;
var activeElement = null;
textNodes(doc).forEach(function(node) {
var source = node.nodeValue || '';
var lowerSource = source.toLowerCase();
var cursor = 0;
var replaced = false;
var fragment = doc.createDocumentFragment();
while (cursor < source.length) {
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
if (foundIndex < 0) {
break;
}
replaced = true;
if (foundIndex > cursor) {
fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex)));
}
var mark = doc.createElement('span');
mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark';
mark.textContent = source.slice(foundIndex, foundIndex + keyword.length);
fragment.appendChild(mark);
if (currentLocalIndex === activeIndex) {
activeElement = mark;
}
currentLocalIndex += 1;
cursor = foundIndex + keyword.length;
}
if (!replaced) {
return;
}
if (cursor < source.length) {
fragment.appendChild(doc.createTextNode(source.slice(cursor)));
}
if (node.parentNode) {
node.parentNode.replaceChild(fragment, node);
}
});
if (activeElement && activeElement.scrollIntoView) {
activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' });
}
}
var docs = searchableDocuments(document);
var states = new Map();
if (payload && Array.isArray(payload.resources)) {
payload.resources.forEach(function(resource) {
states.set(normalizeHref(resource.href), resource);
});
}
docs.forEach(function(item) {
applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : '');
});
if (!window.RDReaderBridge) { return false; }
window.RDReaderBridge.setSearchPresentation(\(payload));
return true;
})();
"""
}
static func resolveDecorationsScript() -> String {
"""
(function() {
if (!window.RDReaderBridge) { return { highlights: [], search: [] }; }
return window.RDReaderBridge.resolveDecorations();
})();
"""
}
/// JS
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string
@@ -61,7 +61,17 @@ extension RDEPUBWebView {
///
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
[
let searchSignature = [
request.searchPresentation?.keyword ?? "",
request.searchPresentation?.resources.map {
[
$0.href,
String($0.matchCount),
String($0.activeLocalMatchIndex ?? -1)
].joined(separator: "|")
}.joined(separator: ",") ?? ""
].joined(separator: "#")
return [
publicationKey,
"fixed",
request.spread.resources.map(\.href).joined(separator: "|"),
@@ -72,7 +82,8 @@ extension RDEPUBWebView {
String(format: "%.2f", request.contentInset.bottom),
String(format: "%.2f", request.contentInset.right),
request.backgroundColorCSS ?? "",
request.fit.rawValue
request.fit.rawValue,
searchSignature
].joined(separator: "#")
}
@@ -102,6 +102,16 @@ extension RDEPUBWebView {
let highlightSignature = request.highlights
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
.joined(separator: ",")
let searchSignature = [
request.searchPresentation?.keyword ?? "",
request.searchPresentation?.resources.map {
[
$0.href,
String($0.matchCount),
String($0.activeLocalMatchIndex ?? -1)
].joined(separator: "|")
}.joined(separator: ",") ?? ""
].joined(separator: "#")
let presentation = request.presentation
return [
publicationKey,
@@ -121,7 +131,8 @@ extension RDEPUBWebView {
presentation.themeBackgroundColor ?? "",
presentation.themeTextColor ?? "",
targetSignature,
highlightSignature
highlightSignature,
searchSignature
].joined(separator: "#")
}
@@ -2,6 +2,7 @@
// RDEPUBWebView
// WebView
import UIKit
import WebKit
extension RDEPUBWebView {
@@ -31,7 +32,107 @@ extension RDEPUBWebView {
if let error {
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
}
self.refreshNativeDecorationsIfNeeded(completion: completion)
}
}
func refreshNativeDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
onDecorationsResolved?([])
completion?()
return
}
webView.evaluateJavaScript(RDEPUBJavaScriptBridge.resolveDecorationsScript()) { [weak self] result, error in
guard let self else {
completion?()
return
}
if let error {
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
self.onDecorationsResolved?([])
completion?()
return
}
let decorations = self.nativeDecorations(from: result)
self.onDecorationsResolved?(decorations)
completion?()
}
}
private func nativeDecorations(from result: Any?) -> [RDEPUBTextOverlayDecoration] {
guard let payload = result as? [String: Any] else { return [] }
let highlightDecorations = decorationList(
from: payload["highlights"] as? [[String: Any]],
defaultColor: overlayColor(hex: "#F8E16C", alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
)
let searchDecorations = decorationList(
from: payload["search"] as? [[String: Any]],
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
)
return highlightDecorations + searchDecorations
}
private func decorationList(
from items: [[String: Any]]?,
defaultColor: UIColor
) -> [RDEPUBTextOverlayDecoration] {
guard let items else { return [] }
return items.enumerated().compactMap { index, item -> RDEPUBTextOverlayDecoration? in
guard let kindString = item["kind"] as? String,
let kind = RDEPUBTextOverlayDecoration.Kind(rawValue: kindString),
let rectPayloads = item["rects"] as? [[String: Any]] else {
return nil
}
let rects = rectPayloads.compactMap { rectPayload -> CGRect? in
guard let x = (rectPayload["x"] as? NSNumber)?.doubleValue,
let y = (rectPayload["y"] as? NSNumber)?.doubleValue,
let width = (rectPayload["width"] as? NSNumber)?.doubleValue,
let height = (rectPayload["height"] as? NSNumber)?.doubleValue else {
return nil
}
return CGRect(x: x, y: y, width: width, height: height)
}
guard !rects.isEmpty else { return nil }
let color: UIColor
switch kind {
case .underline:
let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
case .activeSearch:
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
case .search:
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
case .highlight:
let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
case .selection, .locate:
color = defaultColor
}
return RDEPUBTextOverlayDecoration(
kind: kind,
absoluteRange: NSRange(location: index, length: 1),
rects: rects,
color: color
)
}
}
private func overlayColor(hex: String, alpha: CGFloat) -> UIColor? {
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.replacingOccurrences(of: "#", with: "")
guard value.count == 6, let hexValue = Int(value, radix: 16) else { return nil }
return UIColor(
red: CGFloat((hexValue >> 16) & 0xFF) / 255,
green: CGFloat((hexValue >> 8) & 0xFF) / 255,
blue: CGFloat(hexValue & 0xFF) / 255,
alpha: alpha
)
}
}
@@ -71,6 +71,8 @@ public final class RDEPUBWebView: UIView {
public weak var delegate: RDEPUBWebViewDelegate?
/// delegate 使
public var onRendered: (() -> Void)?
///
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
///
var publication: RDEPUBPublication?
@@ -143,6 +145,7 @@ public final class RDEPUBWebView: UIView {
public func reset() {
delegate = nil
onRendered = nil
onDecorationsResolved = nil
publication = nil
currentRenderRequest = nil
currentSpineIndex = 0
@@ -153,47 +153,195 @@
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
}
function unwrapHighlights() {
document.querySelectorAll('mark.ss-reader-highlight').forEach(function(mark) {
var parent = mark.parentNode;
if (!parent) { return; }
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
});
var storedHighlights = [];
var storedSearchPresentation = null;
function normalizeHref(rawHref) {
if (!rawHref) { return ''; }
var value = String(rawHref);
value = value.replace(/^.*?:\/\/[^/]+\//, '');
value = value.split('#')[0];
value = value.replace(/^\/+/, '');
try {
value = decodeURIComponent(value);
} catch (error) {
}
return value;
}
function clearHighlights() {
storedHighlights = [];
}
function setHighlights(items) {
unwrapHighlights();
if (!Array.isArray(items)) { return; }
items.forEach(function(item) {
if (!item || !item.rangeInfo) { return; }
var range = rangeFromInfo(item.rangeInfo);
if (!range || range.collapsed) { return; }
storedHighlights = Array.isArray(items) ? items.slice() : [];
}
function setSearchPresentation(payload) {
storedSearchPresentation = payload || null;
}
function textNodes(doc) {
if (!doc || !doc.body) { return []; }
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
return NodeFilter.FILTER_REJECT;
}
var parent = node.parentNode;
if (!parent || !parent.nodeName) {
return NodeFilter.FILTER_REJECT;
}
var name = parent.nodeName.toLowerCase();
if (['script', 'style', 'noscript'].indexOf(name) >= 0) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
});
var nodes = [];
while (walker.nextNode()) {
nodes.push(walker.currentNode);
}
return nodes;
}
function documentContexts(rootDoc, baseOffsetX, baseOffsetY) {
var items = [];
if (!rootDoc) { return items; }
var offsetX = baseOffsetX || 0;
var offsetY = baseOffsetY || 0;
items.push({
doc: rootDoc,
href: normalizeHref(rootDoc.location && rootDoc.location.href),
offsetX: offsetX,
offsetY: offsetY
});
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
frames.forEach(function(frame) {
try {
var mark = document.createElement('mark');
var style = item.style || 'highlight';
mark.className = 'ss-reader-highlight ss-reader-highlight-' + style;
mark.style.color = 'inherit';
if (style === 'underline') {
mark.style.background = 'transparent';
mark.style.textDecorationLine = 'underline';
mark.style.textDecorationStyle = 'solid';
mark.style.textDecorationThickness = '2px';
mark.style.textDecorationColor = item.color || '#F8E16C';
} else {
mark.style.background = item.color || '#F8E16C';
}
mark.dataset.highlightId = item.id || '';
var fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
if (mark.parentNode && mark.parentNode.normalize) {
mark.parentNode.normalize();
}
if (!frame.contentDocument) { return; }
var frameRect = frame.getBoundingClientRect();
documentContexts(
frame.contentDocument,
offsetX + frameRect.left,
offsetY + frameRect.top
).forEach(function(item) {
items.push(item);
});
} catch (error) {
}
});
return items;
}
function rectPayloadForRange(range, offsetX, offsetY) {
if (!range || range.collapsed) { return []; }
var rects = [];
Array.prototype.forEach.call(range.getClientRects(), function(rect) {
if (!rect || rect.width < 0.5 || rect.height < 0.5) { return; }
rects.push({
x: rect.left + (offsetX || 0),
y: rect.top + (offsetY || 0),
width: rect.width,
height: rect.height
});
});
return rects;
}
function collectHighlightDecorations() {
return storedHighlights.map(function(item, index) {
if (!item || !item.rangeInfo) { return null; }
var range = rangeFromInfo(item.rangeInfo);
var rects = rectPayloadForRange(range, 0, 0);
if (!rects.length) { return null; }
return {
key: item.id || ('highlight-' + index),
kind: item.style === 'underline' ? 'underline' : 'highlight',
color: item.color || '#F8E16C',
rangeInfo: item.rangeInfo,
rects: rects
};
}).filter(Boolean);
}
function collectSearchDecorations() {
if (!storedSearchPresentation || !storedSearchPresentation.keyword) {
return [];
}
var resources = new Map();
if (Array.isArray(storedSearchPresentation.resources)) {
storedSearchPresentation.resources.forEach(function(resource) {
resources.set(normalizeHref(resource.href), resource);
});
}
var keyword = String(storedSearchPresentation.keyword);
var lowerKeyword = keyword.toLowerCase();
var decorations = [];
documentContexts(document, 0, 0).forEach(function(context) {
var resourceState = resources.get(context.href);
if (!resourceState || resourceState.matchCount <= 0) {
return;
}
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
var currentLocalIndex = 0;
var activeRect = null;
var contextStartIndex = decorations.length;
textNodes(context.doc).forEach(function(node) {
var source = node.nodeValue || '';
var lowerSource = source.toLowerCase();
var cursor = 0;
while (cursor < source.length) {
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
if (foundIndex < 0) {
break;
}
var range = context.doc.createRange();
range.setStart(node, foundIndex);
range.setEnd(node, foundIndex + keyword.length);
var rects = rectPayloadForRange(range, context.offsetX, context.offsetY);
if (rects.length) {
var isActive = currentLocalIndex === activeIndex;
decorations.push({
key: context.href + ':' + currentLocalIndex,
kind: isActive ? 'activeSearch' : 'search',
rects: rects
});
if (isActive) {
activeRect = rects[0];
}
}
currentLocalIndex += 1;
cursor = foundIndex + keyword.length;
}
});
if (activeRect && !isFixedLayoutDocument()) {
var previousPageIndex = visiblePageIndex;
var activePageIndex = Math.max(0, Math.floor(Math.max(0, activeRect.x + currentPageOffset()) / effectivePageStride()));
setVisiblePageIndex(activePageIndex);
var deltaX = (activePageIndex - previousPageIndex) * effectivePageStride();
if (deltaX !== 0) {
for (var index = contextStartIndex; index < decorations.length; index += 1) {
decorations[index].rects = decorations[index].rects.map(function(rect) {
return {
x: rect.x - deltaX,
y: rect.y,
width: rect.width,
height: rect.height
};
});
}
}
}
});
return decorations;
}
function resolveDecorations() {
return {
highlights: collectHighlightDecorations(),
search: collectSearchDecorations()
};
}
function selectionPayload() {
var selection = window.getSelection();
@@ -290,7 +438,9 @@
this.scrollToPage(derivedPageIndex);
},
setHighlights: setHighlights,
clearHighlights: unwrapHighlights,
clearHighlights: clearHighlights,
setSearchPresentation: setSearchPresentation,
resolveDecorations: resolveDecorations,
reportProgression: reportProgression,
selectionPayload: selectionPayload
};
@@ -0,0 +1,56 @@
/* 适配搜狗百科 bar 里的 icon 被染色 */
.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before {
background-color: transparent !important;
}
/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */
.abstract-img-none {
background: none !important;
}
/* 适配百度搜索图片丢失 */
.c-touchable-feedback-no-default * {
background-color: transparent !important;
}
img, video {
z-index: 1 !important;
}
/*背景纯黑*/
*, *:before, *:after {
background-color: rgba(0, 0, 0, 1) !important;
}
.wr-business-container,
.wr-business-container *,
.wr-business-container *:before,
.wr-business-container *:after {
background-color: rgb(28, 28, 29) !important;
}
/*背景颜色和一般字体颜色*/
div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section {
color: rgba(180, 180, 182, 1) !important;
border-color: #555555 !important;
text-shadow: 0 0 0 #000;
}
/*超链接*/
a {
color: rgba(84, 127, 176, 1) !important;
}
blockquote {
color : rgb(218, 220, 224);
background-color : rgb(10, 14, 18) !important;
border-left: 1px solid rgba(116, 120, 124, 1);
}
img {
border: none !important;
}
ul li:before, ol li:before {
background-color: rgba(196, 200, 204, 1) !important;
}
@@ -0,0 +1,221 @@
/* this file is processed with xxd via a build rule and embedded in library */
/* these styles come from Safari */
/* note that comments are only permitted before selectors and before the styles */
/* DO NOT fiddle with this file if you want to have your own styles,
pass your own stylesheet via the option parameter to override these defaults */
head {
display:none;
}
title {
display:none;
}
style {
display:none;
}
link {
display: none;
}
meta {
display: none;
}
script {
display: none;
}
html {
display:block;
margin:0;
padding:0;
}
body {
display:block;
font-size:16px;
margin:0;
padding:0;
}
article,aside,footer,header,hgroup,nav,section {
display:block;
}
p {
display:block;
margin:0;
}
img {
margin:0;
}
ul,ol,menu,dir {
display:block;
margin:1em 0 1em 0;
padding-left:24px;
}
ul {
list-style-type:disc;
}
ol {
list-style-type:decimal;
}
li {
display:list-item;
}
ul ul, ol ul {
list-style-type: circle;
}
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
list-style-type: square;
}
code {
font-family:Courier;
}
pre, xmp, plaintext, listing {
display: block;
font-family: monospace;
white-space: pre;
margin: 1em 0;
}
a {
color:#2262A3;
text-decoration:underline;
}
a:active {
color:#2262A3;
}
center {
text-align:center;
display:block;
}
strong,b {
font-weight:bold;
}
i,em {
font-style:italic;
}
u {
text-decoration:underline;
}
big {
font-size:bigger;
}
small {
font-size:smaller;
}
sub {
font-size:smaller;
vertical-align:sub;
}
sup {
font-size:smaller;
vertical-align:super;
}
s,strike,del {
text-decoration:line-through;
}
tt,code,kbd,samp {
font-family:monospace;
}
pre,xmp,plaintext,listing {
display:block;
font-family:monospace;
white-space:pre;
margin-top:1em;
margin-right:0;
margin-bottom:1em;
}
pre {
background-color:rgba(0,0,0,.05);
padding-top:1em;
padding-bottom:1em;
border-radius:0.3em;
}
h1 {
display:block;
font-size:1.5em;
font-weight: normal;
}
h2 {
display:block;
font-size:1.4em;
margin-top: 0.83em;
}
h3 {
display:block;
font-size:1.3em;
margin-top: 1em;
}
h4 {
display:block;
font-size:1.2em;
margin-top: 1.33em;
}
h5 {
display:block;
font-size:1.1em;
margin-top: 1.67em;
}
h6 {
display:block;
font-size:1em;
margin-top: 2.33em;
}
div {
display: block;
}
hr {
display: block;
margin:0.5em auto 0.5em auto;
border-style: inset;
border-width: 1px;
}
table {
display: table;
border-collapse: separate;
border-spacing: 2px;
border-color: gray;
}
blockquote {
display: block;
}
@@ -0,0 +1,113 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-weight: normal;
}
/*标题*/
.firstTitle, h1.firstTitle {
font-size: 1.5em;
font-weight: bold;
line-height: 1.25em;
}
.secondTitle, h2.secondTitle {
font-size: 1.4em;
font-weight: bold;
line-height: 1.35em;
}
.thirdTitle, h3.thirdTitle {
font-size: 1.3em;
font-weight: bold;
line-height: 1.5em;
}
.fourthTitle, h4.fourthTitle {
font-size: 1.2em;
font-weight: bold;
line-height: 1.65em;
}
.fifthTitle, h5.fifthTitle {
font-size: 1.1em;
font-weight: bold;
line-height: 1.85em;
}
.sixthTitle, h6.sixthTitle {
font-size: 1em;
font-weight: bold;
line-height: 2em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.bodyPic {
wr-vertical-center-style: 2;
}
.qrbodyPic {
page-break-inside: avoid;
wr-vertical-center-style: 2;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/* 私有垂直居中类 */
.wr-vertical-center {
wr-vertical-center-style: 1 !important;
}
/* 翻译 */
.wr-translation {
line-height: 1.7em !important;
}
/* 章节结尾工具 */
.book-chapter-tool {
display: block;
height: 78px;
width: 100%;
margin-top: 24px;
}
@@ -0,0 +1,171 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-family: "FZFSJW--GB1-0";
font-weight: normal;
}
/*标题*/
.firstTitle, h1.firstTitle {
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.25em;
}
.secondTitle, h2.secondTitle {
font-size: 1.4em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.35em;
}
.thirdTitle, h3.thirdTitle {
font-size: 1.3em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.5em;
}
.fourthTitle, h4.fourthTitle {
font-size: 1.2em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.65em;
}
.fifthTitle, h5.fifthTitle {
font-size: 1.1em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.85em;
}
.sixthTitle, h6.sixthTitle {
font-size: 1em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 2em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-family: "FZFSJW--GB1-0";
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.bodyPic {
wr-vertical-center-style: 2;
}
.qrbodyPic {
page-break-inside: avoid;
wr-vertical-center-style: 2;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/* 翻译 */
.wr-translation {
line-height: 1.7em !important;
}
/*-------------------- 小文章相关 ----------------------*/
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
.weread-page-relate {
weread-page-relate:true;
}
/*epub-custom-rule-29 小文章打赏的样式*/
.chapter-reward {
display: block;
height: 120px;
width: 100%;
margin-top: 24px;
}
/*epub-custom-rule-7 小文章工具栏标签的样式*/
.chapter-tool {
display: block;
height: 140px;
width: 100%;
margin-top: 24px;
}
/* 章节结尾工具 */
.book-chapter-tool {
display: block;
height: 78px;
width: 100%;
margin-top: 24px;
}
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
.re_bookItem{
display: block;
margin: 18px 0 18px 0;
}
p {
text-align:justify;
}
img[data-image-size="large"] {
width: 100%;
}
/* 文集公众号文章不支持控件的样式 */
.unsupported_iframe {
border-radius: 4px;
border: 1px solid #ffcdc0;
background: #fee;
padding: 12px 0px;
color: #ff8d8d;
text-align: center;
font-size: 14px;
}
/* 私有垂直居中类 */
.wr-vertical-center {
wr-vertical-center-style: 1 !important;
}
@@ -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(
@@ -679,8 +679,9 @@ public final class RDEPUBReaderController: UIViewController {
if publication.readingProfile == .textReflowable {
let renderer = resolvedTextRenderer()
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
let renderStyle = currentTextRenderStyle()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
@@ -913,7 +914,7 @@ public final class RDEPUBReaderController: UIViewController {
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
let pageSize = currentTextPageSize()
let style = currentTextRenderStyle()
let builder = RDPlainTextBookBuilder()
let builder = RDPlainTextBookBuilder(layoutConfig: currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
applyTextBook(newBook, restoreLocation: restoreLocation)
}
@@ -1231,6 +1232,26 @@ public final class RDEPUBReaderController: UIViewController {
)
}
private func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
let viewportSize = currentLayoutContext().viewportSize
let derivedFrameSize = CGSize(
width: max(viewportSize.width, pageSize.width),
height: max(viewportSize.height, pageSize.height)
)
return RDEPUBTextLayoutConfig(
frameWidth: derivedFrameSize.width,
frameHeight: derivedFrameSize.height,
edgeInsets: .zero,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
}
private func resolvedTextRenderer() -> RDEPUBTextRenderer {
RDEPUBDTCoreTextRenderer()
}
@@ -445,12 +445,12 @@ final class RDEPUBTextContentView: UIView {
case .highlight:
content.addAttribute(
.backgroundColor,
value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
range: relativeRange
)
case .underline:
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
if let color = UIColor(hexString: highlight.color, alpha: 1) {
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
content.addAttribute(.underlineColor, value: color, range: relativeRange)
}
}
@@ -545,7 +545,7 @@ final class RDEPUBTextContentView: UIView {
let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue }
let color = UIColor(hexString: highlight.color, alpha: 0.45)
let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
let decoration = RDEPUBTextOverlayDecoration(
kind: highlight.style == .underline ? .underline : .highlight,
@@ -712,17 +712,3 @@ extension RDEPUBTextContentView: UITextViewDelegate {
delegate?.textContentView(self, didChangeSelection: selection)
}
}
private extension UIColor {
convenience init?(hexString: String, alpha: CGFloat) {
var value = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.replacingOccurrences(of: "#", with: "")
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha
)
}
}
@@ -28,6 +28,7 @@ final class RDEPUBWebContentView: UIView {
weak var delegate: RDEPUBWebContentViewDelegate?
private let epubWebView = RDEPUBWebView()
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
private let pageNumberLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
@@ -40,8 +41,12 @@ final class RDEPUBWebContentView: UIView {
layer.masksToBounds = true
addSubview(epubWebView)
addSubview(decorationOverlayView)
addSubview(pageNumberLabel)
epubWebView.delegate = self
epubWebView.onDecorationsResolved = { [weak self] decorations in
self?.decorationOverlayView.applyDecorations(decorations)
}
}
required init?(coder: NSCoder) {
@@ -51,6 +56,7 @@ final class RDEPUBWebContentView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
epubWebView.frame = bounds
decorationOverlayView.frame = bounds
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
pageNumberLabel.frame = CGRect(
@@ -71,13 +77,18 @@ final class RDEPUBWebContentView: UIView {
backgroundColor = theme.contentBackgroundColor
pageNumberLabel.textColor = theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
decorationOverlayView.applyDecorations([])
epubWebView.load(publication: publication, request: request)
setNeedsLayout()
}
func releaseResources() {
decorationOverlayView.applyDecorations([])
epubWebView.reset()
epubWebView.delegate = self
epubWebView.onDecorationsResolved = { [weak self] decorations in
self?.decorationOverlayView.applyDecorations(decorations)
}
}
}
@@ -105,6 +116,5 @@ extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
}
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
}
@@ -0,0 +1,50 @@
import UIKit
/// WebView
/// WXRead JS rect CGContext
final class RDEPUBWebDecorationOverlayView: UIView {
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let verticalAdjustment: CGFloat = -1
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations.filter { !$0.rects.isEmpty }
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for decoration in decorations {
switch decoration.kind {
case .underline:
context.setStrokeColor(decoration.color.cgColor)
context.setLineWidth(2)
for underlineRect in decoration.rects {
let y = underlineRect.maxY - 1
context.move(to: CGPoint(x: underlineRect.minX, y: y))
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
context.strokePath()
}
default:
context.setFillColor(decoration.color.cgColor)
for selectionRect in decoration.rects {
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: verticalAdjustment)
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
}
@@ -119,7 +119,20 @@ public final class RDURLReaderController: UIViewController {
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
let builder = RDPlainTextBookBuilder()
let builder = RDPlainTextBookBuilder(
layoutConfig: RDEPUBTextLayoutConfig(
frameWidth: pageSize.width,
frameHeight: pageSize.height,
edgeInsets: .zero,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
)
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
controller = RDEPUBReaderController(
textBook: textBook,
@@ -0,0 +1,15 @@
import UIKit
extension UIColor {
convenience init?(rdHexString: String, alpha: CGFloat) {
var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.replacingOccurrences(of: "#", with: "")
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha
)
}
}