Fix epub compatibility + cover image ext
[fanfix.git] / src / be / nikiroo / fanfix / output / Epub.java
CommitLineData
08fe2e33
NR
1package be.nikiroo.fanfix.output;
2
3d247bc3 3import java.io.BufferedWriter;
08fe2e33 4import java.io.File;
ecfb936e 5import java.io.FileInputStream;
3d247bc3 6import java.io.FileOutputStream;
08fe2e33
NR
7import java.io.IOException;
8import java.io.InputStream;
ecfb936e 9import java.io.OutputStream;
3d247bc3 10import java.io.OutputStreamWriter;
ecfb936e
NR
11import java.util.zip.ZipEntry;
12import java.util.zip.ZipOutputStream;
08fe2e33 13
08fe2e33
NR
14import be.nikiroo.fanfix.Instance;
15import be.nikiroo.fanfix.bundles.Config;
16import be.nikiroo.fanfix.bundles.StringId;
17import be.nikiroo.fanfix.data.Chapter;
18import be.nikiroo.fanfix.data.MetaData;
19import be.nikiroo.fanfix.data.Paragraph;
08fe2e33 20import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
3d247bc3 21import be.nikiroo.fanfix.data.Story;
08fe2e33
NR
22import be.nikiroo.utils.IOUtils;
23import be.nikiroo.utils.StringUtils;
24
25class Epub extends BasicOutput {
26 private File tmpDir;
3d247bc3 27 private BufferedWriter writer;
08fe2e33
NR
28 private boolean inDialogue = false;
29 private boolean inNormal = false;
30 private File images;
16a81ef7 31 private boolean nextParaIsCover = true;
08fe2e33
NR
32
33 @Override
34 public File process(Story story, File targetDir, String targetName)
35 throws IOException {
36 String targetNameOrig = targetName;
10d558d2 37 targetName += getDefaultExtension(false);
08fe2e33
NR
38
39 tmpDir = File.createTempFile("fanfic-reader-epub_", ".wip");
40 tmpDir.delete();
41
42 if (!tmpDir.mkdir()) {
43 throw new IOException(
44 "Cannot create a temporary directory: no space left on device?");
45 }
46
47 // "Originals"
48 File data = new File(tmpDir, "DATA");
49 data.mkdir();
925298fd
NR
50 BasicOutput.getOutput(OutputType.TEXT, isWriteInfo(), isWriteCover())
51 .process(story, data, targetNameOrig);
fa4dcafe 52 InfoCover.writeInfo(data, targetNameOrig, story.getMeta());
08fe2e33
NR
53 IOUtils.writeSmallFile(data, "version", "3.0");
54
55 super.process(story, targetDir, targetNameOrig);
56
57 // zip/epub
58 File epub = new File(targetDir, targetName);
59 IOUtils.zip(tmpDir, epub, true);
ecfb936e
NR
60
61 OutputStream out = new FileOutputStream(epub);
62 try {
63 ZipOutputStream zip = new ZipOutputStream(out);
64 try {
65 // "mimetype" MUST be the first element and not compressed
66 zip.setLevel(ZipOutputStream.STORED);
67 File mimetype = new File(tmpDir, "mimetype");
68 IOUtils.writeSmallFile(tmpDir, "mimetype",
69 "application/epub+zip");
70 ZipEntry entry = new ZipEntry("mimetype");
71 entry.setExtra(new byte[] {});
72 zip.putNextEntry(entry);
73 FileInputStream in = new FileInputStream(mimetype);
74 try {
75 IOUtils.write(in, zip);
76 } finally {
77 in.close();
78 }
79 IOUtils.deltree(mimetype);
80 zip.setLevel(ZipOutputStream.DEFLATED);
81 //
82
83 IOUtils.zip(zip, "", tmpDir, true);
84 } finally {
85 zip.close();
86 }
87 } finally {
88 out.close();
89 }
90
08fe2e33
NR
91 IOUtils.deltree(tmpDir);
92 tmpDir = null;
93
94 return epub;
95 }
96
97 @Override
10d558d2 98 public String getDefaultExtension(boolean readerTarget) {
08fe2e33
NR
99 return ".epub";
100 }
101
102 @Override
103 protected void writeStoryHeader(Story story) throws IOException {
104 File ops = new File(tmpDir, "OPS");
105 ops.mkdirs();
106 File css = new File(ops, "css");
107 css.mkdirs();
108 images = new File(ops, "images");
109 images.mkdirs();
110 File metaInf = new File(tmpDir, "META-INF");
111 metaInf.mkdirs();
112
08fe2e33 113 // META-INF
ecfb936e 114 String containerContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
08fe2e33
NR
115 + "<container xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\" version=\"1.0\">\n"
116 + "\t<rootfiles>\n"
117 + "\t\t<rootfile full-path=\"OPS/epb.opf\" media-type=\"application/oebps-package+xml\"/>\n"
118 + "\t</rootfiles>\n" + "</container>\n";
119
120 IOUtils.writeSmallFile(metaInf, "container.xml", containerContent);
121
122 // OPS/css
123 InputStream inStyle = getClass().getResourceAsStream("epub.style.css");
124 if (inStyle == null) {
125 throw new IOException("Cannot find style.css resource");
126 }
127 try {
128 IOUtils.write(inStyle, new File(css, "style.css"));
129 } finally {
130 inStyle.close();
131 }
132
133 // OPS/images
134 if (story.getMeta() != null && story.getMeta().getCover() != null) {
ecfb936e 135 File file = new File(images, "cover");
16a81ef7
NR
136 Instance.getCache().saveAsImage(story.getMeta().getCover(), file,
137 true);
08fe2e33
NR
138 }
139
140 // OPS/* except chapters
141 IOUtils.writeSmallFile(ops, "epb.ncx", generateNcx(story));
142 IOUtils.writeSmallFile(ops, "epb.opf", generateOpf(story));
ecfb936e 143 IOUtils.writeSmallFile(ops, "title.xhtml", generateTitleXml(story));
08fe2e33
NR
144
145 // Resume
146 if (story.getMeta() != null && story.getMeta().getResume() != null) {
147 writeChapter(story.getMeta().getResume());
148 }
149 }
150
151 @Override
152 protected void writeChapterHeader(Chapter chap) throws IOException {
153 String filename = String.format("%s%03d%s", "chapter-",
ecfb936e 154 chap.getNumber(), ".xhtml");
3d247bc3
NR
155 writer = new BufferedWriter(new OutputStreamWriter(
156 new FileOutputStream(new File(tmpDir + File.separator + "OPS",
157 filename)), "UTF-8"));
08fe2e33
NR
158 inDialogue = false;
159 inNormal = false;
160 try {
161 String title = "Chapter " + chap.getNumber();
162 String nameOrNum = Integer.toString(chap.getNumber());
163 if (chap.getName() != null && !chap.getName().isEmpty()) {
164 title += ": " + chap.getName();
165 nameOrNum = chap.getName();
166 }
167
ecfb936e
NR
168 writer.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
169 writer.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
170 writer.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
08fe2e33
NR
171 writer.write("\n<head>");
172 writer.write("\n <title>" + StringUtils.xmlEscape(title)
173 + "</title>");
174 writer.write("\n <link rel='stylesheet' href='css/style.css' type='text/css'/>");
175 writer.write("\n</head>");
176 writer.write("\n<body>");
177 writer.write("\n <h2>");
178 writer.write("\n <span class='chap'>Chapter <span class='chapnumber'>"
179 + chap.getNumber() + "</span>:</span> ");
180 writer.write("\n <span class='chaptitle'>"
181 + StringUtils.xmlEscape(nameOrNum) + "</span>");
182 writer.write("\n </h2>");
183 writer.write("\n ");
184 writer.write("\n <div class='chapter_content'>\n");
185 } catch (Exception e) {
186 writer.close();
187 throw new IOException(e);
188 }
189 }
190
191 @Override
192 protected void writeChapterFooter(Chapter chap) throws IOException {
193 try {
194 if (inDialogue) {
195 writer.write(" </div>\n");
196 inDialogue = false;
197 }
198 if (inNormal) {
199 writer.write(" </div>\n");
200 inNormal = false;
201 }
202 writer.write(" </div>\n</body>\n</html>\n");
203 } finally {
204 writer.close();
205 writer = null;
206 }
207 }
208
209 @Override
210 protected void writeParagraphHeader(Paragraph para) throws IOException {
211 if (para.getType() == ParagraphType.QUOTE && !inDialogue) {
212 writer.write(" <div class='dialogues'>\n");
213 inDialogue = true;
214 } else if (para.getType() != ParagraphType.QUOTE && inDialogue) {
215 writer.write(" </div>\n");
216 inDialogue = false;
217 }
218
219 if (para.getType() == ParagraphType.NORMAL && !inNormal) {
220 writer.write(" <div class='normals'>\n");
221 inNormal = true;
222 } else if (para.getType() != ParagraphType.NORMAL && inNormal) {
223 writer.write(" </div>\n");
224 inNormal = false;
225 }
226
227 switch (para.getType()) {
228 case BLANK:
229 writer.write(" <div class='blank'></div>");
230 break;
231 case BREAK:
f977d05b 232 writer.write(" <hr class='break'/>");
08fe2e33
NR
233 break;
234 case NORMAL:
235 writer.write(" <span class='normal'>");
236 break;
237 case QUOTE:
238 writer.write(" <div class='dialogue'>&mdash; ");
239 break;
240 case IMAGE:
241 File file = new File(images, getCurrentImageBestName(false));
16a81ef7
NR
242 Instance.getCache().saveAsImage(para.getContentImage(), file,
243 nextParaIsCover);
ecfb936e 244 writer.write(" <img alt='page image' class='page-image' src='images/"
08fe2e33
NR
245 + getCurrentImageBestName(false) + "'/>");
246 break;
247 }
16a81ef7
NR
248
249 nextParaIsCover = false;
08fe2e33
NR
250 }
251
252 @Override
253 protected void writeParagraphFooter(Paragraph para) throws IOException {
254 switch (para.getType()) {
255 case NORMAL:
256 writer.write("</span>\n");
257 break;
258 case QUOTE:
259 writer.write("</div>\n");
260 break;
261 default:
262 writer.write("\n");
263 break;
264 }
265 }
266
267 @Override
268 protected void writeTextLine(ParagraphType type, String line)
269 throws IOException {
270 switch (type) {
271 case QUOTE:
272 case NORMAL:
273 writer.write(decorateText(StringUtils.xmlEscape(line)));
274 break;
275 default:
276 break;
277 }
278 }
279
280 @Override
281 protected String enbold(String word) {
282 return "<strong>" + word + "</strong>";
283 }
284
285 @Override
286 protected String italize(String word) {
287 return "<emph>" + word + "</emph>";
288 }
289
290 private String generateNcx(Story story) {
291 StringBuilder builder = new StringBuilder();
292
293 String title = "";
294 String uuid = "";
295 String author = "";
296 if (story.getMeta() != null) {
297 MetaData meta = story.getMeta();
298 uuid = meta.getUuid();
299 author = meta.getAuthor();
300 title = meta.getTitle();
301 }
302
ecfb936e 303 builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
08fe2e33
NR
304 builder.append("\n<!DOCTYPE ncx");
305 builder.append("\nPUBLIC \"-//NISO//DTD ncx 2005-1//EN\" \"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd\">");
306 builder.append("\n<ncx xmlns=\"http://www.daisy.org/z3986/2005/ncx/\" version=\"2005-1\">");
307 builder.append("\n <head>");
308 builder.append("\n <!--The following four metadata items are required for all");
309 builder.append("\n NCX documents, including those conforming to the relaxed");
310 builder.append("\n constraints of OPS 2.0-->");
311 builder.append("\n <meta name=\"dtb:uid\" content=\""
312 + StringUtils.xmlEscapeQuote(uuid) + "\"/>");
313 builder.append("\n <meta name=\"dtb:depth\" content=\"1\"/>");
314 builder.append("\n <meta name=\"dtb:totalPageCount\" content=\"0\"/>");
315 builder.append("\n <meta name=\"dtb:maxPageNumber\" content=\"0\"/>");
316 builder.append("\n <meta name=\"epub-creator\" content=\""
317 + StringUtils.xmlEscapeQuote(EPUB_CREATOR) + "\"/>");
318 builder.append("\n </head>");
319 builder.append("\n <docTitle>");
320 builder.append("\n <text>" + StringUtils.xmlEscape(title) + "</text>");
321 builder.append("\n </docTitle>");
322 builder.append("\n <docAuthor>");
323
324 builder.append("\n <text>" + StringUtils.xmlEscape(author) + "</text>");
325 builder.append("\n </docAuthor>");
326 builder.append("\n <navMap>");
327 builder.append("\n <navPoint id=\"navpoint-1\" playOrder=\"1\">");
328 builder.append("\n <navLabel>");
329 builder.append("\n <text>Title Page</text>");
330 builder.append("\n </navLabel>");
ecfb936e 331 builder.append("\n <content src=\"title.xhtml\"/>");
08fe2e33
NR
332 builder.append("\n </navPoint>");
333
334 int navPoint = 2; // 1 is above
335
336 if (story.getMeta() != null & story.getMeta().getResume() != null) {
337 Chapter chap = story.getMeta().getResume();
338 generateNcx(chap, builder, navPoint++);
339 }
340
341 for (Chapter chap : story) {
342 generateNcx(chap, builder, navPoint++);
343 }
344
345 builder.append("\n </navMap>");
346 builder.append("\n</ncx>\n");
347
348 return builder.toString();
349 }
350
351 private void generateNcx(Chapter chap, StringBuilder builder, int navPoint) {
352 String name;
353 if (chap.getName() != null && !chap.getName().isEmpty()) {
354 name = Instance.getTrans().getString(StringId.CHAPTER_NAMED,
355 chap.getNumber(), chap.getName());
356 } else {
357 name = Instance.getTrans().getString(StringId.CHAPTER_UNNAMED,
358 chap.getNumber());
359 }
360
361 String nnn = String.format("%03d", (navPoint - 2));
362
363 builder.append("\n <navPoint id=\"navpoint-" + navPoint
364 + "\" playOrder=\"" + navPoint + "\">");
365 builder.append("\n <navLabel>");
366 builder.append("\n <text>" + name + "</text>");
367 builder.append("\n </navLabel>");
ecfb936e 368 builder.append("\n <content src=\"chapter-" + nnn + ".xhtml\"/>");
08fe2e33
NR
369 builder.append("\n </navPoint>\n");
370 }
371
372 private String generateOpf(Story story) {
373 StringBuilder builder = new StringBuilder();
374
375 String title = "";
376 String uuid = "";
377 String author = "";
378 String date = "";
379 String publisher = "";
380 String subject = "";
381 String source = "";
382 String lang = "";
383 if (story.getMeta() != null) {
384 MetaData meta = story.getMeta();
385 title = meta.getTitle();
386 uuid = meta.getUuid();
387 author = meta.getAuthor();
388 date = meta.getDate();
389 publisher = meta.getPublisher();
390 subject = meta.getSubject();
391 source = meta.getSource();
392 lang = meta.getLang();
393 }
394
ecfb936e
NR
395 builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
396 builder.append("\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"BookId\" version=\"2.0\">");
08fe2e33
NR
397 builder.append("\n <metadata xmlns:opf=\"http://www.idpf.org/2007/opf\"");
398 builder.append("\n xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
399 builder.append("\n <dc:title>" + StringUtils.xmlEscape(title)
400 + "</dc:title>");
401 builder.append("\n <dc:creator opf:role=\"aut\" opf:file-as=\""
402 + StringUtils.xmlEscapeQuote(author) + "\">"
403 + StringUtils.xmlEscape(author) + "</dc:creator>");
404 builder.append("\n <dc:date opf:event=\"original-publication\">"
405 + StringUtils.xmlEscape(date) + "</dc:date>");
406 builder.append("\n <dc:publisher>"
407 + StringUtils.xmlEscape(publisher) + "</dc:publisher>");
408 builder.append("\n <dc:date opf:event=\"epub-publication\"></dc:date>");
409 builder.append("\n <dc:subject>" + StringUtils.xmlEscape(subject)
410 + "</dc:subject>");
411 builder.append("\n <dc:source>" + StringUtils.xmlEscape(source)
412 + "</dc:source>");
413 builder.append("\n <dc:rights>Not for commercial use.</dc:rights>");
ecfb936e 414 builder.append("\n <dc:identifier id=\"BookId\" opf:scheme=\"URI\">"
08fe2e33
NR
415 + StringUtils.xmlEscape(uuid) + "</dc:identifier>");
416 builder.append("\n <dc:language>" + StringUtils.xmlEscape(lang)
417 + "</dc:language>");
418 builder.append("\n </metadata>");
419 builder.append("\n <manifest>");
420 builder.append("\n <!-- Content Documents -->");
ecfb936e 421 builder.append("\n <item id=\"titlepage\" href=\"title.xhtml\" media-type=\"application/xhtml+xml\"/>");
08fe2e33
NR
422 for (int i = 0; i <= story.getChapters().size(); i++) {
423 String name = String.format("%s%03d", "chapter-", i);
424 builder.append("\n <item id=\""
425 + StringUtils.xmlEscapeQuote(name) + "\" href=\""
426 + StringUtils.xmlEscapeQuote(name)
ecfb936e 427 + ".xhtml\" media-type=\"application/xhtml+xml\"/>");
08fe2e33
NR
428 }
429
430 builder.append("\n <!-- CSS Style Sheets -->");
431 builder.append("\n <item id=\"style-css\" href=\"css/style.css\" media-type=\"text/css\"/>");
432
433 builder.append("\n <!-- Images -->");
434
435 if (story.getMeta() != null && story.getMeta().getCover() != null) {
436 String format = Instance.getConfig()
437 .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
438 builder.append("\n <item id=\"cover\" href=\"images/cover."
439 + format + "\" media-type=\"image/png\"/>");
440 }
441
442 builder.append("\n <!-- NCX -->");
443 builder.append("\n <item id=\"ncx\" href=\"epb.ncx\" media-type=\"application/x-dtbncx+xml\"/>");
444 builder.append("\n </manifest>");
445 builder.append("\n <spine toc=\"ncx\">");
446 builder.append("\n <itemref idref=\"titlepage\" linear=\"yes\"/>");
447 for (int i = 0; i <= story.getChapters().size(); i++) {
448 String name = String.format("%s%03d", "chapter-", i);
449 builder.append("\n <itemref idref=\""
450 + StringUtils.xmlEscapeQuote(name) + "\" linear=\"yes\"/>");
451 }
452 builder.append("\n </spine>");
453 builder.append("\n</package>\n");
454
455 return builder.toString();
456 }
457
458 private String generateTitleXml(Story story) {
459 StringBuilder builder = new StringBuilder();
460
461 String title = "";
462 String tags = "";
463 String author = "";
464 if (story.getMeta() != null) {
465 MetaData meta = story.getMeta();
466 title = meta.getTitle();
467 if (meta.getTags() != null) {
468 for (String tag : meta.getTags()) {
469 if (!tags.isEmpty()) {
470 tags += ", ";
471 }
472 tags += tag;
473 }
474
475 if (!tags.isEmpty()) {
476 tags = "(" + tags + ")";
477 }
478 }
479 author = meta.getAuthor();
480 }
481
482 String format = Instance.getConfig()
483 .getString(Config.IMAGE_FORMAT_COVER).toLowerCase();
484
ecfb936e
NR
485 builder.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
486 builder.append("\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">");
487 builder.append("\n<html xmlns=\"http://www.w3.org/1999/xhtml\">");
08fe2e33
NR
488 builder.append("\n<head>");
489 builder.append("\n <title>" + StringUtils.xmlEscape(title) + "</title>");
490 builder.append("\n <link rel=\"stylesheet\" href=\"css/style.css\" type=\"text/css\"/>");
491 builder.append("\n</head>");
492 builder.append("\n<body>");
493 builder.append("\n <div class=\"titlepage\">");
494 builder.append("\n <h1>" + StringUtils.xmlEscape(title) + "</h1>");
495 builder.append("\n <div class=\"type\">"
496 + StringUtils.xmlEscape(tags) + "</div>");
497 builder.append("\n <div class=\"cover\">");
ecfb936e
NR
498 builder.append("\n <img alt=\"cover image\" src=\"images/cover."
499 + format + "\"></img>");
08fe2e33
NR
500 builder.append("\n </div>");
501 builder.append("\n <div class=\"author\">"
502 + StringUtils.xmlEscape(author) + "</div>");
503 builder.append("\n </div>");
504 builder.append("\n</body>");
505 builder.append("\n</html>\n");
506
507 return builder.toString();
508 }
509}