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) } }