feat(07): close native text pagination semantics loop

This commit is contained in:
shen
2026-05-22 20:39:26 +08:00
parent 21c8bbb23a
commit d7339aa255
15 changed files with 722 additions and 22 deletions
@@ -3,6 +3,7 @@ import UIKit
enum RDEPUBTextRendererSupport {
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
@@ -44,6 +45,40 @@ enum RDEPUBTextRendererSupport {
return fragmentOffsets
}
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return
}
let mutableString = NSMutableString(string: attributedString.string)
var searchRange = NSRange(location: 0, length: mutableString.length)
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let kind = mutableString.substring(with: match.range(at: 1))
let payload = mutableString.substring(with: match.range(at: 2))
let markerLocation = match.range.location
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
if kind == "start" {
let semantics = parseSemanticMarkerPayload(payload)
openRanges[semantics.id] = (markerLocation, semantics)
} else {
let markerID = parseSemanticEndID(payload)
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
let length = max(markerLocation - opened.location, 0)
if length > 0 {
apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString)
}
}
}
searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation)
}
}
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
@@ -64,6 +99,15 @@ enum RDEPUBTextRendererSupport {
if let attachmentKind = attachmentKind(for: attributes) {
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
}
if let placement = normalizeAttachmentPlacement(for: attributes) {
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
}
if let blockKind = normalizeBlockKind(for: attributes) {
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
}
if let hints = normalizeSemanticHints(for: attributes), !hints.isEmpty {
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
}
attributedString.setAttributes(updatedAttributes, range: range)
blockIndex += 1
}
@@ -86,7 +130,7 @@ enum RDEPUBTextRendererSupport {
style: RDEPUBTextRenderStyle,
resourceResolver: RDEPUBResourceResolver?
) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = normalizeHTML(rawHTML)
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
in: normalizedHTML,
chapterHref: href,
@@ -482,4 +526,261 @@ enum RDEPUBTextRendererSupport {
return nil
}
private static func injectPaginationSemanticMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
return html
}
let nsHTML = html as NSString
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
guard !matches.isEmpty else {
return html
}
var output = ""
var cursor = 0
var openTagStack: [(name: String, id: String)] = []
var nextMarkerID = 0
for match in matches {
let tagRange = match.range
guard tagRange.location >= cursor else { continue }
output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor))
let tag = nsHTML.substring(with: tagRange)
let loweredTag = tag.lowercased()
let tagName = htmlTagName(from: loweredTag)
if loweredTag.hasPrefix("</"), let tagName {
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
let markerID = openTagStack.remove(at: index).id
output += semanticEndMarker(id: markerID)
}
output += tag
} else if let tagName,
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
nextMarkerID += 1
let markerID = String(nextMarkerID)
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
output += startMarker + tag + semanticEndMarker(id: markerID)
} else {
openTagStack.append((name: tagName, id: markerID))
output += tag + startMarker
}
} else {
output += tag
}
cursor = tagRange.location + tagRange.length
}
output += nsHTML.substring(from: cursor)
return output
}
private static func htmlTagName(from loweredTag: String) -> String? {
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("<") else { return nil }
let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" })
let name = body.prefix { $0.isLetter || $0.isNumber }
return name.isEmpty ? nil : String(name)
}
private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? {
let loweredTag = rawTag.lowercased()
let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag)
let hints = inferredHints(forTagName: tagName, rawTag: loweredTag)
let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag)
guard blockKind != nil || !hints.isEmpty || placement != nil else {
return nil
}
return RDPaginationSemantics(
id: "",
blockKind: blockKind,
hints: hints,
attachmentPlacement: placement
)
}
private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? {
if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" {
return .attachment
}
switch tagName {
case "blockquote":
return .blockquote
case "ul", "ol", "li":
return .list
case "table", "thead", "tbody", "tfoot", "tr", "td", "th":
return .table
case "pre", "code":
return .code
case "p":
return .paragraph
case "div":
if rawTag.contains("code") || rawTag.contains("highlight") {
return .code
}
if rawTag.contains("quote") || rawTag.contains("blockquote") {
return .blockquote
}
if rawTag.contains("table") {
return .table
}
if rawTag.contains("list") {
return .list
}
return .generic
default:
return nil
}
}
private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] {
var hints: [RDEPUBTextSemanticHint] = []
if rawTag.contains("avoidpagebreakinside") ||
rawTag.contains("break-inside: avoid") ||
rawTag.contains("page-break-inside: avoid") ||
["blockquote", "pre", "code", "table", "ul", "ol"].contains(tagName) {
hints.append(.avoidPageBreakInside)
}
if rawTag.contains("pagebreakbefore") ||
rawTag.contains("page-break-before: always") ||
rawTag.contains("break-before: page") {
hints.append(.pageBreakBefore)
}
if rawTag.contains("pagebreakafter") ||
rawTag.contains("page-break-after: always") ||
rawTag.contains("break-after: page") {
hints.append(.pageBreakAfter)
}
if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") {
hints.append(.pageRelate)
}
return hints
.reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in
if !result.contains(hint) {
result.append(hint)
}
}
.sorted { $0.rawValue < $1.rawValue }
}
private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? {
guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else {
return nil
}
if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") {
return .centered
}
if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") {
return .baseline
}
return .inline
}
private static func isVoidHTMLTag(_ tagName: String) -> Bool {
["img", "br", "hr", "input", "meta", "link"].contains(tagName)
}
private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String {
var segments = ["id=\(id)"]
if let blockKind = semantics.blockKind {
segments.append("block=\(blockKind.rawValue)")
}
if !semantics.hints.isEmpty {
segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))")
}
if let placement = semantics.attachmentPlacement {
segments.append("placement=\(placement.rawValue)")
}
return "${rd-sem-start:\(segments.joined(separator: ";"))}"
}
private static func semanticEndMarker(id: String) -> String {
"${rd-sem-end:id=\(id)}"
}
private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics {
var values: [String: String] = [:]
payload.split(separator: ";").forEach { entry in
let parts = entry.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { return }
values[String(parts[0])] = String(parts[1])
}
let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:))
let hints = values["hints"]?
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? []
let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
return RDPaginationSemantics(
id: values["id"] ?? UUID().uuidString,
blockKind: blockKind,
hints: hints,
attachmentPlacement: placement
)
}
private static func parseSemanticEndID(_ payload: String) -> String? {
payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) }
}
private static func apply(
semantics: RDPaginationSemantics,
to range: NSRange,
in attributedString: NSMutableAttributedString
) {
var attributes: [NSAttributedString.Key: Any] = [:]
if let blockKind = semantics.blockKind {
attributes[.rdPageBlockKind] = blockKind.rawValue
}
if !semantics.hints.isEmpty {
attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",")
}
if let placement = semantics.attachmentPlacement {
attributes[.rdPageAttachmentPlacement] = placement.rawValue
}
guard !attributes.isEmpty else { return }
attributedString.addAttributes(attributes, range: range)
}
private static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
if let rawValue = attributes[.rdPageBlockKind] as? String,
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
return blockKind
}
return nil
}
private static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
if let rawValue = attributes[.rdPageSemanticHints] as? String {
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
return hints.isEmpty ? nil : hints
}
return nil
}
private static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
return placement
}
if let attachmentKind = attachmentKind(for: attributes), attachmentKind == .image {
return .inline
}
return nil
}
private struct RDPaginationSemantics {
var id: String
var blockKind: RDEPUBTextBlockKind?
var hints: [RDEPUBTextSemanticHint]
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
}
}