mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
fix #90: export comic as epub
This commit is contained in:
@@ -46,21 +46,28 @@ class _ToastOverlay extends StatelessWidget {
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: Theme.of(context).colorScheme.onInverseSurface),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) icon!.paddingRight(8),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (trailing != null) trailing!.paddingLeft(8)
|
||||
],
|
||||
child: IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: context.width - 32,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) icon!.paddingRight(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!.paddingLeft(8)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -220,7 +227,7 @@ LoadingDialogController showLoadingDialog(BuildContext context,
|
||||
);
|
||||
});
|
||||
|
||||
var navigator = Navigator.of(context);
|
||||
var navigator = Navigator.of(context, rootNavigator: true);
|
||||
|
||||
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
|
||||
part of 'favorites_page.dart';
|
||||
|
||||
/// Open a dialog to create a new favorite folder.
|
||||
|
@@ -7,6 +7,7 @@ import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/downloading_page.dart';
|
||||
import 'package:venera/utils/cbz.dart';
|
||||
import 'package:venera/utils/epub.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/pdf.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
@@ -389,6 +390,33 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
File(cache).deleteIgnoreError();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (!multiSelectMode)
|
||||
MenuEntry(
|
||||
icon: Icons.import_contacts_outlined,
|
||||
text: "Export as epub".tl,
|
||||
onClick: () async {
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
File? file;
|
||||
try {
|
||||
file = await createEpubWithLocalComic(
|
||||
c as LocalComic,
|
||||
);
|
||||
await saveFile(
|
||||
file: file,
|
||||
filename: "${c.title}.epub",
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("EPUB Export", e, s);
|
||||
context.showMessage(message: e.toString());
|
||||
} finally {
|
||||
controller.close();
|
||||
file?.deleteIgnoreError();
|
||||
}
|
||||
},
|
||||
)
|
||||
];
|
||||
},
|
||||
|
208
lib/utils/epub.dart
Normal file
208
lib/utils/epub.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/utils/file_type.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:zip_flutter/zip_flutter.dart';
|
||||
|
||||
class EpubData {
|
||||
final String title;
|
||||
|
||||
final String author;
|
||||
|
||||
final File cover;
|
||||
|
||||
final Map<String, List<File>> chapters;
|
||||
|
||||
const EpubData({
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.cover,
|
||||
required this.chapters,
|
||||
});
|
||||
}
|
||||
|
||||
Future<File> createEpubComic(EpubData data, String cacheDir) async {
|
||||
final workingDir = Directory(FilePath.join(cacheDir, 'epub'));
|
||||
if (workingDir.existsSync()) {
|
||||
workingDir.deleteSync(recursive: true);
|
||||
}
|
||||
workingDir.createSync(recursive: true);
|
||||
|
||||
// mimetype
|
||||
workingDir.joinFile('mimetype').writeAsStringSync('application/epub+zip');
|
||||
|
||||
// META-INF
|
||||
Directory(FilePath.join(workingDir.path, 'META-INF')).createSync();
|
||||
File(FilePath.join(workingDir.path, 'META-INF', 'container.xml'))
|
||||
.writeAsStringSync('''
|
||||
<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
''');
|
||||
|
||||
Directory(FilePath.join(workingDir.path, 'OEBPS')).createSync();
|
||||
|
||||
// copy images, create html files
|
||||
final imageDir = Directory(FilePath.join(workingDir.path, 'OEBPS', 'images'));
|
||||
imageDir.createSync();
|
||||
final coverExt = data.cover.extension;
|
||||
final coverMime = FileType.fromExtension(coverExt).mime;
|
||||
imageDir
|
||||
.joinFile('cover.$coverExt')
|
||||
.writeAsBytesSync(data.cover.readAsBytesSync());
|
||||
int imgIndex = 0;
|
||||
int chapterIndex = 0;
|
||||
var manifestStrBuilder = StringBuffer();
|
||||
manifestStrBuilder.writeln(
|
||||
' <item id="cover_image" href="OEBPS/images/cover.$coverExt" media-type="$coverMime"/>');
|
||||
manifestStrBuilder.writeln(
|
||||
' <item id="toc" href="toc.ncx" media-type="application/x-dtbncx+xml"/>');
|
||||
for (final chapter in data.chapters.keys) {
|
||||
var images = <String>[];
|
||||
for (final image in data.chapters[chapter]!) {
|
||||
final ext = image.extension;
|
||||
imageDir
|
||||
.joinFile('img$imgIndex.$ext')
|
||||
.writeAsBytesSync(image.readAsBytesSync());
|
||||
images.add('images/img$imgIndex.$ext');
|
||||
var mime = FileType.fromExtension(ext).mime;
|
||||
manifestStrBuilder.writeln(
|
||||
' <item id="img$imgIndex" href="OEBPS/images/img$imgIndex$ext" media-type="$mime"/>');
|
||||
imgIndex++;
|
||||
}
|
||||
var html =
|
||||
File(FilePath.join(workingDir.path, 'OEBPS', '$chapterIndex.html'));
|
||||
html.writeAsStringSync('''
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
|
||||
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>$chapter</title>
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>$chapter</h1>
|
||||
<div>
|
||||
${images.map((e) => ' <img src="$e" alt="$e"/>').join('\n')}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
''');
|
||||
manifestStrBuilder.writeln(
|
||||
' <item id="chapter$chapterIndex" href="OEBPS/$chapterIndex.html" media-type="application/xhtml+xml"/>');
|
||||
chapterIndex++;
|
||||
}
|
||||
|
||||
// content.opf
|
||||
final contentOpf =
|
||||
File(FilePath.join(workingDir.path, 'content.opf'));
|
||||
final uuid = const Uuid().v4();
|
||||
var spineStrBuilder = StringBuffer();
|
||||
for (var i = 0; i < chapterIndex; i++) {
|
||||
var idRef = 'idref="chapter$i"';
|
||||
spineStrBuilder.writeln(' <itemref $idRef/>');
|
||||
}
|
||||
contentOpf.writeAsStringSync('''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package version="3.0"
|
||||
xmlns="http://www.idpf.org/2007/opf"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata>
|
||||
<dc:title>${data.title}</dc:title>
|
||||
<dc:creator>${data.author}</dc:creator>
|
||||
<dc:identifier id="book_id">urn:uuid:$uuid</dc:identifier>
|
||||
<meta name="cover" content="cover_image"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
${manifestStrBuilder.toString()}
|
||||
</manifest>
|
||||
<spine toc="toc">
|
||||
${spineStrBuilder.toString()}
|
||||
</spine>
|
||||
</package>
|
||||
''');
|
||||
|
||||
// toc.ncx
|
||||
final tocNcx = File(FilePath.join(workingDir.path, 'toc.ncx'));
|
||||
var navMapStrBuilder = StringBuffer();
|
||||
var playOrder = 2;
|
||||
final chapterNames = data.chapters.keys.toList();
|
||||
for (var i = 0; i < chapterIndex; i++) {
|
||||
navMapStrBuilder
|
||||
.writeln(' <navPoint id="chapter$i" playOrder="$playOrder">');
|
||||
navMapStrBuilder.writeln(
|
||||
' <navLabel><text>${chapterNames[i]}</text></navLabel>');
|
||||
navMapStrBuilder.writeln(' <content src="OEBPS/$i.html"/>');
|
||||
navMapStrBuilder.writeln(' </navPoint>');
|
||||
playOrder++;
|
||||
}
|
||||
|
||||
tocNcx.writeAsStringSync('''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="urn:uuid:$uuid"/>
|
||||
<meta name="dtb:depth" content="1"/>
|
||||
<meta name="dtb:totalPageCount" content="0"/>
|
||||
<meta name="dtb:maxPageNumber" content="0"/>
|
||||
</head>
|
||||
<docTitle>
|
||||
<text>${data.title}</text>
|
||||
</docTitle>
|
||||
<navMap>
|
||||
${navMapStrBuilder.toString()}
|
||||
</navMap>
|
||||
</ncx>
|
||||
''');
|
||||
|
||||
// zip
|
||||
final zipPath = FilePath.join(cacheDir, '${data.title}.epub');
|
||||
ZipFile.compressFolder(workingDir.path, zipPath);
|
||||
|
||||
return File(zipPath);
|
||||
}
|
||||
|
||||
Future<File> createEpubWithLocalComic(LocalComic comic) async {
|
||||
var chapters = <String, List<File>>{};
|
||||
if (comic.chapters == null) {
|
||||
chapters[comic.title] =
|
||||
(await LocalManager().getImages(comic.id, comic.comicType, 0))
|
||||
.map((e) => File(e))
|
||||
.toList();
|
||||
} else {
|
||||
for (var chapter in comic.chapters!.keys) {
|
||||
chapters[comic.chapters![chapter]!] = (await LocalManager()
|
||||
.getImages(comic.id, comic.comicType, chapter))
|
||||
.map((e) => File(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
var data = EpubData(
|
||||
title: comic.title,
|
||||
author: comic.subtitle,
|
||||
cover: comic.coverFile,
|
||||
chapters: chapters,
|
||||
);
|
||||
|
||||
final cacheDir = App.cachePath;
|
||||
|
||||
return Isolate.run(() => overrideIO(() async {
|
||||
return createEpubComic(data, cacheDir);
|
||||
}));
|
||||
}
|
Reference in New Issue
Block a user