Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions.

<!-- ## [Unreleased] -->
## [Unreleased]

### Added

#### Shared

* The EPUB parser now exposes the publication's ISBNs through the new `metadata.isbns` property. ISBNs are read from the OPF `dc:identifier` elements, recognized via an `opf:scheme="ISBN"` attribute (EPUB 2), an `identifier-type` ONIX code (EPUB 3), a `urn:isbn:` URN, or Calibre's `isbn:` value prefix. Each value is returned as declared (scheme prefix and separators removed); a publication may expose more than one (e.g. its ISBN-10 and ISBN-13).


## [3.10.0] - 2026-06-24

Expand Down
19 changes: 19 additions & 0 deletions Sources/Shared/Publication/Extensions/EPUB/Metadata+EPUB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,29 @@
import Foundation

private let mediaOverlayKey = "mediaOverlay"
private let isbnKey = "isbn"

public extension Metadata {
/// Media overlay CSS class names for this publication.
var mediaOverlay: EPUBMediaOverlay? {
try? otherMetadata[mediaOverlayKey]?.decode()
}

/// ISBNs declared by the publication, as found in the OPF.
///
/// The package's primary `identifier` is usually a UUID, so an ISBN — when
/// present — is typically a *secondary* `dc:identifier` tagged via an
/// `opf:scheme` attribute (EPUB 2), an `identifier-type` ONIX code (EPUB 3),
/// or expressed as a `urn:isbn:` URN. A publication may declare more than one
/// (e.g. the ISBN-10 and ISBN-13 of the same book, or distinct ISBNs per
/// format). Values are returned as declared, with only the scheme prefix and
/// separators removed — no normalization. Empty when the publication carries
/// no recognizable ISBN.
var isbns: [String] {
guard case let .array(values)? = otherMetadata[isbnKey] else { return [] }
return values.compactMap {
guard case let .string(value) = $0 else { return nil }
return value
}
}
}
44 changes: 44 additions & 0 deletions Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class EPUBMetadataParser: Loggable {

var other = metas.otherMetadata
if let mo = mediaOverlay() { other["mediaOverlay"] = .object(mo.jsonObject) }
if !isbns.isEmpty { other["isbn"] = .array(isbns.map { .string($0) }) }

return Metadata(
identifier: uniqueIdentifier,
Expand Down Expand Up @@ -323,6 +324,49 @@ final class EPUBMetadataParser: Loggable {
dcElement(tag: "identifier[@id=/opf:package/@unique-identifier]")?
.stringValue

/// The publication's ISBNs, as declared in the OPF.
///
/// The package `unique-identifier` is usually a UUID, so an ISBN — when
/// present — is a *secondary* `dc:identifier` tagged via an `opf:scheme`
/// attribute (EPUB 2), an `identifier-type` ONIX Code List 5 refinement
/// (EPUB 3), or expressed as a `urn:isbn:` URN. A publication may declare
/// more than one (e.g. the ISBN-10 and ISBN-13 of the same book, or distinct
/// ISBNs per format), so every matching `dc:identifier` is returned, in
/// document order with duplicates removed. Values are surfaced as declared,
/// with only the scheme prefix and separators stripped — no normalization.
private lazy var isbns: [String] = {
var seen = Set<String>()
return metas["identifier", in: .dcterms]
.filter { isISBNIdentifier($0) }
.compactMap { Self.cleanedISBN($0.content) }
.filter { seen.insert($0).inserted }
}()

private func isISBNIdentifier(_ meta: OPFMeta) -> Bool {
// EPUB 3 (spec) tags the scheme with an `identifier-type` meta refining
// the `dc:identifier`, carrying an ONIX Code List 5 value (15 = ISBN-13,
// 02 = ISBN-10). EPUB 2 uses an `opf:scheme` attribute on the element.
// Calibre's EPUB 3 writer and the URN form instead prefix the value
// text (`isbn:…` / `urn:isbn:…`).
let value = meta.content.lowercased()
if value.hasPrefix("urn:isbn:") || value.hasPrefix("isbn:") { return true }
if meta.element.attr("scheme")?.caseInsensitiveCompare("isbn") == .orderedSame { return true }
let onixCode = meta.id.flatMap { metas["identifier-type", refining: $0].first?.content }
return onixCode == "15" || onixCode == "02"
}

/// Strips the scheme prefix (`urn:isbn:` / `isbn:`) and separators from a raw
/// identifier value, returning the bare ISBN as declared. nil when empty.
private static func cleanedISBN(_ raw: String) -> String? {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
for prefix in ["urn:isbn:", "isbn:"] where value.lowercased().hasPrefix(prefix) {
value = String(value.dropFirst(prefix.count))
break
}
value = value.filter { !$0.isWhitespace && $0 != "-" }
return value.isEmpty ? nil : value
}

/// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#publication-date
private lazy var publishedDate =
dcElement(tag: "date[not(@opf:event) or @opf:event='publication']")?
Expand Down
15 changes: 15 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-calibre.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Programming Scala</dc:title>
<dc:identifier id="pub-id">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier>isbn:9781449325862</dc:identifier>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
16 changes: 16 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-duplicate.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Programming Scala</dc:title>
<dc:identifier id="pub-id">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier opf:scheme="ISBN">9781449325862</dc:identifier>
<dc:identifier>urn:isbn:9781449325862</dc:identifier>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
15 changes: 15 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-epub2.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Programming Scala</dc:title>
<dc:identifier id="pub-id" opf:scheme="UUID">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier opf:scheme="ISBN">978-1-4493-2586-2</dc:identifier>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
16 changes: 16 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-epub3-isbn10.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>The C Programming Language</dc:title>
<dc:identifier id="pub-id">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier id="isbn-id">0306406152</dc:identifier>
<meta refines="#isbn-id" property="identifier-type" scheme="onix:codelist5">02</meta>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
16 changes: 16 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-epub3.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Programming Scala</dc:title>
<dc:identifier id="pub-id">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier id="isbn-id">9781449325862</dc:identifier>
<meta refines="#isbn-id" property="identifier-type" scheme="onix:codelist5">15</meta>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
16 changes: 16 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-multiple.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Programming Scala</dc:title>
<dc:identifier id="pub-id" opf:scheme="UUID">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier opf:scheme="ISBN">9781449325862</dc:identifier>
<dc:identifier opf:scheme="ISBN">1449325866</dc:identifier>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
15 changes: 15 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn-urn.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Programming Scala</dc:title>
<dc:identifier id="pub-id">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier>urn:isbn:9781449325862</dc:identifier>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
15 changes: 15 additions & 0 deletions Tests/StreamerTests/Fixtures/OPF/identifier-isbn10.opf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>The C Programming Language</dc:title>
<dc:identifier id="pub-id">urn:uuid:7408D53A-5383-40AA-8078-5256C872AE41</dc:identifier>
<dc:identifier opf:scheme="ISBN">0306406152</dc:identifier>
</metadata>
<manifest>
<item id="titlepage" href="titlepage.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="titlepage"/>
</spine>
</package>
54 changes: 54 additions & 0 deletions Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,60 @@ class EPUBMetadataParserTests: XCTestCase {
XCTAssertNotNil(sut.otherMetadata["mediaOverlay"])
}

// MARK: - ISBN

func testParseISBN13FromEPUB3IdentifierType() throws {
let sut = try parseMetadata("identifier-isbn-epub3")
XCTAssertEqual(sut.isbns, ["9781449325862"])
}

func testParseISBNFromEPUB2OPFScheme() throws {
let sut = try parseMetadata("identifier-isbn-epub2")
// Hyphens stripped from "978-1-4493-2586-2".
XCTAssertEqual(sut.isbns, ["9781449325862"])
}

func testParseISBNFromURN() throws {
let sut = try parseMetadata("identifier-isbn-urn")
XCTAssertEqual(sut.isbns, ["9781449325862"])
}

/// ISBN-10 values are returned as declared, not converted to ISBN-13.
func testParseISBN10FromEPUB2OPFScheme() throws {
let sut = try parseMetadata("identifier-isbn10")
XCTAssertEqual(sut.isbns, ["0306406152"])
}

/// Calibre's EPUB 3 writer prefixes the scheme into the value text rather
/// than using the spec's `identifier-type` refinement.
func testParseISBNFromCalibreSchemePrefix() throws {
let sut = try parseMetadata("identifier-isbn-calibre")
XCTAssertEqual(sut.isbns, ["9781449325862"])
}

func testParseISBN10FromEPUB3IdentifierType() throws {
let sut = try parseMetadata("identifier-isbn-epub3-isbn10")
XCTAssertEqual(sut.isbns, ["0306406152"])
}

/// A publication may declare several ISBNs (e.g. ISBN-13 and ISBN-10); all
/// are returned, in document order.
func testParseMultipleISBNs() throws {
let sut = try parseMetadata("identifier-isbn-multiple")
XCTAssertEqual(sut.isbns, ["9781449325862", "1449325866"])
}

/// The same ISBN declared under more than one scheme is returned once.
func testParseDuplicateISBNsAreDeduplicated() throws {
let sut = try parseMetadata("identifier-isbn-duplicate")
XCTAssertEqual(sut.isbns, ["9781449325862"])
}

func testNoISBNWhenOnlyUUIDIdentifiers() throws {
let sut = try parseMetadata("identifier-unique")
XCTAssertEqual(sut.isbns, [])
}

// MARK: - Toolkit

func parseMetadata(_ name: String, displayOptions: String? = nil) throws -> Metadata {
Expand Down