Skip to content

Commit

Permalink
feat: generate opf metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
yjl9903 committed Feb 26, 2023
1 parent 1d40324 commit b813e8b
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 79 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ It will support the generation of the [latest epub standard](https://www.w3.org/
## Resources

+ [EPUB 3.3 specification](https://www.w3.org/TR/epub-33/)
+ [EbookLib](https://github.com/aerkalov/ebooklib)

## License

Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"defu": "^6.1.2",
"fast-xml-parser": "^4.1.3",
"fflate": "^0.7.4"
},
Expand Down
77 changes: 0 additions & 77 deletions packages/core/src/bundle.ts

This file was deleted.

156 changes: 156 additions & 0 deletions packages/core/src/bundle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import * as fflate from 'fflate';
import { XMLBuilder } from 'fast-xml-parser';

import type { Epubook, PackageDocument } from '../epub';
import { BundleError } from '../error';

const MIMETYPE = 'application/epub+zip';

/**
* Bundle epub to zip archive
*
* @param epub
* @returns
*/
export async function bundle(epub: Epubook): Promise<Uint8Array> {
return new Promise((res, rej) => {
const opfs = epub
.packageDocuments()
.map((opf) => [opf.filename(), fflate.strToU8(makePackageDocument(opf))] as const);

const abstractContainer: fflate.AsyncZippable = {
mimetype: fflate.strToU8(MIMETYPE),
'META-INF': {
'container.xml': fflate.strToU8(makeContainer(epub))
},
...Object.fromEntries(opfs)
};

fflate.zip(
abstractContainer,
{
level: 0,
mtime: new Date()
},
(err, data) => {
if (err) {
rej(err);
} else {
res(data);
}
}
);
});
}

/**
* Generate META-INF/container.xml
*
* See: https://www.w3.org/TR/epub-33/#sec-container-metainf-container.xml
*
* Example: https://www.w3.org/TR/epub-33/#sec-container-container.xml-example
*
* @param epub
* @returns xml string
*/
export function makeContainer(epub: Epubook): string {
const builder = new XMLBuilder({
format: true,
ignoreAttributes: false,
suppressUnpairedNode: false,
unpairedTags: ['rootfile']
});

const rootfile = epub.packageDocuments().map((p) => ({
'@_full-path': p.filename(),
'@_media-type': 'application/oebps-package+xml',
'#text': ''
}));

return builder.build({
'?xml': { '#text': '', '@_version': '1.0', '@_encoding': 'UTF-8' },
container: {
'@_version': '1.0',
'@_xmlns': 'urn:oasis:names:tc:opendocument:xmlns:container',
rootfiles: [
{
rootfile
}
]
}
});
}

/**
* Generate package document
*
* @param opf
* @returns
*/
export function makePackageDocument(opf: PackageDocument): string {
if (opf.version() !== '3.0') {
throw new BundleError(`Unsupport EPUB spec ${opf.version()}`);
}

const builder = new XMLBuilder({
format: true,
ignoreAttributes: false,
suppressUnpairedNode: false,
unpairedTags: ['rootfile']
});

const optionalMetadata: Record<string, any> = {};
const optionalList: Array<keyof ReturnType<typeof opf.metadata>> = [
'contributor',
'coverage',
'format',
'publisher',
'relation',
'rights',
'source',
'subject',
'type'
];
for (const key of optionalList) {
const m = opf.metadata();
if (!!m[key]) {
optionalMetadata['dc:' + key] = m[key];
}
}
const metadata = {
'dc:identifier': opf.identifier(),
'dc:title': opf.title(),
'dc:language': opf.language(),
'dc:creator': {
'@_id': 'creator',
'@_opf:role': 'aut',
'@_opf:file-as': opf.creator(),
'#text': opf.creator()
},
'dc:date': opf.metadata().date.toISOString(),
'dc:description': opf.metadata().description,
...optionalMetadata,
meta: [
{
'@_property': 'dcterms:modified',
'#text': opf.metadata().lastModified.toISOString()
},
{
'@_refines': '#creator',
'@_property': 'file-as',
'#text': opf.creator()
}
]
};

return builder.build({
'?xml': { '#text': '', '@_version': '1.0', '@_encoding': 'UTF-8' },
package: {
'@_unique-identifier': opf.uniqueIdentifier(),
'@_version': opf.version(),
metadata,
manifest: {},
spine: {}
}
});
}
111 changes: 111 additions & 0 deletions packages/core/src/epub.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,119 @@
import { randomUUID } from 'node:crypto';
import { type PathLike, promises as fs } from 'node:fs';

import { createDefu } from 'defu';

export class Epubook {
/**
* See: https://www.w3.org/TR/epub-33/#sec-package-doc
*
* Now, it only supports single opf (OEBPS/content.opf)
*
* @returns list of package documents
*/
private opfs: PackageDocument[] = [new PackageDocument('OEBPS/content.opf')];

constructor() {}

public packageDocuments(): PackageDocument[] {
return this.opfs;
}

public mainPackageDocument() {
return this.opfs[0];
}

async bundle() {
const { bundle } = await import('./bundle');
return await bundle(this);
}

async writeFile(file: PathLike) {
const buffer = await this.bundle();
await fs.writeFile(file, buffer);
}
}

const defu = createDefu((obj: any, key, value: any) => {
if (obj[key] instanceof Date && value instanceof Date) {
obj[key] = value;
return true;
}
});

export class PackageDocument {
private readonly file: string;

private readonly SpecVersion: '3.0' = '3.0';

private _uniqueIdentifier = 'uuid';

private _identifier = randomUUID();

private _metadata = {
title: '',
language: 'zh-CN',
contributor: [] as string[],
coverage: '',
creator: 'unknown',
date: new Date(),
description: '',
format: '',
publisher: '',
relation: '',
rights: '',
source: '',
subject: '',
type: '',
lastModified: new Date()
};

constructor(file: string) {
this.file = file;
}

public filename() {
return this.file;
}

public version() {
return this.SpecVersion;
}

// --- metadata ---
public update(info: Partial<typeof this._metadata>) {
// TODO: valiate input data
this._metadata = defu(info, this._metadata);
return this;
}

public title() {
return this._metadata.title;
}

public language() {
return this._metadata.language;
}

public creator() {
return this._metadata.creator;
}

public metadata() {
return this._metadata;
}

// --- identifier ---
public uniqueIdentifier() {
return this._uniqueIdentifier;
}

public identifier() {
return this._identifier;
}

public setIdentifier(identifier: string, uniqueIdentifier: string) {
this._identifier = identifier;
this._uniqueIdentifier = uniqueIdentifier;
}
}
5 changes: 5 additions & 0 deletions packages/core/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class BundleError extends Error {
constructor(msg: string) {
super(msg);
}
}
Loading

0 comments on commit b813e8b

Please sign in to comment.