ReadViewSDK/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

408 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
extension RDEPUBParser {
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
let publicationLayout = packageDocument.metadata.layout
let spineItems = try packageDocument.spineReferences.map { reference in
guard let manifestItem = packageDocument.manifestByID[reference.idref] else {
throw RDEPUBParserError.missingManifestItem(idref: reference.idref)
}
let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL)
let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackTitle = normalizedHref
.split(separator: "/")
.last
.map(String.init)
.map { $0.replacingOccurrences(of: ".xhtml", with: "") }
.map { $0.replacingOccurrences(of: ".html", with: "") }
.flatMap { $0.isEmpty ? nil : $0 }
?? reference.idref
let effectiveLayout: RDEPUBLayout?
if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") {
effectiveLayout = .fixed
} else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") {
effectiveLayout = .reflowable
} else {
effectiveLayout = publicationLayout == .fixed ? .fixed : nil
}
return RDEPUBSpineItem(
idref: reference.idref,
href: normalizedHref,
mediaType: manifestItem.mediaType,
title: title?.isEmpty == false ? title! : fallbackTitle,
linear: reference.linear,
properties: reference.properties,
pageSpread: reference.pageSpread,
layout: effectiveLayout
)
}
guard !spineItems.isEmpty else {
throw RDEPUBParserError.emptySpine
}
return spineItems
}
}
struct OPFPackageDocument {
var metadata: RDEPUBMetadata
var manifest: [RDEPUBManifestItem]
var manifestByID: [String: RDEPUBManifestItem]
var spineReferences: [OPFSpineReference]
var ncxItem: RDEPUBManifestItem?
var navigationItem: RDEPUBManifestItem?
}
struct OPFSpineReference {
var idref: String
var linear: Bool
var properties: [String]
var pageSpread: RDEPUBPageSpread?
}
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var metadata = RDEPUBMetadata()
private var manifestItems: [RDEPUBManifestItem] = []
private var spineReferences: [OPFSpineReference] = []
private let xmlContext = XMLParserContext()
private var uniqueIdentifierID: String?
private var currentIdentifierElementID: String?
private var currentMetaProperty: String?
private var identifierByID: [String: String] = [:]
private var manifestTitleByID: [String: String] = [:]
private var currentMetaRefinesID: String?
private var ncxID: String?
func packageDocument() -> OPFPackageDocument {
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
metadata.identifier = fallbackIdentifier
}
metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines)
metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines)
let manifest = manifestItems.map { item in
var updatedItem = item
updatedItem.title = manifestTitleByID[item.id]
return updatedItem
}
// EPUB OPF manifest id`uniqueKeysWithValues`
// spine OPF
//
let manifestByID = manifest.reduce(into: [String: RDEPUBManifestItem]()) { result, item in
guard !item.id.isEmpty, result[item.id] == nil else { return }
result[item.id] = item
}
return OPFPackageDocument(
metadata: metadata,
manifest: manifest,
manifestByID: manifestByID,
spineReferences: spineReferences,
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
navigationItem: manifest.first(where: { $0.isNavigationDocument })
)
}
func parser(
_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String: String] = [:]
) {
let name = xmlContext.startElement(named: qName ?? elementName)
switch name {
case "package":
metadata.version = attributeDict["version"]
uniqueIdentifierID = attributeDict["unique-identifier"]
case "identifier":
if currentSection == .metadata {
currentIdentifierElementID = attributeDict["id"]
}
case "item":
guard currentSection == .manifest,
let id = attributeDict["id"],
let href = attributeDict["href"],
let mediaType = attributeDict["media-type"] else {
return
}
manifestItems.append(
RDEPUBManifestItem(
id: id,
href: href,
mediaType: mediaType,
properties: XMLName.tokenize(attributeDict["properties"]),
fallback: attributeDict["fallback"],
mediaOverlay: attributeDict["media-overlay"]
)
)
case "itemref":
guard currentSection == .spine, let idref = attributeDict["idref"] else {
return
}
spineReferences.append(
OPFSpineReference(
idref: idref,
linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no",
properties: XMLName.tokenize(attributeDict["properties"]),
pageSpread: pageSpread(from: attributeDict)
)
)
case "meta":
currentMetaProperty = attributeDict["property"] ?? attributeDict["name"]
currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:))
if let property = currentMetaProperty?.lowercased(),
property == "rendition:layout",
let value = attributeDict["content"] {
metadata.layout = layout(from: value)
}
if let property = currentMetaProperty?.lowercased(),
property == "rendition:spread" {
metadata.spread = attributeDict["content"]
}
if let property = currentMetaProperty?.lowercased(),
property == "rendition:orientation",
let value = attributeDict["content"] {
metadata.orientation = orientation(from: value)
}
if let property = currentMetaProperty?.lowercased(),
property == "title",
let refinesID = currentMetaRefinesID,
let value = attributeDict["content"],
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines)
}
default:
break
}
if name == "spine" {
if let direction = attributeDict["page-progression-direction"]?.lowercased() {
metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto)
}
ncxID = attributeDict["toc"]
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
xmlContext.appendCharacters(string)
}
func parser(
_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?
) {
let name = XMLName.localName(from: qName ?? elementName)
let text = xmlContext.trimmedCharacters
switch (currentSection, name) {
case (.metadata, "identifier"):
if let identifierID = currentIdentifierElementID, !text.isEmpty {
identifierByID[identifierID] = text
if identifierID == uniqueIdentifierID {
metadata.identifier = text
}
} else if metadata.identifier == nil, !text.isEmpty {
metadata.identifier = text
}
currentIdentifierElementID = nil
case (.metadata, "title"):
if metadata.title.isEmpty, !text.isEmpty {
metadata.title = text
}
case (.metadata, "creator"):
if metadata.author == nil, !text.isEmpty {
metadata.author = text
}
case (.metadata, "language"):
if metadata.language == nil, !text.isEmpty {
metadata.language = text
}
case (.metadata, "meta"):
applyMetaValue(text)
currentMetaProperty = nil
currentMetaRefinesID = nil
default:
break
}
xmlContext.endElement()
}
private var currentSection: XMLSection {
if xmlContext.containsElement(named: "manifest") {
return .manifest
}
if xmlContext.containsElement(named: "spine") {
return .spine
}
if xmlContext.containsElement(named: "metadata") {
return .metadata
}
return .other
}
private func applyMetaValue(_ value: String) {
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
return
}
switch property {
case "rendition:layout":
metadata.layout = layout(from: normalizedValue)
case "rendition:spread":
metadata.spread = normalizedValue
case "rendition:orientation":
metadata.orientation = orientation(from: normalizedValue)
case "dcterms:identifier", "identifier":
if metadata.identifier == nil {
metadata.identifier = normalizedValue
}
case "title":
if let refinesID = currentMetaRefinesID {
manifestTitleByID[refinesID] = normalizedValue
}
default:
break
}
}
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
if let properties = attributes["properties"]?.lowercased() {
if properties.contains("page-spread-left") {
return .left
}
if properties.contains("page-spread-right") {
return .right
}
if properties.contains("page-spread-center") {
return .center
}
}
if let legacySpread = attributes["page-spread"]?.lowercased() {
switch legacySpread {
case "left":
return .left
case "right":
return .right
case "center":
return .center
default:
return nil
}
}
return nil
}
private func layout(from rawValue: String) -> RDEPUBLayout {
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
}
private func orientation(from rawValue: String) -> RDEPUBOrientation? {
switch rawValue.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) {
case "portrait":
return .portrait
case "landscape":
return .landscape
case "auto":
return .auto
default:
return nil
}
}
}
enum XMLSection {
case metadata
case manifest
case spine
case other
}
enum XMLName {
static func localName(from rawName: String) -> String {
rawName.split(separator: ":").last.map(String.init) ?? rawName
}
static func tokenize(_ rawValue: String?) -> [String] {
guard let rawValue else { return [] }
return rawValue
.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" })
.map(String.init)
}
static func refinedID(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("#") else {
return nil
}
return String(trimmed.dropFirst())
}
}
final class XMLParserContext {
private var elementStack: [String] = []
private var currentCharacters = ""
var trimmedCharacters: String {
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
}
func startElement(named rawName: String) -> String {
let name = XMLName.localName(from: rawName)
elementStack.append(name)
currentCharacters = ""
return name
}
func appendCharacters(_ string: String) {
currentCharacters += string
}
func endElement() {
if !elementStack.isEmpty {
_ = elementStack.removeLast()
}
currentCharacters = ""
}
func containsElement(named name: String) -> Bool {
elementStack.contains(name)
}
}