diff --git a/epub3/lib/src/model.dart b/epub3/lib/src/model.dart index db95405..485a4c0 100644 --- a/epub3/lib/src/model.dart +++ b/epub3/lib/src/model.dart @@ -179,25 +179,22 @@ class ItemRef { } } -class Manifest { +class Package { final Version version; final Metadata metadata; - final List items; + final Manifest manifest; final Spine spine; - // TODO: guide - Manifest({ - required this.version, - required this.metadata, - required this.items, - required this.spine, - }); + Package(this.version, this.metadata, this.manifest, this.spine); +} - String get identifier => metadata.identifier.first.identifier; +class Manifest { + final List items; + Manifest(this.items); - String tocFile() { + String tocFile(Version version) { Item? entry; if (version == Version.epub2) { - entry = items.where((element) => element.id == spine.toc).firstOrNull; + entry = items.where((element) => element.id == 'ncx').firstOrNull; } else if (version == Version.epub3) { // TODO: this is better? // id="nav" first @@ -215,14 +212,19 @@ class Manifest { } return p.join('', entry?.href ?? ''); } + + Item? find(String id) { + return items.firstWhere((i) => i.id == id); + } } class Chapter { final String title; final String? href; final List children; + /// raw content, always content of html file - final String? content; + String? content; /// TODO: extract text from html document String get text => ''; @@ -232,7 +234,7 @@ class Chapter { (prev, c) => prev + c.chapterCount, ); - const Chapter({ + Chapter({ required this.title, this.href, this.children = const [], @@ -242,7 +244,10 @@ class Chapter { factory Chapter.textContent(String title, String text) { final href = title; // TODO: return Chapter( - title: title, href: href, content: Chapter.toHtml(title, text)); + title: title, + href: href, + content: Chapter.toHtml(title, text), + children: []); } // transform text to HTML @@ -326,20 +331,24 @@ abstract class ContentReader { } class Book { + final Version version; final Manifest manifest; + final Metadata metadata; + final Spine spine; final Navigation navigation; + + /// Read content later final ContentReader? reader; - Book(this.manifest, this.navigation, this.reader); + Book(this.version, this.manifest, this.metadata, this.spine, this.navigation, + this.reader); factory Book.create( {required String title, required String author, ContentReader? reader}) { return Book( - Manifest( - version: Version.epub3, - metadata: Metadata.create(title, author), - items: [], - spine: Spine.empty(), - ), + Version.epub3, + Manifest([]), + Metadata.create(title, author), + Spine.empty(), Navigation(title: title, author: author, chapters: []), reader, ); @@ -350,21 +359,20 @@ class Book { manifest.items.addAll(chapter.items); } - Version get version => manifest.version; + String get identifier => metadata.identifier.first.identifier; + String get title { if (navigation.title != null) { return navigation.title!; } - return manifest.metadata.title.isNotEmpty ? manifest.metadata.title[0] : ''; + return metadata.title.isNotEmpty ? metadata.title[0] : ''; } String get author { if (navigation.author != null) { return navigation.author!; } - return manifest.metadata.creator.isNotEmpty - ? manifest.metadata.creator[0].creator - : ''; + return metadata.creator.isNotEmpty ? metadata.creator[0].creator : ''; } String? get cover => @@ -380,7 +388,7 @@ class Book { Item? get nav { if (version == Version.epub2) { return manifest.items - .where((element) => element.id == manifest.spine.toc) + .where((element) => element.id == spine.toc) .firstOrNull; } else if (version == Version.epub3) { return manifest.items @@ -390,14 +398,18 @@ class Book { return null; } - String? toHref(String id) { - return manifest.items.where((i) => i.id == id).firstOrNull?.href; - } - String pathOf(String href) { final pos = href.indexOf('#'); - final path = pos == -1 ? href : href.substring(0, pos); - return p.join(p.dirname(manifest.tocFile()), path); + String path = pos == -1 ? href : href.substring(0, pos); + + // id, not file + if (p.extension(path).isEmpty) { + final item = manifest.find(path); + if (item != null) { + path = item.href!; + } + } + return p.join(p.dirname(manifest.tocFile(version)), path); } String? readString(String href) { diff --git a/epub3/lib/src/reader.dart b/epub3/lib/src/reader.dart index d63835f..31c56da 100644 --- a/epub3/lib/src/reader.dart +++ b/epub3/lib/src/reader.dart @@ -7,7 +7,7 @@ import 'package:xml/xpath.dart' as xml; import 'model.dart'; -/// [Reader] read scheme and content from epub file. +/// [Reader] read `package document` and content from zip file. class Reader extends ContentReader { factory Reader.open(Archive archive) { final rootFile = extractRootFileName(archive); @@ -23,21 +23,31 @@ class Reader extends ContentReader { static const opfNS = 'http://www.idpf.org/2007/opf'; - Book? read() { - final scheme = readSchema(rootFile); - if (scheme == null) { + Book? read({bool extractContent = false}) { + final package = parsePackage(rootFile); + if (package == null) { return null; } - final navfile = pathOf(scheme.tocFile()); - final nav = scheme.version == Version.epub2 + final navfile = package.manifest.tocFile(package.version); + final nav = package.version == Version.epub2 ? readNavigation2(navfile) : readNavigation3(navfile); - return Book(scheme, nav ?? Navigation(chapters: []), this); + final book = Book(package.version, package.manifest, package.metadata, + package.spine, nav ?? Navigation(chapters: []), this); + + if (extractContent) { + book.chapters.forEach((ch) => _loadContent(book, ch)); + } + return book; } - /// Relative path by rootfile - String pathOf(String fp) => p.join(p.dirname(rootFile), fp); + void _loadContent(Book book, Chapter ch) { + ch.content = book.readString(ch.href!); + for (Chapter sub in ch.children) { + _loadContent(book, sub); + } + } static const containerFN = 'META-INF/container.xml'; @@ -59,8 +69,8 @@ class Reader extends ContentReader { return null; } - /// Read [Manifest] from OEBPS/content.opf - Manifest? readSchema(String path) { + /// Parse Package document normal as OEBPS/content.opf + Package? parsePackage(String path) { // root element, var doc = _readAsXml(path); if (doc == null) return null; @@ -76,13 +86,13 @@ class Reader extends ContentReader { throw Exception('Unsupported EPUB version: $vs.'); } - return Manifest( - version: version, - metadata: _extractMetadata( + return Package( + version, + _extractMetadata( doc.findElements('metadata', namespace: opfNS).first, version), - items: _extractManifest( - doc.findElements('manifest', namespace: opfNS).first), - spine: _extractSpine(doc.findElements('spine', namespace: opfNS).first), + Manifest(_extractManifest( + doc.findElements('manifest', namespace: opfNS).first)), + _extractSpine(doc.findElements('spine', namespace: opfNS).first), ); } @@ -212,7 +222,7 @@ class Reader extends ContentReader { .toList(); Navigation? readNavigation2(String path) { - final doc = _readAsXml(path); + final doc = _readAsXml(pathOf(path)); if (doc == null) return null; return _readNav2(doc); @@ -242,7 +252,7 @@ class Reader extends ContentReader { /// path maybe "EPUB/xhtml/epub30-nav.xhtml" /// nav / ol / li / span|a Navigation? readNavigation3(String path) { - final doc = _readAsXml(path); + final doc = _readAsXml(pathOf(path)); if (doc == null) return null; if (doc.localName == 'ncx') { @@ -269,23 +279,25 @@ class Reader extends ContentReader { final a = li.findElements('a').firstOrNull; final id = li.getAttribute('id'); - final href = id == null ? id : a?.getAttribute('href'); + final href = id != null ? id : a?.getAttribute('href'); return Chapter( title: clean(span != null ? span.innerText : a?.innerText ?? ''), href: href, children: _readOl(li), - // TODO: content #2 ); } + /// Relative path by rootfile + String pathOf(String fp) => p.join(p.dirname(rootFile), fp); + /// Read an [ArchiveFile] ArchiveFile? readFile(String path) { assert(path.indexOf('#') == -1); // pathOf for relative path // normalize for avoid ./ - path = p.normalize(pathOf(path)); + path = pathOf(p.normalize(path)); return archive.files .firstWhereOrNull((ArchiveFile file) => file.name == path); diff --git a/epub3/lib/src/writer.dart b/epub3/lib/src/writer.dart index c075dcf..ce7c224 100644 --- a/epub3/lib/src/writer.dart +++ b/epub3/lib/src/writer.dart @@ -73,7 +73,7 @@ class Writer { final out = xml.XmlBuilder(); out.processing('xml', 'version="1.0"'); - final uid = manifest.metadata.identifier.first.id; + final uid = book.metadata.identifier.first.id; out.element( 'package', namespaces: {Reader.opfNS: null}, @@ -86,7 +86,7 @@ class Writer { 'identifier', namespace: dcuri, attributes: {'id': uid}, // same id as [1] - nest: manifest.metadata.identifier.first.identifier, + nest: book.metadata.identifier.first.identifier, ); out.element('title', namespace: dcuri, nest: book.title); out.element('language', namespace: dcuri, nest: 'en'); @@ -116,7 +116,7 @@ class Writer { out.element('spine', // attributes: {'toc': manifest.spine.toc}, nest: () { - for (var i in manifest.spine.refs) { + for (var i in book.spine.refs) { out.element('itemref', attributes: i.attributes); } out.element('itemref', attributes: {'idref': 'nav'}); diff --git a/epub3/pubspec.yaml b/epub3/pubspec.yaml index 018b7dc..592ab90 100644 --- a/epub3/pubspec.yaml +++ b/epub3/pubspec.yaml @@ -1,7 +1,7 @@ name: epub3 description: Read and Write Epub3 files. homepage: https://github.com/pedia/epub3 -version: 0.2.2 +version: 0.3.0 environment: sdk: '>=2.14.0 <4.0.0' diff --git a/epub3/test/reader_test.dart b/epub3/test/reader_test.dart index 552a3ce..2f381c7 100644 --- a/epub3/test/reader_test.dart +++ b/epub3/test/reader_test.dart @@ -30,17 +30,7 @@ void main() { 'test/res/std/linear-algebra.epub', ]; - const csharput = [ - 'test/res/53.epub', - 'test/res/55.epub', - 'test/res/57.epub', - 'test/res/invalid-manifest-epub2.epub', - 'test/res/invalid-manifest-epub3.epub', - 'test/res/missing-navigation-point.epub', - 'test/res/missing-toc.epub', - 'test/res/remote-content.epub', - 'test/res/xml11.epub', - ]; + test('epub2', () { final r = Reader.open( @@ -48,26 +38,26 @@ void main() { final book = r.read(); expect(book!.version, Version.epub2); expect( - book.manifest.metadata.title, equals(['Test title 1', 'Test title 2'])); + book.metadata.title, equals(['Test title 1', 'Test title 2'])); expect( MetaCreator('John Doe', fileAs: 'Doe, John', role: 'author') == MetaCreator('John Doe', fileAs: 'Doe, John', role: 'author'), isTrue, ); expect( - book.manifest.metadata.creator, + book.metadata.creator, equals([ MetaCreator('John Doe', fileAs: 'Doe, John', role: 'author'), MetaCreator('Jane Doe', fileAs: 'Doe, Jane', role: 'author'), ])); expect( - book.manifest.metadata.contributor, + book.metadata.contributor, equals([ MetaCreator('John Editor', fileAs: 'Editor, John', role: 'editor'), MetaCreator('Jane Editor', fileAs: 'Editor, Jane', role: 'editor'), ])); expect( - book.manifest.metadata.identifier, + book.metadata.identifier, equals([ MetaIdentifier( identifier: 'https://example.com/books/123', @@ -80,15 +70,15 @@ void main() { scheme: 'ISBN', ), ])); - expect(book.manifest.metadata.subject, + expect(book.metadata.subject, equals(['Test subject 1', 'Test subject 2'])); - expect(book.manifest.metadata.publisher, + expect(book.metadata.publisher, equals(['Test publisher 1', 'Test publisher 2'])); - expect(book.manifest.metadata.description, equals(['Test description'])); - expect(book.manifest.metadata.type, equals(['dictionary', 'preview'])); - expect(book.manifest.metadata.format, equals(['format-1', 'format-2'])); + expect(book.metadata.description, equals(['Test description'])); + expect(book.metadata.type, equals(['dictionary', 'preview'])); + expect(book.metadata.format, equals(['format-1', 'format-2'])); expect( - book.manifest.metadata.source, + book.metadata.source, equals([ 'https://example.com/books/123/content-1.html', 'https://example.com/books/123/content-2.html', @@ -128,25 +118,25 @@ void main() { final book = readFile('test/res/epub3.epub'); expect(book!.version, Version.epub3); expect( - book.manifest.metadata.title, equals(['Test title 1', 'Test title 2'])); + book.metadata.title, equals(['Test title 1', 'Test title 2'])); expect( MetaCreator('John Doe', fileAs: 'Doe, John', role: 'author'), MetaCreator('John Doe', fileAs: 'Doe, John', role: 'author'), ); expect( - book.manifest.metadata.creator, + book.metadata.creator, equals([ MetaCreator('John Doe', fileAs: 'Doe, John', role: 'author'), MetaCreator('Jane Doe', fileAs: 'Doe, Jane', role: 'author'), ])); expect( - book.manifest.metadata.contributor, + book.metadata.contributor, equals([ MetaCreator('John Editor', fileAs: 'Editor, John', role: 'editor'), MetaCreator('Jane Editor', fileAs: 'Editor, Jane', role: 'editor'), ])); expect( - book.manifest.metadata.identifier, + book.metadata.identifier, equals([ MetaIdentifier( identifier: 'https://example.com/books/123', @@ -159,15 +149,15 @@ void main() { scheme: 'ISBN', ), ])); - expect(book.manifest.metadata.subject, + expect(book.metadata.subject, equals(['Test subject 1', 'Test subject 2'])); - expect(book.manifest.metadata.publisher, + expect(book.metadata.publisher, equals(['Test publisher 1', 'Test publisher 2'])); - expect(book.manifest.metadata.description, equals(['Test description'])); - expect(book.manifest.metadata.type, equals(['dictionary', 'preview'])); - expect(book.manifest.metadata.format, equals(['format-1', 'format-2'])); + expect(book.metadata.description, equals(['Test description'])); + expect(book.metadata.type, equals(['dictionary', 'preview'])); + expect(book.metadata.format, equals(['format-1', 'format-2'])); expect( - book.manifest.metadata.source, + book.metadata.source, equals([ 'https://example.com/books/123/content-1.html', 'https://example.com/books/123/content-2.html', @@ -185,12 +175,12 @@ void main() { expect(book.navigation.chapterCount, 5); expect( - book.navigation.chapters[0], + book.chapters[0], equals(Chapter(title: 'Test span header 1', children: [ Chapter(title: 'Chapter 1', href: 'chapter1.html', children: []), ]))); expect( - book.navigation.chapters[1], + book.chapters[1], equals(Chapter(title: 'Test span header 2', children: [ Chapter(title: 'Chapter 2', href: 'chapter2.html', children: []), Chapter(title: 'Chapter 3', href: 'chapter3.html', children: []), @@ -227,7 +217,7 @@ void main() { readit(Chapter c) { if (c.href != null) { final af = book.readBytes(c.href!); - expect(af, isNotNull); + expect(af, isNotNull, reason: '$f ${c.href} "${c.title}"'); } for (var cc in c.children) { readit(cc); @@ -240,6 +230,18 @@ void main() { } }); + const csharput = [ + 'test/res/53.epub', + 'test/res/55.epub', + 'test/res/57.epub', + 'test/res/invalid-manifest-epub2.epub', + 'test/res/invalid-manifest-epub3.epub', + 'test/res/missing-navigation-point.epub', + 'test/res/missing-toc.epub', + 'test/res/remote-content.epub', + 'test/res/xml11.epub', + ]; + test('malformed', () { csharput.forEach((f) { print('epub: $f'); diff --git a/epub3/test/writer_test.dart b/epub3/test/writer_test.dart index a103c55..acec685 100644 --- a/epub3/test/writer_test.dart +++ b/epub3/test/writer_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; +import 'package:archive/archive_io.dart'; import 'package:epub3/epub3_io.dart'; import 'package:test/test.dart'; import 'package:archive/archive.dart'; @@ -51,4 +53,26 @@ void main() { } } }); + + test('content with children', () { + final book1 = Book.create(title: 'title', author: 'author'); + book1.add(Chapter(title: 'c1', content: 'c1-body', children: [ + Chapter(title: 'c11', content: 'c11-body'), + Chapter(title: 'c12', content: 'c12-body'), + ])); + book1.add(Chapter(title: 'c2', content: 'c2-body', children: [ + Chapter(title: 'c21', content: 'c21-body', children: [ + Chapter(title: 'c211', content: 'c211-body'), + ]), + Chapter(title: 'c22', content: 'c22-body'), + ])); + + final a = Archive(); + Writer(book1).write(a); + File('foo.epub')..createSync()..writeAsBytesSync(ZipEncoder().encode(a)!); + + final book2 = Reader.open(a).read(extractContent: true); + expect(book2?.chapters.length, 2); + expect(book2?.chapters.first.content, 'c1-body'); + }); } diff --git a/epubview/lib/epubview.dart b/epubview/lib/epubview.dart index fa294bd..34b341b 100644 --- a/epubview/lib/epubview.dart +++ b/epubview/lib/epubview.dart @@ -12,6 +12,7 @@ class Calculator { int addOne(int value) => value + 1; } +/// none-tree-liked Chapter class Chapter { Chapter({required this.title, this.href, this.parent}); final String title; @@ -40,25 +41,24 @@ class Chapter { } } -class ChapterView extends StatelessWidget { +class ReaderState extends ChangeNotifier { + final epub.Book book; final List chapters; - final String? href; - final void Function(Chapter)? onChapterSelect; - const ChapterView({ - required this.chapters, - this.href, - this.onChapterSelect, - super.key, - }); - - factory ChapterView.from( - epub.Book book, { - void Function(Chapter)? onChapterSelect, - }) => - ChapterView( - chapters: Chapter.build(book), - onChapterSelect: onChapterSelect, - ); + Chapter? chapter; + + ReaderState(this.book) : chapters = Chapter.build(book); + + void setCurrent(Chapter ch) { + chapter = ch; + notifyListeners(); + } +} + +class ChapterView extends StatelessWidget { + final ReaderState rs; + const ChapterView(this.rs, {super.key}); + + factory ChapterView.from(epub.Book book) => ChapterView(ReaderState(book)); @override Widget build(BuildContext context) { @@ -67,17 +67,18 @@ class ChapterView extends StatelessWidget { // print( // 'tile font: ${Theme.of(context).textTheme.bodyMedium?.fontFamily} ' // '${Theme.of(context).textTheme.bodyMedium?.fontFamilyFallback}'); - final ch = chapters[index]; + final ch = rs.chapters[index]; return ListTile( leading: ch.parent == null ? null : const Text(''), title: Text(ch.title), + selected: rs.chapter == ch, onTap: () { - onChapterSelect?.call(ch); + rs.setCurrent(ch); print('click ${ch.href}'); }, ); }, - itemCount: chapters.length, + itemCount: rs.chapters.length, ); } }