ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift
shenlei e4e629a06c 长章节内存优化 P0:每页子串改按需构造并新增内存打点
- RDEPUBTextPage.content 由存储属性改为基于 chapterContent+contentRange
  的计算属性,三处构造点不再生成每页子串,章节缓存窗口内每章少一份
  整章文本常驻副本
- 新增 RDEPUBMemoryProbe(--demo-memory-probe 开启),在章节插入、
  每 20 次翻页、设置失效、转屏完成时输出 phys_footprint
- shouldAvoidReaderPageCaching 补注释,锁定放开前置条件为 P1-1
- 新增实施清单文档 LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md 并勾选 P0

验证:Demo 编译通过;UI 回归 9/9 通过(PageNavigation/
ReaderOpenClose/LargeBookOnDemand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:34:45 +09:00

159 lines
6.4 KiB
Swift

import UIKit
public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let layoutConfig: RDEPUBTextLayoutConfig
public init(
renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer
self.layoutConfig = layoutConfig
}
public func build(
textFileURL: URL,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook {
let rawText = rd_decodeTextFile(url: textFileURL)
let chapterSpecs = splitChapters(from: rawText)
var chapters: [RDEPUBTextChapter] = []
var flatPages: [RDEPUBTextPage] = []
for (index, spec) in chapterSpecs.enumerated() {
let html = wrapTextAsHTML(spec.content)
let request = RDEPUBTextChapterRenderRequest(
context: RDEPUBTextChapterContext(
href: "chapter_\(index).xhtml",
title: spec.title ?? "\(index + 1)",
html: html,
baseURL: nil,
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
resourceDiagnostics: []
),
style: style,
pageSize: pageSize,
layoutConfig: layoutConfig
)
let rendered = try renderer.renderChapter(request: request)
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: layoutFrames
let href = "chapter_\(index).xhtml"
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: index,
spineIndex: index,
href: href,
chapterTitle: spec.title ?? "\(index + 1)",
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
chapters.append(
RDEPUBTextChapter(
chapterIndex: index,
spineIndex: index,
href: href,
title: spec.title ?? "\(index + 1)",
attributedContent: chapterAttributedContent,
fragmentOffsets: [:],
cfiMap: nil,
pageBreakReasons: pages.map { $0.metadata.breakReason },
pages: pages
)
)
flatPages.append(contentsOf: pages)
}
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
}
private struct ChapterSpec {
let title: String?
let content: String
}
private func splitChapters(from text: String) -> [ChapterSpec] {
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
return [ChapterSpec(title: nil, content: text)]
}
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
guard !matches.isEmpty else {
return [ChapterSpec(title: nil, content: text)]
}
var specs: [ChapterSpec] = []
for (i, match) in matches.enumerated() {
let titleRange = match.range(at: 1)
let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines)
let contentStart = titleRange.location + titleRange.length
let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length
let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
if !content.isEmpty {
specs.append(ChapterSpec(title: title, content: content))
}
}
if specs.isEmpty {
return [ChapterSpec(title: nil, content: text)]
}
return specs
}
private func wrapTextAsHTML(_ text: String) -> String {
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
return "<html><body>\(body)</body></html>"
}
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
}
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
return content
}
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
return content
}
return ""
}
}