feat(07): close native text pagination semantics loop
This commit is contained in:
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user