From 625b7f2c507ea75de6867059a62588b8f9a9d328 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Tue, 10 Dec 2024 23:26:10 +0100 Subject: [PATCH] epub: DOMExporter => HTMLExporterConverter --- fiduswriter/book/static/css/book.css | 30 +- .../modules/books/exporter/epub/dom_export.js | 293 --------- .../js/modules/books/exporter/epub/index.js | 571 ++++++++---------- .../modules/books/exporter/epub/templates.js | 418 ++++++------- .../js/modules/books/exporter/epub/tools.js | 223 ++----- .../js/modules/books/exporter/html/index.js | 3 +- 6 files changed, 493 insertions(+), 1045 deletions(-) delete mode 100644 fiduswriter/book/static/js/modules/books/exporter/epub/dom_export.js diff --git a/fiduswriter/book/static/css/book.css b/fiduswriter/book/static/css/book.css index fc773d2..be9d34a 100644 --- a/fiduswriter/book/static/css/book.css +++ b/fiduswriter/book/static/css/book.css @@ -167,50 +167,50 @@ table { text-decoration: underline; } -.article table[data-layout="fixed"] { +.user-contents table[data-layout="fixed"] { table-layout: fixed !important; } -.article table[data-layout="auto"] { +.user-contents table[data-layout="auto"] { table-layout: auto !important; } -.article-title { +.doc-title { font-size: 48px; } -.article .table-of-contents h1, -.article .table-of-contents h2, -.article .table-of-contents h3, -.article .table-of-contents h4, -.article .table-of-contents h5, -.article .table-of-contents h6 { +.user-contents .table-of-contents h1, +.user-contents .table-of-contents h2, +.user-contents .table-of-contents h3, +.user-contents .table-of-contents h4, +.user-contents .table-of-contents h5, +.user-contents .table-of-contents h6 { font-size: 1em; font-weight: normal; margin-bottom: unset; } -.article .table-of-contents h2 { +.user-contents .table-of-contents h2 { padding-left: 1em; } -.article .table-of-contents h3 { +.user-contents .table-of-contents h3 { padding-left: 2em; } -.article .table-of-contents h4 { +.user-contents .table-of-contents h4 { padding-left: 3em; } -.article .table-of-contents h5 { +.user-contents .table-of-contents h5 { padding-left: 4em; } -.article .table-of-contents h6 { +.user-contents .table-of-contents h6 { padding-left: 5em; } -.article .table-of-contents h1.toc { +.user-contents .table-of-contents h1.toc { font-style: italic; } diff --git a/fiduswriter/book/static/js/modules/books/exporter/epub/dom_export.js b/fiduswriter/book/static/js/modules/books/exporter/epub/dom_export.js deleted file mode 100644 index 0098ce5..0000000 --- a/fiduswriter/book/static/js/modules/books/exporter/epub/dom_export.js +++ /dev/null @@ -1,293 +0,0 @@ -import {DOMSerializer} from "prosemirror-model" -import {RenderCitations} from "../../../citations/render" -import {get} from "../../../common" -import {BIBLIOGRAPHY_HEADERS, CATS} from "../../../schema/i18n" - -/* - -WARNING: DEPRECATED! - -Base exporter class for dom-based exports. This is the deprecated way of creating exports. -The epub book export filter goes over a DOM of a document which they change little -by little, and it is based on the DOMExporter class. - - New exporters should instead by walking the doc.content tree. - This is how all document exporters work, including the new EPUB exporter. -*/ - -export class DOMExporter { - constructor(schema, csl, documentStyles) { - this.schema = schema - this.csl = csl - this.documentStyles = documentStyles - - this.fontFiles = [] - this.binaryFiles = [] - this.styleSheets = [{url: staticUrl("css/document.css")}] - } - - addDocStyle(doc) { - const docStyle = this.documentStyles.find( - docStyle => docStyle.slug === doc.settings.documentstyle - ) - - // The files will be in the base directory. The filenames of - // DocumentStyleFiles will therefore not need to replaced with their URLs. - if (!docStyle) { - return - } - let contents = docStyle.contents - docStyle.documentstylefile_set.forEach( - ([_url, filename]) => - (contents = contents.replace( - new RegExp(filename, "g"), - `media/${filename}` - )) - ) - this.styleSheets.push({contents, filename: `css/${docStyle.slug}.css`}) - this.fontFiles = this.fontFiles.concat( - docStyle.documentstylefile_set.map(([url, filename]) => ({ - filename: `css/media/${filename}`, - url - })) - ) - } - - loadStyles() { - const p = [] - this.styleSheets.forEach(sheet => { - if (sheet.url) { - p.push( - get(sheet.url) - .then(response => response.text()) - .then(response => { - sheet.contents = response - sheet.filename = `css/${sheet.url.split("/").pop().split("?")[0]}` - delete sheet.url - }) - ) - } - }) - return Promise.all(p) - } - - joinDocumentParts() { - this.schema.cached.imageDB = this.imageDB - const serializer = DOMSerializer.fromSchema(this.schema) - this.content = serializer.serializeNode( - this.schema.nodeFromJSON(this.docContent) - ) - - this.addFootnotes() - const bibliographyHeader = - this.doc.settings.bibliography_header[this.doc.settings.language] || - BIBLIOGRAPHY_HEADERS[this.doc.settings.language] - const citRenderer = new RenderCitations( - this.content, - this.doc.settings.citationstyle, - bibliographyHeader, - this.bibDB, - this.csl - ) - return citRenderer.init().then(() => { - this.addBibliographyHTML(citRenderer.fm.bibHTML) - this.cleanHTML(citRenderer.fm) - this.addCategoryLabels(this.doc.settings.language) - return Promise.resolve() - }) - } - - addCategoryLabels(language) { - this.content - .querySelectorAll("figcaption span.label,caption span.label") - .forEach(el => { - const category = el.parentElement.parentElement.dataset.category - el.innerHTML = - category === "none" ? "" : CATS[category][language] - }) - } - - addBibliographyHTML(bibliographyHTML) { - if (bibliographyHTML.length > 0) { - const tempNode = document.createElement("div") - tempNode.innerHTML = bibliographyHTML - while (tempNode.firstChild) { - const footnotesContainer = - this.content.querySelector("section.fnlist") - this.content.insertBefore( - tempNode.firstChild, - footnotesContainer - ) - } - } - } - - replaceImgSrc(htmlString) { - htmlString = htmlString.replace( - /<(img|IMG) data-src([^>]+)>/gm, - "<$1 src$2>" - ) - return htmlString - } - // Replace all instances of the before string in all descendant textnodes of - // node. - replaceText(node, before, after) { - if (node.nodeType === 1) { - ;[].forEach.call(node.childNodes, child => - this.replaceText(child, before, after) - ) - } else if (node.nodeType === 3) { - node.textContent = node.textContent.replace( - new window.RegExp(before, "g"), - after - ) - } - } - - cleanNode(node) { - if (node.contentEditable === "true") { - node.removeAttribute("contentEditable") - } - if (node.children) { - Array.from(node.children).forEach(childNode => - this.cleanNode(childNode) - ) - } - } - - getFootnoteAnchor(counter) { - const footnoteAnchor = document.createElement("a") - footnoteAnchor.setAttribute("href", "#fn" + counter) - // RASH 0.5 doesn't mark the footnote anchors, so we add this class - footnoteAnchor.classList.add("fn") - return footnoteAnchor - } - - addFootnotes() { - // Replace the footnote markers with anchors and put footnotes with contents - // at the back of the document. - // Also, link the footnote anchor with the footnote according to - // https://rawgit.com/essepuntato/rash/master/documentation/index.html#footnotes. - const footnotes = this.content.querySelectorAll(".footnote-marker") - const footnotesContainer = document.createElement("section") - footnotesContainer.classList.add("fnlist") - footnotesContainer.setAttribute("role", "doc-footnotes") - - footnotes.forEach((footnote, index) => { - const counter = index + 1 - const footnoteAnchor = this.getFootnoteAnchor(counter) - footnote.parentNode.replaceChild(footnoteAnchor, footnote) - const newFootnote = document.createElement("section") - newFootnote.id = "fn" + counter - newFootnote.setAttribute("role", "doc-footnote") - newFootnote.innerHTML = footnote.dataset.footnote - footnotesContainer.appendChild(newFootnote) - }) - this.content.appendChild(footnotesContainer) - } - - moveCitationFootnotes(citationFormatter) { - const footnotes = this.content.querySelectorAll("a.fn, .citation") - const footnotesContainer = this.content.querySelector("section.fnlist") - const fnCountOffset = this.content.querySelectorAll("a.fn").length - - if (footnotes.length === fnCountOffset) { - // There are no citations to move - return - } - - let counter = 0 - - footnotes.forEach((footnote, index) => { - if (footnote.matches("a.fn")) { - // Regular footnote - skip - return - } - if (footnote.matches("section.fnlist .citation")) { - // The citation is already in a footnote. Do not add a second footnote. - footnote.innerHTML = - citationFormatter.citationTexts[counter] || " " - counter += 1 - return - } - const id = fnCountOffset + counter + 1 - const footnoteAnchor = this.getFootnoteAnchor(id) - footnote.parentNode.replaceChild(footnoteAnchor, footnote) - const newFootnote = document.createElement("section") - newFootnote.id = "fn" + id - newFootnote.setAttribute("role", "doc-footnote") - newFootnote.innerHTML = `

${ - citationFormatter.citationTexts[counter] || " " - }

` - footnotesContainer.insertBefore( - newFootnote, - footnotesContainer.childNodes[index] - ) - counter += 1 - }) - } - - cleanHTML(citationFormatter) { - if (citationFormatter.citationType === "note") { - this.moveCitationFootnotes(citationFormatter) - } - - this.cleanNode(this.content) - - // Replace nbsp spaces with normal ones - this.replaceText(this.content, " ", " ") - - this.content.querySelectorAll(".comment").forEach(el => { - el.insertAdjacentHTML("afterend", el.innerHTML) - el.parentElement.removeChild(el) - }) - - this.content.querySelectorAll(".citation").forEach(el => { - delete el.dataset.references - delete el.dataset.bibs - delete el.dataset.format - }) - this.content.querySelectorAll("img").forEach(el => { - delete el.dataset.image - delete el.dataset.imageSrc - }) - - this.content.querySelectorAll("table").forEach(el => { - delete el.dataset.captionHidden - }) - - this.content - .querySelectorAll( - "figcaption span.text:empty,caption span.text:empty" - ) - .forEach(el => { - el.parentElement.removeChild(el) - }) - - this.content.querySelectorAll(".cross-reference").forEach(el => { - el.innerHTML = `${el.innerHTML}` - }) - } - - // Fill the contents of table of contents. - fillToc() { - const headlines = Array.from( - this.content.querySelectorAll("h1,h2,h3,h4,h5,h6") - ) - const tocs = Array.from( - this.content.querySelectorAll("div.table-of-contents") - ) - tocs.forEach(toc => { - toc.innerHTML += headlines - .map(headline => { - if (!headline.id || !headline.textContent.length) { - // ignore the tocs own headlines - return "" - } - const tagName = headline.tagName.toLowerCase() - return `<${tagName}>${headline.innerHTML}` - }) - .join("") - }) - } -} diff --git a/fiduswriter/book/static/js/modules/books/exporter/epub/index.js b/fiduswriter/book/static/js/modules/books/exporter/epub/index.js index d4c06a0..fea7242 100644 --- a/fiduswriter/book/static/js/modules/books/exporter/epub/index.js +++ b/fiduswriter/book/static/js/modules/books/exporter/epub/index.js @@ -1,60 +1,49 @@ import download from "downloadjs" import pretty from "pretty" -import {DOMSerializer} from "prosemirror-model" - -import {getTimestamp} from "../../../exporter/epub/tools" -import {mathliveOpfIncludes} from "../../../mathlive/opf_includes" -import {BIBLIOGRAPHY_HEADERS} from "../../../schema/i18n" -import {bookTerm} from "../../i18n" +import {addAlert, get} from "../../../common" +import {HTMLExporterConvert} from "../../../exporter/html/convert" +import {htmlExportTemplate} from "../../../exporter/html/templates" +import {removeHidden} from "../../../exporter/tools/doc_content" +import {createSlug} from "../../../exporter/tools/file" +import {ZipFileCreator} from "../../../exporter/tools/zip" +import {LANGUAGES} from "../../../schema/const" import {getMissingChapterData} from "../tools" -import {DOMExporter} from "./dom_export" import { + containerTemplate, epubBookCopyrightTemplate, epubBookCoverTemplate, epubBookOpfTemplate, epubBookTitlepageTemplate, navTemplate, - ncxTemplate, - xhtmlTemplate + ncxTemplate } from "./templates" import { - addCategoryLabels, - modifyImages, - orderLinks, - setLinks, - styleEpubFootnotes, - uniqueObjects + buildHierarchy, + getFontMimeType, + getImageMimeType, + getTimestamp } from "./tools" -import {RenderCitations} from "../../../citations/render" -import {addAlert} from "../../../common" -import { - containerTemplate, - ncxItemTemplate -} from "../../../exporter/epub/templates" -import {removeHidden} from "../../../exporter/tools/doc_content" -import {createSlug} from "../../../exporter/tools/file" -import {node2Obj, obj2Node} from "../../../exporter/tools/json" -import {ZipFileCreator} from "../../../exporter/tools/zip" - -export class EpubBookExporter extends DOMExporter { +export class EpubBookExporter { constructor(schema, csl, bookStyles, book, user, docList, updated) { - super(schema, csl, bookStyles) + this.schema = schema + this.csl = csl + this.bookStyles = bookStyles this.book = book this.user = user this.docList = docList this.updated = updated - - this.chapters = [] + this.textFiles = [] this.images = [] - this.outputList = [] + this.fontFiles = [] + this.styleSheets = [{url: staticUrl("css/book.css")}] + this.chapters = [] + this.contentItems = [] this.includeZips = [] this.math = false - this.coverImage = false - this.contentItems = [] } - init() { + async init() { if (this.book.chapters.length === 0) { addAlert( "error", @@ -62,28 +51,29 @@ export class EpubBookExporter extends DOMExporter { ) return false } - return getMissingChapterData(this.book, this.docList, this.schema).then( - () => this.exportOne() - ) + + await getMissingChapterData(this.book, this.docList, this.schema) + + this.addBookStyle() + await this.exportContents() + return true } addBookStyle() { - const bookStyle = this.documentStyles.find( - bookStyle => bookStyle.slug === this.book.settings.book_style + const bookStyle = this.bookStyles.find( + style => style.slug === this.book.settings.book_style ) if (!bookStyle) { return false } - // The files will be in the base directory. The filenames of - // BookStyleFiles will therefore not need to replaced with their URLs. + let contents = bookStyle.contents - bookStyle.bookstylefile_set.forEach( - ([_url, filename]) => - (contents = contents.replace( - new RegExp(filename, "g"), - `media/${filename}` - )) - ) + bookStyle.bookstylefile_set.forEach(([_url, filename]) => { + contents = contents.replace( + new RegExp(filename, "g"), + `media/${filename}` + ) + }) this.styleSheets.push({contents, filename: `css/${bookStyle.slug}.css`}) this.fontFiles = this.fontFiles.concat( @@ -92,328 +82,277 @@ export class EpubBookExporter extends DOMExporter { url })) ) - return `css/${bookStyle.slug}.css` } - addFootnotes(contentsEl) { - // Replace the footnote markers with anchors and put footnotes with contents - // at the back of the document. - // Also, link the footnote anchor with the footnote according to - // https://rawgit.com/essepuntato/rash/master/documentation/index.html#footnotes. - const footnotes = contentsEl.querySelectorAll(".footnote-marker") - const footnotesContainer = document.createElement("section") - footnotesContainer.classList.add("fnlist") - footnotesContainer.setAttribute("role", "doc-footnotes") - - footnotes.forEach((footnote, index) => { - const counter = index + 1 - const footnoteAnchor = this.getFootnoteAnchor(counter) - footnote.parentNode.replaceChild(footnoteAnchor, footnote) - const newFootnote = document.createElement("section") - newFootnote.id = "fn" + counter - newFootnote.setAttribute("role", "doc-footnote") - newFootnote.innerHTML = footnote.dataset.footnote - footnotesContainer.appendChild(newFootnote) - }) - contentsEl.appendChild(footnotesContainer) - } + async exportContents() { + await Promise.all( + this.styleSheets.map(async sheet => await this.loadStyle(sheet)) + ) - exportOne() { - this.book.chapters.sort((a, b) => (a.number > b.number ? 1 : -1)) - const shortLang = this.book.settings.language.split("-")[0] + // Create cover if (this.book.cover_image) { - this.coverImage = this.book.cover_image_data + const coverImage = this.book.cover_image_data this.images.push({ - url: this.coverImage.image.split("?")[0], - filename: this.coverImage.image.split("/").pop().split("?")[0] + url: coverImage.image.split("?")[0], + filename: coverImage.image.split("/").pop().split("?")[0], + coverImage: true }) - this.outputList.push({ - filename: "EPUB/cover.xhtml", - contents: epubBookCoverTemplate({ - book: this.book, - coverImage: this.coverImage, - shortLang - }) - }) - this.contentItems.push({ - link: "cover.xhtml#cover", - title: bookTerm("Cover", this.book.settings.language), - docNum: 0, - id: 0, - level: 0, - subItems: [] + this.textFiles.push({ + filename: "cover.xhtml", + contents: pretty( + epubBookCoverTemplate({ + book: this.book, + coverImage, + shortLang: this.book.settings.language.split("-")[0] + }) + ) }) } - this.contentItems.push({ - link: "titlepage.xhtml#title", - title: bookTerm("Title page", this.book.settings.language), - docNum: 0, - id: 1, - level: 0, - subItems: [] - }) - let currentPart = false - this.chapters = this.book.chapters.map(chapter => { - const doc = this.docList.find(doc => doc.id === chapter.text), - schema = this.schema - schema.cached.imageDB = {db: doc.images} - const docContent = removeHidden(doc.content, false), - serializer = DOMSerializer.fromSchema(schema), - tempNode = serializer.serializeNode( - schema.nodeFromJSON(docContent) - ) - const contentsEl = document.createElement("body") - let math = false - while (tempNode.firstChild) { - contentsEl.appendChild(tempNode.firstChild) - } - this.addFootnotes(contentsEl) - - this.images = this.images.concat(modifyImages(contentsEl)) - addCategoryLabels(contentsEl, doc.settings.language) - const equations = contentsEl.querySelectorAll(".equation") - - const figureEquations = - contentsEl.querySelectorAll(".figure-equation") - - if (equations.length || figureEquations.length) { - math = true - this.math = true - } - if (chapter.part?.length) { - this.contentItems.push({ - link: `document-${chapter.number}.xhtml`, - title: chapter.part, - docNum: chapter.number, - id: 0, - level: -1, - subItems: [] + // Create title page + this.textFiles.push({ + filename: "titlepage.xhtml", + contents: pretty( + epubBookTitlepageTemplate({ + book: this.book, + shortLang: this.book.settings.language.split("-")[0] }) - currentPart = chapter.part - } - - // Make links to all H1-3 and create a TOC list of them - this.contentItems = this.contentItems.concat( - setLinks(contentsEl, chapter.number) ) - - return { - contents: contentsEl, - number: chapter.number, - currentPart, - part: chapter.part, - math, - doc - } - }) - const citRendererPromises = this.chapters.map(chapter => { - const bibliographyHeader = - chapter.doc.settings.bibliography_header[ - chapter.doc.settings.language - ] || BIBLIOGRAPHY_HEADERS[chapter.doc.settings.language] - // add bibliographies (asynchronously) - const citRenderer = new RenderCitations( - chapter.contents, - this.book.settings.citationstyle, - bibliographyHeader, - {db: chapter.doc.bibliography}, - this.csl - ) - return citRenderer.init().then(() => { - const bibHTML = citRenderer.fm.bibHTML - if (bibHTML.length > 0) { - chapter.contents.innerHTML += bibHTML - } - this.content = chapter.contents - this.cleanHTML(citRenderer.fm) - chapter.contents = this.content - delete this.content - return Promise.resolve() - }) }) - return Promise.all(citRendererPromises).then(() => this.exportTwo()) - } - exportTwo() { - const bookStyle = this.addBookStyle() - this.outputList = this.outputList.concat( - this.chapters.map(chapter => { - chapter.contents = styleEpubFootnotes(chapter.contents) - const styleSheets = [{filename: "css/document.css"}] - if (bookStyle) { - styleSheets.push({filename: bookStyle}) - } - let xhtmlCode = xhtmlTemplate({ - part: chapter.part, - currentPart: chapter.currentPart, - shortLang: chapter.doc.settings.language.split("-")[0], - title: chapter.doc.title, - metadata: chapter.doc.metadata, - settings: chapter.doc.settings, - styleSheets, - body: obj2Node(node2Obj(chapter.contents), "xhtml") - .innerHTML, - math: chapter.math + // Export chapters + this.chapters = await Promise.all( + this.book.chapters + .sort((a, b) => a.number - b.number) + .map(async chapter => { + const doc = this.docList.find( + doc => doc.id === chapter.text + ) + if (!doc) { + return false + } + + const docContent = removeHidden(doc.content) + + const converter = new HTMLExporterConvert( + doc.title, + doc.settings, + docContent, + htmlExportTemplate, + {db: doc.images}, + {db: doc.bibliography}, + this.csl, + this.styleSheets, + { + xhtml: true, + epub: true, + footnoteNumbering: "decimal", + affiliationNumbering: "alpha", + idPrefix: `c-${chapter.number}-` + } + ) + const {html, imageIds, metaData, extraStyleSheets} = + await converter.init() + + if (!html) { + return false + } + + imageIds.forEach(id => { + const image = doc.images[id] + this.images.push({ + filename: `images/${image.image.split("/").pop()}`, + url: image.image + }) + }) + + await Promise.all( + extraStyleSheets.map( + async sheet => await this.loadStyle(sheet) + ) + ) + + // Check for math + if (converter.features.math) { + this.math = true + } + + this.textFiles.push({ + filename: `document-${chapter.number}.xhtml`, + contents: pretty(html) + }) + + return { + number: chapter.number, + part: chapter.part, + title: doc.title, + docNum: chapter.number, + metaData + } }) - xhtmlCode = this.replaceImgSrc(xhtmlCode) - - return { - filename: `EPUB/document-${chapter.number}.xhtml`, - contents: pretty(xhtmlCode, {ocd: true}) - } - }) ) - return this.loadStyles().then(() => this.exportThree()) - } - - exportThree() { - const language = this.book.settings.language - this.contentItems.push({ - link: "copyright.xhtml#copyright", - title: bookTerm("Copyright", language), - docNum: 0, - id: 2, - level: 0, - subItems: [] - }) - this.contentItems = orderLinks(this.contentItems) + // Filter out any failed chapter exports + this.chapters = this.chapters.filter(chapter => chapter !== false) - const timestamp = getTimestamp(this.updated) - - this.images = uniqueObjects(this.images) - this.fontFiles = uniqueObjects(this.fontFiles) - this.styleSheets = uniqueObjects(this.styleSheets) - - // mark cover image - if (this.coverImage) { - this.images.find( - image => image.url === this.coverImage.image.split("?")[0] - ).coverImage = true - } - - const shortLang = language.split("-")[0] - const opfCode = epubBookOpfTemplate({ - language, - book: this.book, - idType: "fidus", - date: timestamp.slice(0, 10), - modified: timestamp, - styleSheets: this.styleSheets, - math: this.math, - images: this.images, - fontFiles: this.fontFiles, - chapters: this.chapters, - coverImage: this.coverImage, - mathliveOpfIncludes, - user: this.user + // Create copyright page + this.textFiles.push({ + filename: "copyright.xhtml", + contents: pretty( + epubBookCopyrightTemplate({ + book: this.book, + creator: this.user.name, + language: this.book.settings.language, + shortLang: this.book.settings.language.split("-")[0] + }) + ) }) - const ncxCode = ncxTemplate({ - shortLang, - title: this.book.title, - idType: "fidus", - id: this.book.id, - contentItems: this.contentItems, - templates: {ncxTemplate, ncxItemTemplate} - }) + // Create navigation + const contentItems = this.chapters.reduce((items, chapter) => { + if (chapter.part) { + items.push({ + title: chapter.part, + docNum: chapter.number, + link: `document-${chapter.number}.xhtml`, + level: -1 + }) + } + items = items.concat( + chapter.metaData.toc.map(item => ({ + ...item, + docNum: chapter.number, + link: `document-${chapter.number}.xhtml#c-${chapter.number}-${item.id}` + })) + ) + return items + }, []) - const navCode = navTemplate({ - shortLang, - contentItems: this.contentItems, - styleSheets: this.styleSheets - }) + const toc = buildHierarchy(contentItems) - this.outputList = this.outputList.concat([ + this.textFiles = this.textFiles.concat([ { filename: "META-INF/container.xml", - contents: pretty(containerTemplate({}), {ocd: true}) + contents: pretty(containerTemplate()) }, { - filename: "EPUB/document.opf", - contents: pretty(opfCode, {ocd: true}) - }, - { - filename: "EPUB/document.ncx", - contents: pretty(ncxCode, {ocd: true}) - }, - { - filename: "EPUB/document-nav.xhtml", - contents: pretty(navCode, {ocd: true}) + filename: "document.opf", + contents: pretty( + epubBookOpfTemplate({ + book: this.book, + language: this.book.settings.language, + idType: "fidus", + date: getTimestamp(new Date(this.updated * 1000)).slice( + 0, + 10 + ), + modified: getTimestamp(new Date(this.updated * 1000)), + styleSheets: this.styleSheets, + math: this.math, + images: this.images.map(image => ({ + ...image, + mimeType: getImageMimeType(image.filename) + })), + fontFiles: this.fontFiles.map(font => ({ + ...font, + mimeType: getFontMimeType(font.filename) + })), + chapters: this.chapters, + user: this.user + }) + ) }, { - filename: "EPUB/titlepage.xhtml", + filename: "document.ncx", contents: pretty( - epubBookTitlepageTemplate({ - book: this.book, - shortLang - }), - {ocd: true} + ncxTemplate({ + shortLang: this.book.settings.language.split("-")[0], + title: this.book.title, + idType: "fidus", + id: this.book.id, + toc + }) ) }, { - filename: "EPUB/copyright.xhtml", + filename: "document-nav.xhtml", contents: pretty( - epubBookCopyrightTemplate({ - book: this.book, - creator: this.user.name, - language, - shortLang - }), - {ocd: true} + navTemplate({ + shortLang: this.book.settings.language.split("-")[0], + toc, + styleSheets: this.styleSheets + }) ) } ]) - - this.outputList = this.outputList.concat( - this.styleSheets.map(sheet => ({ - filename: `EPUB/${sheet.filename}`, - contents: sheet.contents - })) - ) - - this.binaryFiles = this.binaryFiles.concat( - this.images.map(image => ({ - filename: `EPUB/${image.filename}`, - url: image.url - })) - ) - - this.fontFiles.forEach(font => { - this.binaryFiles.push({ - filename: `EPUB/${font.filename}`, - url: font.url - }) - }) - - this.binaryFiles = uniqueObjects(this.binaryFiles) - if (this.math) { this.includeZips.push({ - directory: "EPUB/css", + directory: "css", url: staticUrl("zip/mathlive_style.zip") }) } + + this.httpFiles = this.images.concat(this.fontFiles) + this.prefixFiles() return this.createZip() } - createZip() { + async loadStyle(sheet) { + const filename = + sheet.filename || `css/${sheet.url.split("/").pop().split("?")[0]}` + const existing = this.textFiles.find(file => file.filename === filename) + if (existing) { + // Already loaded + return Promise.resolve(existing) + } + if (sheet.url) { + const response = await get(sheet.url) + const text = await response.text() + sheet.contents = text + sheet.filename = filename + delete sheet.url + } + if (sheet.filename) { + this.textFiles.push(sheet) + } + return Promise.resolve(sheet) + } + + prefixFiles() { + // prefix almost all files with "EPUB/" + this.textFiles = this.textFiles.map(file => { + if ( + ["META-INF/container.xml", "mimetype"].includes(file.filename) + ) { + return file + } + return Object.assign({}, file, {filename: `EPUB/${file.filename}`}) + }) + this.httpFiles = this.httpFiles.map(file => + Object.assign({}, file, {filename: `EPUB/${file.filename}`}) + ) + this.includeZips = this.includeZips.map(file => + Object.assign({}, file, {directory: `EPUB/${file.directory}`}) + ) + } + + async createZip() { const zipper = new ZipFileCreator( - this.outputList, - this.binaryFiles, + this.textFiles, + this.httpFiles, this.includeZips, "application/epub+zip", this.updated ) - return zipper.init().then(blob => this.download(blob)) + const blob = await zipper.init() + return this.download(blob) } download(blob) { return download( blob, - createSlug(this.book.title) + ".epub", + `${createSlug(this.book.title)}.epub`, "application/epub+zip" ) } diff --git a/fiduswriter/book/static/js/modules/books/exporter/epub/templates.js b/fiduswriter/book/static/js/modules/books/exporter/epub/templates.js index 27d1964..154787a 100644 --- a/fiduswriter/book/static/js/modules/books/exporter/epub/templates.js +++ b/fiduswriter/book/static/js/modules/books/exporter/epub/templates.js @@ -1,52 +1,113 @@ import {escapeText, localizeDate} from "../../../common" -import {ncxItemTemplate} from "../../../exporter/epub/templates" +import {mathliveOpfIncludes} from "../../../mathlive/opf_includes" import {LANGUAGES} from "../../../schema/const" import {bookTerm} from "../../i18n" -/** A template for a document in an epub. */ -export const xhtmlTemplate = ({ +export const containerTemplate = () => ` + + + + +` + +export const epubBookCoverTemplate = ({book, coverImage, shortLang}) => + ` + + + ${escapeText(book.title)} + + + +
+ ${bookTerm( +
+ +` + +export const epubBookTitlepageTemplate = ({book, shortLang}) => + ` + + + ${escapeText(book.title)} + + + +
+

${escapeText(book.title)}

+ ${ + book.metadata.subtitle + ? `

${escapeText(book.metadata.subtitle)}

` + : "" + } + ${ + book.metadata.author + ? `

${bookTerm("by", book.settings.language)} ${escapeText(book.metadata.author)}

` + : "" + } + ${ + book.metadata.version + ? `

${escapeText(book.metadata.version)}

` + : "" + } +
+ +` + +export const epubBookCopyrightTemplate = ({ + book, + language, shortLang, - title, - math, - styleSheets, - part, - currentPart, - body, - copyright + creator }) => ` - + - ${copyright && copyright.holder ? `` : ""} - ${escapeText(title)} -${ - math - ? '\n' - : "" -} -${styleSheets - .map( - sheet => - `\n` - ) - .join("")} + ${escapeText(book.title)} + - ${ - part && part.length ? `

${escapeText(part)}

` : "" - }${body}${ - copyright && copyright.holder - ? `
© ${copyright.year ? copyright.year : new Date().getFullYear()} ${copyright.holder}
` - : "" - } - ${ - copyright && copyright.licenses.length - ? `
${copyright.licenses.map(license => `${escapeText(license.title)}${license.start ? ` (${license.start})` : ""}`).join("
")}
` - : "" - } + +
+ +
+ ` -/** A template to create the OPF file of book epubs. */ export const epubBookOpfTemplate = ({ book, language, @@ -58,40 +119,34 @@ export const epubBookOpfTemplate = ({ images, fontFiles, chapters, - coverImage, - mathliveOpfIncludes, user -}) => - ` - +}) => ` + ${book.id} ${escapeText(book.title)} - - ${ - book.metadata.author && book.metadata.author.length - ? escapeText(book.metadata.author) - : escapeText(user.name) - } - + ${ + book.metadata.author && book.metadata.author.length + ? escapeText(book.metadata.author) + : escapeText(user.name) + } ${language} ${modified} ${date} ${ - book.metadata.copyright && book.metadata.copyright.length + book.metadata.copyright ? `${escapeText(book.metadata.copyright)}` : "" } ${ - book.metadata.publisher && book.metadata.publisher.length - ? `${escapeText( - book.metadata.publisher - )}` + book.metadata.publisher + ? `${escapeText(book.metadata.publisher)}` : "" } ${ - book.metadata.keywords && book.metadata.keywords.length + book.metadata.keywords ? book.metadata.keywords .split(",") .map( @@ -104,7 +159,7 @@ export const epubBookOpfTemplate = ({ ${ - coverImage + book.cover_image ? '' : "" } @@ -113,11 +168,11 @@ export const epubBookOpfTemplate = ({ .map( chapter => `` + media-type="application/xhtml+xml" />` ) .join("")} + media-type="application/xhtml+xml" /> ${images .map( @@ -126,42 +181,28 @@ export const epubBookOpfTemplate = ({ image.coverImage ? 'id="cover-image" properties="cover-image"' : `id="img${index}"` - } href="${image.filename}" media-type="image/${ - image.filename.split(".")[1] === "png" - ? "png" - : image.filename.split(".")[1] === "svg" - ? "svg+xml" - : "jpeg" - }"/>` + } href="${image.filename}" media-type="${image.mimeType}"/>` ) .join("")} ${fontFiles .map( - (fontFile, index) => - `` + (font, index) => + `` ) .join("")} ${styleSheets .map( (sheet, index) => - `` + `` ) .join("")} ${math ? mathliveOpfIncludes : ""} - - ${coverImage ? '' : ""} + ${book.cover_image ? '' : ""} ${chapters .map( @@ -173,140 +214,48 @@ export const epubBookOpfTemplate = ({ ` -/** A template to create the book epub cover XML. */ -export const epubBookCoverTemplate = ({book, coverImage, shortLang}) => +export const navTemplate = ({shortLang, toc, styleSheets}) => ` - + - ${book.title} + Navigation + ${styleSheets + .map( + sheet => + `` + ) + .join("")} - -
- ${bookTerm(
-                        -
+ + ` -/** A template to create the book epub titlepage XML. */ -export const epubBookTitlepageTemplate = ({book, shortLang}) => - ` - - - ${escapeText(book.title)} - - - -
-

${escapeText(book.title)}

- ${ - book.metadata.subtitle.length - ? `

${escapeText( - book.metadata.subtitle - )}

` - : "" - } - ${ - book.metadata.version?.length - ? `

${escapeText( - book.metadata.version - )}

` - : "" - } - ${ - book.metadata.author.length - ? `

${bookTerm( - "by", - book.settings.language - )} ${escapeText(book.metadata.author)}

` - : "" - } -
- -` - -/** A template to create the book epub copyright page XML. */ -export const epubBookCopyrightTemplate = ({ - book, - language, - shortLang, - creator -}) => - ` - - - ${escapeText(book.title)} - - - -
- -
- -` +const renderTocItems = items => + items + .map( + item => ` +
  • + ${escapeText(item.title)} + ${ + item.children?.length + ? `
      ${renderTocItems(item.children)}
    ` + : "" + } +
  • +` + ) + .join("") -/** A template of the NCX file of an epub. */ -export const ncxTemplate = ({shortLang, idType, id, title, contentItems}) => +export const ncxTemplate = ({shortLang, idType, id, title, toc}) => ` - + @@ -314,48 +263,21 @@ export const ncxTemplate = ({shortLang, idType, id, title, contentItems}) => ${escapeText(title)} - -${contentItems.map(item => ncxItemTemplate({item})).join("")} + ${renderNcxItems(toc)} ` -/** A template for each item in an epub's navigation document. */ -const navItemTemplate = ({item}) => - `\t\t\t\t
  • ${escapeText(item.title)} -${ - item.subItems.length - ? `
      - ${item.subItems.map(item => navItemTemplate({item})).join("")} -
    ` - : "" -} -
  • ` - -/** A template for an epub's navigation document. */ -export const navTemplate = ({shortLang, contentItems, styleSheets}) => - ` - - - - Navigation - ${styleSheets - .map( - sheet => - `\n` - ) - .join("")} - - - - -` +const renderNcxItems = (items, counter = {count: 1}) => + items + .map( + item => ` + + + ${escapeText(item.title)} + + + ${item.children?.length ? renderNcxItems(item.children, counter) : ""} + +` + ) + .join("") diff --git a/fiduswriter/book/static/js/modules/books/exporter/epub/tools.js b/fiduswriter/book/static/js/modules/books/exporter/epub/tools.js index 6b6b809..b672435 100644 --- a/fiduswriter/book/static/js/modules/books/exporter/epub/tools.js +++ b/fiduswriter/book/static/js/modules/books/exporter/epub/tools.js @@ -1,186 +1,67 @@ -import {CATS} from "../../../schema/i18n" - -export function styleEpubFootnotes(htmlEl) { - // Converts RASH style footnotes into epub footnotes - const fnListEl = htmlEl.querySelector("section.fnlist") - if (!fnListEl) { - // There are no footnotes. - return htmlEl - } - fnListEl.setAttribute("role", "doc-endnotes") - const footnotes = fnListEl.querySelectorAll("section[role=doc-footnote]") - let footnoteCounter = 1 - footnotes.forEach(footnote => { - const newFootnote = document.createElement("aside") - newFootnote.setAttribute("epub:type", "footnote") - newFootnote.id = footnote.id - if (footnote.firstChild) { - while (footnote.firstChild) { - newFootnote.appendChild(footnote.firstChild) - } - newFootnote.firstChild.innerHTML = - footnoteCounter + " " + newFootnote.firstChild.innerHTML - } else { - newFootnote.innerHTML = "

    " + footnoteCounter + "

    " - } - - footnote.parentNode.replaceChild(newFootnote, footnote) - footnoteCounter++ - }) - const footnoteMarkers = htmlEl.querySelectorAll("a.fn") - let footnoteMarkerCounter = 1 - footnoteMarkers.forEach(fnMarker => { - const newFnMarker = document.createElement("sup") - const newFnMarkerLink = document.createElement("a") - newFnMarkerLink.setAttribute("epub:type", "noteref") - newFnMarkerLink.setAttribute("href", fnMarker.getAttribute("href")) - newFnMarkerLink.innerHTML = footnoteMarkerCounter - newFnMarker.appendChild(newFnMarkerLink) - fnMarker.parentNode.replaceChild(newFnMarker, fnMarker) - footnoteMarkerCounter++ - }) - - return htmlEl +export function getTimestamp(date) { + return date.toISOString().replace(/\.\d{3}/, "") } -export function setLinks(htmlEl, docNum = 0) { - const contentItems = [] - let title - let idCount = 0 - - htmlEl.querySelectorAll("div.doc-title,h1,h2,h3,h4,h5,h6").forEach(el => { - title = el.textContent.trim() - if (title !== "" || el.classList.contains("doc-title")) { - const contentItem = {} - contentItem.title = title - contentItem.level = el.classList.contains("doc-title") - ? 0 - : Number.parseInt(el.tagName.substring(1, 2)) - if (docNum) { - contentItem.docNum = docNum - } - if (!el.id) { - // The element has no ID, so we add one. - el.id = `_${docNum}_${idCount++}` - } - contentItem.id = el.id - contentItems.push(contentItem) - } - }) - return contentItems -} - -export function orderLinks(contentItems) { - for (let i = 0; i < contentItems.length; i++) { - contentItems[i].subItems = [] - if (i > 0) { - for (let j = i - 1; j > -1; j--) { - if (contentItems[j].level < contentItems[i].level) { - contentItems[j].subItems.push(contentItems[i]) - contentItems[i].delete = true - break - } - } - } - } - - for (let i = contentItems.length; i > -1; i--) { - if (contentItems[i]?.delete) { - delete contentItems[i].delete - contentItems.splice(i, 1) - } +export function getFontMimeType(filename) { + const fontMimeTypes = { + ttf: "font/ttf", + otf: "font/otf", + woff: "font/woff", + woff2: "font/woff2" } - return contentItems + const ext = filename.split(".").pop().toLowerCase() + return fontMimeTypes[ext] || null } -export function addCategoryLabels(htmlEl, language, footnote = false) { - // Due to lacking CSS support in ereaders, figure numbers need to be hardcoded. - htmlEl - .querySelectorAll( - "figure[data-category='figure'] figcaption span.label" - ) - .forEach((el, index) => { - const suffix = el.parentElement.innerText.trim().length ? ": " : "" - el.innerHTML = `${CATS["figure"][language]} ${index + 1}${footnote ? "A" : ""}${suffix}` - el.classList.remove("label") - }) - - htmlEl - .querySelectorAll( - "figure[data-category='equation'] figcaption span.label" - ) - .forEach((el, index) => { - const suffix = el.parentElement.innerText.trim().length ? ": " : "" - el.innerHTML = `${CATS["equation"][language]} ${index + 1}${footnote ? "A" : ""}${suffix}` - el.classList.remove("label") - }) - - htmlEl - .querySelectorAll("figure[data-category='photo'] figcaption span.label") - .forEach((el, index) => { - const suffix = el.parentElement.innerText.trim().length ? ": " : "" - el.innerHTML = `${CATS["photo"][language]} ${index + 1}${footnote ? "A" : ""}${suffix}` - el.classList.remove("label") - }) - - htmlEl - .querySelectorAll( - "figure[data-category='table'] figcaption span.label,table[data-category='table'] caption span.label" - ) - .forEach((el, index) => { - const suffix = el.parentElement.innerText.trim().length ? ": " : "" - el.innerHTML = `${CATS["table"][language]} ${index + 1}${footnote ? "A" : ""}${suffix}` - el.classList.remove("label") - }) - return htmlEl +export function getImageMimeType(filename) { + const imageMimeTypes = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + svg: "image/svg+xml" + } + const ext = filename.split(".").pop().toLowerCase() + return imageMimeTypes[ext] || null } -export const modifyImages = htmlEl => { - const imageLinks = htmlEl.querySelectorAll("img"), - images = [] - - imageLinks.forEach((el, index) => { - const src = el.getAttribute("src").split("?")[0] - let filename = `images/${src.split("/").pop()}` - // JPGs are output as PNG elements as well. - if (filename === "images/") { - // name was not retrievable so we give the image a unique numerical - // name like 1.png, 2.jpg, 3.svg, etc. . - filename = `images/${index}` - } - - const newImg = document.createElement("img") - // We set the src of the image as "data-src" for now so that the browser - // won't try to load the file immediately - newImg.setAttribute("data-src", filename) - el.parentNode.replaceChild(newImg, el) +export function buildHierarchy(flatItems) { + const root = [] + const idMap = {} - if (!images.find(image => image.filename === filename)) { - images.push({ - filename, - url: src - }) + // First pass - create all nodes + flatItems.forEach(item => { + idMap[item.id] = { + ...item, + children: [] } }) - return images -} - -export const uniqueObjects = array => { - const results = [] - - for (let i = 0; i < array.length; i++) { - let willCopy = true - for (let j = 0; j < i; j++) { - if (JSON.stringify(array[i]) === JSON.stringify(array[j])) { - willCopy = false - break + // Second pass - create hierarchy + flatItems.forEach(item => { + const node = idMap[item.id] + if (item.level === -1 || item.level === 0) { + root.push(node) + } else { + // Find the closest parent + let parentLevel = item.level - 1 + let parent + while (parentLevel >= 0 && !parent) { + parent = flatItems.find( + p => + p.level === parentLevel && + p.docNum === item.docNum && + p.id < item.id + ) + parentLevel-- + } + if (parent) { + idMap[parent.id].children.push(node) + } else { + root.push(node) } } - if (willCopy) { - results.push(array[i]) - } - } + }) - return results + return root } diff --git a/fiduswriter/book/static/js/modules/books/exporter/html/index.js b/fiduswriter/book/static/js/modules/books/exporter/html/index.js index 9e15785..1775895 100644 --- a/fiduswriter/book/static/js/modules/books/exporter/html/index.js +++ b/fiduswriter/book/static/js/modules/books/exporter/html/index.js @@ -143,7 +143,7 @@ export class HTMLBookExporter { ) await documentHTMLExporter.process() - const {metaData, converter, textFiles, httpFiles, includeZips} = + const {metaData, converter, textFiles, httpFiles} = documentHTMLExporter.getProcessedFiles() const contents = textFiles.find( @@ -152,7 +152,6 @@ export class HTMLBookExporter { if (!contents) { continue } - this.includeZips = this.includeZips.concat(includeZips) this.httpFiles = this.httpFiles.concat(httpFiles) // Update counters