226 lines
10 KiB
Swift
226 lines
10 KiB
Swift
// RDEPUBStyleSheetBuilder.swift
|
||
// EPUB CSS 样式构建器
|
||
// 负责生成重排模式下的分页 CSS(多列布局)和测量 CSS,
|
||
// 以及将分页样式注入到 HTML 中。还提供测量脚本(JS)用于计算页数。
|
||
|
||
import Foundation
|
||
|
||
/// CSS 样式构建工具集,生成分页、渲染和测量所需的 CSS/JS
|
||
public enum RDEPUBStyleSheetBuilder {
|
||
/// 将分页 CSS 注入到 HTML 字符串中(插入 </head> 或 <body 之前)
|
||
public static func injectPaginationCSS(
|
||
into html: String,
|
||
presentation: RDEPUBPresentationStyle
|
||
) -> String {
|
||
let styleTag = "<style id=\"ss-reader-pagination\">\(measurementCSS(for: presentation))</style>"
|
||
if html.range(of: "</head>", options: .caseInsensitive) != nil {
|
||
return html.replacingOccurrences(of: "</head>", with: styleTag + "</head>", options: .caseInsensitive)
|
||
}
|
||
if html.range(of: "<body", options: .caseInsensitive) != nil {
|
||
return html.replacingOccurrences(of: "<body", with: styleTag + "<body", options: .caseInsensitive)
|
||
}
|
||
return styleTag + html
|
||
}
|
||
|
||
/// 生成重排模式的渲染 CSS(视口、body、#ss-reader-viewport、#ss-reader-content 等)
|
||
/// 包含多列布局、字号、行高、主题色、高亮样式等
|
||
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||
let values = cssValues(for: presentation)
|
||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
||
|
||
return """
|
||
html {
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
width: \(values.viewportWidth)px !important;
|
||
height: \(values.viewportHeight)px !important;
|
||
min-height: \(values.viewportHeight)px !important;
|
||
overflow-x: hidden !important;
|
||
overflow-y: hidden !important;
|
||
}
|
||
body {
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
box-sizing: border-box !important;
|
||
position: relative !important;
|
||
width: \(values.viewportWidth)px !important;
|
||
height: \(values.viewportHeight)px !important;
|
||
min-height: \(values.viewportHeight)px !important;
|
||
overflow: hidden !important;
|
||
background: transparent !important;
|
||
}
|
||
#ss-reader-viewport {
|
||
position: relative !important;
|
||
width: \(values.viewportWidth)px !important;
|
||
height: \(values.viewportHeight)px !important;
|
||
min-height: \(values.viewportHeight)px !important;
|
||
overflow: hidden !important;
|
||
background: transparent !important;
|
||
}
|
||
#ss-reader-content {
|
||
position: relative !important;
|
||
box-sizing: border-box !important;
|
||
width: \(values.viewportWidth)px !important;
|
||
height: \(values.viewportHeight)px !important;
|
||
min-height: \(values.viewportHeight)px !important;
|
||
overflow: visible !important;
|
||
padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important;
|
||
font-size: \(values.fontSize)px !important;
|
||
line-height: \(values.lineHeight) !important;
|
||
-webkit-column-width: \(values.contentWidth)px !important;
|
||
column-width: \(values.contentWidth)px !important;
|
||
\(columnCountCSS)
|
||
-webkit-column-gap: \(values.columnGap)px !important;
|
||
column-gap: \(values.columnGap)px !important;
|
||
-webkit-column-fill: auto !important;
|
||
column-fill: auto !important;
|
||
transform-origin: top left !important;
|
||
will-change: transform !important;
|
||
\(backgroundCSS)
|
||
\(textCSS)
|
||
}
|
||
#ss-reader-content img, #ss-reader-content svg, #ss-reader-content video, #ss-reader-content canvas, #ss-reader-content iframe {
|
||
max-width: 100% !important;
|
||
height: auto !important;
|
||
break-inside: avoid-column !important;
|
||
-webkit-column-break-inside: avoid !important;
|
||
}
|
||
#ss-reader-content mark.ss-reader-highlight {
|
||
padding: 0;
|
||
border-radius: 2px;
|
||
color: inherit !important;
|
||
}
|
||
#ss-reader-content mark.ss-reader-highlight-highlight {
|
||
text-decoration: none !important;
|
||
color: inherit !important;
|
||
}
|
||
#ss-reader-content mark.ss-reader-highlight-underline {
|
||
background: transparent !important;
|
||
color: inherit !important;
|
||
text-decoration-line: underline !important;
|
||
text-decoration-thickness: 2px !important;
|
||
text-decoration-skip-ink: auto !important;
|
||
}
|
||
"""
|
||
}
|
||
|
||
/// 生成分页测量用的 CSS(与渲染 CSS 类似但不含 transform/will-change 等优化属性)
|
||
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||
let values = cssValues(for: presentation)
|
||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
||
|
||
return """
|
||
html {
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
width: \(values.viewportWidth)px !important;
|
||
height: \(values.viewportHeight)px !important;
|
||
min-height: \(values.viewportHeight)px !important;
|
||
overflow: hidden !important;
|
||
}
|
||
body {
|
||
margin: 0 !important;
|
||
box-sizing: border-box !important;
|
||
position: relative !important;
|
||
width: \(values.viewportWidth)px !important;
|
||
height: \(values.viewportHeight)px !important;
|
||
min-height: \(values.viewportHeight)px !important;
|
||
overflow: visible !important;
|
||
padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important;
|
||
font-size: \(values.fontSize)px !important;
|
||
line-height: \(values.lineHeight) !important;
|
||
-webkit-column-width: \(values.contentWidth)px !important;
|
||
column-width: \(values.contentWidth)px !important;
|
||
\(columnCountCSS)
|
||
-webkit-column-gap: \(values.columnGap)px !important;
|
||
column-gap: \(values.columnGap)px !important;
|
||
-webkit-column-fill: auto !important;
|
||
column-fill: auto !important;
|
||
transform-origin: top left !important;
|
||
\(backgroundCSS)
|
||
\(textCSS)
|
||
}
|
||
img, svg, video, canvas, iframe {
|
||
max-width: 100% !important;
|
||
height: auto !important;
|
||
break-inside: avoid-column !important;
|
||
-webkit-column-break-inside: avoid !important;
|
||
}
|
||
"""
|
||
}
|
||
|
||
/// 生成分页测量脚本(JS):注入 CSS 后测量 scrollWidth,计算页数
|
||
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
|
||
let style = measurementCSS(for: presentation)
|
||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "`", with: "\\`")
|
||
|
||
let viewportWidth = Double(max(1, presentation.viewportSize.width))
|
||
let viewportWidthJS = String(format: "%.3f", viewportWidth)
|
||
|
||
return """
|
||
(function() {
|
||
var style = document.getElementById('ss-reader-pagination');
|
||
if (!style) {
|
||
style = document.createElement('style');
|
||
style.id = 'ss-reader-pagination';
|
||
document.head.appendChild(style);
|
||
}
|
||
style.textContent = `\(style)`;
|
||
var scrollingElement = document.scrollingElement || document.documentElement || document.body;
|
||
var totalWidth = Math.max(
|
||
scrollingElement ? scrollingElement.scrollWidth : 0,
|
||
scrollingElement ? scrollingElement.offsetWidth : 0,
|
||
document.body ? document.body.scrollWidth : 0,
|
||
document.body ? document.body.offsetWidth : 0,
|
||
document.documentElement ? document.documentElement.scrollWidth : 0,
|
||
document.documentElement ? document.documentElement.offsetWidth : 0
|
||
);
|
||
return Math.max(1, Math.ceil(totalWidth / \(viewportWidthJS)));
|
||
})();
|
||
"""
|
||
}
|
||
|
||
/// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等)
|
||
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
|
||
viewportWidth: String,
|
||
viewportHeight: String,
|
||
contentWidth: String,
|
||
columnGap: String,
|
||
numberOfColumns: Int,
|
||
paddingTop: String,
|
||
paddingRight: String,
|
||
paddingBottom: String,
|
||
paddingLeft: String,
|
||
fontSize: String,
|
||
lineHeight: String
|
||
) {
|
||
let viewportWidth = max(1, presentation.viewportSize.width)
|
||
let viewportHeight = max(1, presentation.viewportSize.height)
|
||
let contentInsets = presentation.contentInsets
|
||
let numberOfColumns = max(1, presentation.numberOfColumns)
|
||
let availableWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
||
let totalGap = CGFloat(numberOfColumns - 1) * max(0, presentation.columnGap)
|
||
let contentWidth = max(1, (availableWidth - totalGap) / CGFloat(numberOfColumns))
|
||
let columnGap = max(0, presentation.columnGap)
|
||
|
||
return (
|
||
viewportWidth: String(format: "%.3f", viewportWidth),
|
||
viewportHeight: String(format: "%.3f", viewportHeight),
|
||
contentWidth: String(format: "%.3f", contentWidth),
|
||
columnGap: String(format: "%.3f", columnGap),
|
||
numberOfColumns: numberOfColumns,
|
||
paddingTop: String(format: "%.3f", contentInsets.top),
|
||
paddingRight: String(format: "%.3f", contentInsets.right),
|
||
paddingBottom: String(format: "%.3f", contentInsets.bottom),
|
||
paddingLeft: String(format: "%.3f", contentInsets.left),
|
||
fontSize: String(format: "%.3f", presentation.fontSize),
|
||
lineHeight: String(format: "%.3f", max(1, presentation.lineHeightMultiple))
|
||
)
|
||
}
|
||
}
|