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