From 32e23a9b23ef248d1848e0bc5fa180ddf0521836 Mon Sep 17 00:00:00 2001 From: XLor Date: Mon, 27 Feb 2023 00:20:10 +0800 Subject: [PATCH] feat: support bundle simple epub --- .gitignore | 1 + packages/core/src/bundle/index.ts | 25 ++- packages/core/src/{bundle => }/constant.ts | 7 +- packages/core/src/epub/epub.ts | 9 +- packages/core/src/epub/index.ts | 2 + packages/core/src/epub/item.ts | 194 +++++++++++++++++++++ packages/core/src/epub/opf.ts | 105 ++--------- packages/core/test/bundle.test.ts | 52 +++++- 8 files changed, 285 insertions(+), 110 deletions(-) rename packages/core/src/{bundle => }/constant.ts (75%) create mode 100644 packages/core/src/epub/item.ts diff --git a/.gitignore b/.gitignore index 83aa5bf..be41522 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules # Build output dist .turbo +.output # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore # Logs diff --git a/packages/core/src/bundle/index.ts b/packages/core/src/bundle/index.ts index fdede21..2b480a1 100644 --- a/packages/core/src/bundle/index.ts +++ b/packages/core/src/bundle/index.ts @@ -1,13 +1,12 @@ import * as fflate from 'fflate'; +import * as path from 'node:path'; import { XMLBuilder } from 'fast-xml-parser'; import type { Epubook, ManifestItem, ManifestItemRef, PackageDocument } from '../epub'; import { BundleError } from '../error'; -import { MIMETYPE } from './constant'; - -export * from './constant'; +import { MIMETYPE } from '../constant'; /** * Bundle epub to zip archive @@ -16,17 +15,31 @@ export * from './constant'; * @returns */ export async function bundle(epub: Epubook): Promise { - return new Promise((res, rej) => { + return new Promise(async (res, rej) => { const opfs = epub .packageDocuments() .map((opf) => [opf.filename(), fflate.strToU8(makePackageDocument(opf))] as const); + const items: Record = {}; + for (const opf of epub.packageDocuments()) { + const base = path.dirname(opf.filename()); + for (const item of opf.items()) { + const name = path.join(base, item.filename()); + if (name in items) { + continue; + } + // TODO: parallel here + items[name] = await item.bundle(); + } + } + const abstractContainer: fflate.AsyncZippable = { mimetype: fflate.strToU8(MIMETYPE), 'META-INF': { 'container.xml': fflate.strToU8(makeContainer(epub)) }, - ...Object.fromEntries(opfs) + ...Object.fromEntries(opfs), + ...items }; fflate.zip( @@ -166,6 +179,8 @@ export function makePackageDocument(opf: PackageDocument): string { return builder.build({ '?xml': { '#text': '', '@_version': '1.0', '@_encoding': 'UTF-8' }, package: { + '@_xmlns': 'http://www.idpf.org/2007/opf', + '@_xmlns:epub': 'http://www.idpf.org/2007/ops', '@_unique-identifier': opf.uniqueIdentifier(), '@_version': opf.version(), metadata, diff --git a/packages/core/src/bundle/constant.ts b/packages/core/src/constant.ts similarity index 75% rename from packages/core/src/bundle/constant.ts rename to packages/core/src/constant.ts index d062b24..3889147 100644 --- a/packages/core/src/bundle/constant.ts +++ b/packages/core/src/constant.ts @@ -6,7 +6,12 @@ export const ImagePng = 'image/png'; export const ImageSvg = 'image/svg+xml'; export const ImageWebp = 'image/webp'; -export type ImageMediaType = typeof ImageGif | typeof ImageJpeg | typeof ImagePng; +export type ImageMediaType = + | typeof ImageGif + | typeof ImageJpeg + | typeof ImagePng + | typeof ImageSvg + | typeof ImageWebp; export const TextCSS = 'text/css'; diff --git a/packages/core/src/epub/epub.ts b/packages/core/src/epub/epub.ts index 6aacbb0..87d969f 100644 --- a/packages/core/src/epub/epub.ts +++ b/packages/core/src/epub/epub.ts @@ -1,4 +1,5 @@ -import { type PathLike, promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { existsSync, mkdirSync, promises as fs } from 'node:fs'; import { PackageDocument } from './opf'; @@ -27,8 +28,12 @@ export class Epubook { return await bundle(this); } - async writeFile(file: PathLike) { + async writeFile(file: string) { const buffer = await this.bundle(); + const dir = path.dirname(file); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } await fs.writeFile(file, buffer); } } diff --git a/packages/core/src/epub/index.ts b/packages/core/src/epub/index.ts index 598ca7a..e8feb8d 100644 --- a/packages/core/src/epub/index.ts +++ b/packages/core/src/epub/index.ts @@ -1,3 +1,5 @@ export * from './epub'; export * from './opf'; + +export * from './item'; diff --git a/packages/core/src/epub/item.ts b/packages/core/src/epub/item.ts new file mode 100644 index 0000000..b33d215 --- /dev/null +++ b/packages/core/src/epub/item.ts @@ -0,0 +1,194 @@ +import * as path from 'node:path'; +import { type PathLike, promises as fs } from 'node:fs'; + +import { strToU8 } from 'fflate'; + +import { + type MediaType, + type ImageMediaType, + XHTML, + ImageJpeg, + ImagePng, + ImageWebp +} from '../constant'; + +export class ManifestItem { + private _href: string; + + private _id: string; + + private optional: { + fallback?: string; + mediaOverlay?: string; + mediaType?: MediaType; + properties?: string; + } = {}; + + constructor(href: string, id: string) { + this._href = href; + this._id = id; + } + + public update(info: typeof this.optional) { + for (const [key, value] of Object.entries(info)) { + if (!!value) { + this.optional[key as keyof typeof this.optional] = value as any; + } + } + return this; + } + + public href() { + return this._href; + } + + public id() { + return this._id; + } + + public fallback() { + return this.optional.fallback; + } + + public mediaOverlay() { + return this.optional.mediaOverlay; + } + + public mediaType() { + return this.optional.mediaType; + } + + public properties() { + return this.optional.properties; + } + + public ref() { + return new ManifestItemRef(this._id); + } +} + +export class ManifestItemRef { + private _idref: string; + + private optional: { + id?: string; + + linear?: string; + + properties?: string; + } = {}; + + constructor(idref: string) { + this._idref = idref; + } + + public update(info: typeof this.optional) { + for (const [key, value] of Object.entries(info)) { + if (!!value) { + this.optional[key as keyof typeof this.optional] = value as any; + } + } + return this; + } + + public idref() { + return this._idref; + } + + public id() { + return this.optional.id; + } + + public linear() { + return this.optional.linear; + } + + public properties() { + return this.optional.properties; + } +} + +export abstract class Item { + private readonly file: string; + + private readonly mediaType: MediaType; + + constructor(file: string, mediaType: MediaType) { + this.file = file; + this.mediaType = mediaType; + } + + public filename() { + return this.file; + } + + public id() { + return this.file.replace(/\/|\\/g, '_').replace(/\.[\w]+$/, ''); + } + + public manifest() { + return new ManifestItem(this.file, this.id()).update({ mediaType: this.mediaType }); + } + + public itemref() { + return this.manifest().ref(); + } + + abstract bundle(): Promise; +} + +export class Html extends Item { + private content: string; + + constructor(file: string, content: string) { + super(file, XHTML); + this.content = content; + } + + static async read(src: PathLike, dst: string) { + // TODO: check src extension + const content = await fs.readFile(src, 'utf-8'); + return new Html(dst, content); + } + + async bundle(): Promise { + // TODO: check encode format + return strToU8(this.content); + } +} + +export class Image extends Item { + private data: Uint8Array; + + constructor(file: string, type: ImageMediaType, data: Uint8Array) { + super(file, type); + this.data = data; + } + + static async read(src: string, dst: string) { + const content = await fs.readFile(src); + const media = this.getExtMediaType(src); + if (media) { + return new Image(dst, media, content); + } else { + return undefined; + } + } + + async bundle(): Promise { + return this.data; + } + + private static getExtMediaType(file: string): ImageMediaType | undefined { + const ext = path.extname(file); + if (ext === 'jpg' || ext === 'jpeg') { + return ImageJpeg; + } else if (ext === 'png') { + return ImagePng; + } else if (ext === 'webp') { + return ImageWebp; + } else { + return undefined; + } + } +} diff --git a/packages/core/src/epub/opf.ts b/packages/core/src/epub/opf.ts index 2faf44d..df8f5b0 100644 --- a/packages/core/src/epub/opf.ts +++ b/packages/core/src/epub/opf.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { createDefu } from 'defu'; -import type { MediaType } from '../bundle/constant'; +import { Item, ManifestItem, ManifestItemRef } from './item'; const defu = createDefu((obj: any, key, value: any) => { if (obj[key] instanceof Date && value instanceof Date) { @@ -38,6 +38,8 @@ export class PackageDocument { lastModified: new Date() }; + private _items: Item[] = []; + private _manifest: ManifestItem[] = []; private _spine: ManifestItemRef[] = []; @@ -78,6 +80,15 @@ export class PackageDocument { } // --- manifest --- + public addItem(item: Item) { + this._items.push(item); + this._manifest.push(item.manifest()); + } + + public items() { + return this._items; + } + public manifest() { return this._manifest; } @@ -100,95 +111,3 @@ export class PackageDocument { this._uniqueIdentifier = uniqueIdentifier; } } - -export class ManifestItem { - private _href: string; - - private _id: string; - - private optional: { - fallback?: string; - mediaOverlay?: string; - mediaType?: MediaType; - properties?: string; - } = {}; - - constructor(href: string, id: string) { - this._href = href; - this._id = id; - } - - public update(info: typeof this.optional) { - for (const [key, value] of Object.entries(info)) { - if (!!value) { - this.optional[key as keyof typeof this.optional] = value as any; - } - } - return this; - } - - public href() { - return this._href; - } - - public id() { - return this._id; - } - - public fallback() { - return this.optional.fallback; - } - - public mediaOverlay() { - return this.optional.mediaOverlay; - } - - public mediaType() { - return this.optional.mediaType; - } - - public properties() { - return this.optional.properties; - } -} - -export class ManifestItemRef { - private _idref: string; - - private optional: { - id?: string; - - linear?: string; - - properties?: string; - } = {}; - - constructor(idref: string) { - this._idref = idref; - } - - public update(info: typeof this.optional) { - for (const [key, value] of Object.entries(info)) { - if (!!value) { - this.optional[key as keyof typeof this.optional] = value as any; - } - } - return this; - } - - public idref() { - return this._idref; - } - - public id() { - return this.optional.id; - } - - public linear() { - return this.optional.linear; - } - - public properties() { - return this.optional.properties; - } -} diff --git a/packages/core/test/bundle.test.ts b/packages/core/test/bundle.test.ts index 51ac00b..7ac2262 100644 --- a/packages/core/test/bundle.test.ts +++ b/packages/core/test/bundle.test.ts @@ -1,3 +1,4 @@ +import { Html } from './../src/epub/item'; import { describe, it, expect } from 'vitest'; import { Epubook, ManifestItem, ManifestItemRef } from '../src'; @@ -31,17 +32,14 @@ describe('Bundle Epub', () => { source: 'imagine' }); - opf - .manifest() - .push( - new ManifestItem('Text/cover.xhtml', 'cover.xhtml').update({ properties: 'cover-image' }) - ); - opf.spine().push(new ManifestItemRef('cover.xhtml')); + const cover = new Html('cover.xhtml', ''); + opf.addItem(cover); + opf.spine().push(cover.manifest().ref()); const res = makePackageDocument(opf); expect(res).toMatchInlineSnapshot(` " - + test-book-id Test Book @@ -54,13 +52,49 @@ describe('Bundle Epub', () => { XLor - + - + " `); }); + + it('write epub', async () => { + const epub = new Epubook(); + + const opf = epub.mainPackageDocument(); + opf.setIdentifier('test-book-id', 'BookId'); + opf.update({ + title: 'Test Book', + date: new Date('2023-02-01T11:00:00.000Z'), + lastModified: new Date('2023-02-26T11:00:00.000Z'), + creator: 'XLor', + description: 'for test usage', + source: 'imagine' + }); + + const content = ` + + Data URL does not open in top-level context + + + +

Data URL does not open in top-level context

+

The following jpeg is contained within a data: URL, which is used as the src attribute for an img element.

+

The test passes if you are able to see the image below inside this ebook.

+ +`; + const cover = new Html('cover.xhtml', content); + opf.addItem(cover); + opf.spine().push(cover.itemref()); + + await epub.writeFile('.output/test.epub'); + }); });