Skip to content

Commit

Permalink
✨ Add logic for inserting iscn page into epub
Browse files Browse the repository at this point in the history
  • Loading branch information
williamchong committed Feb 19, 2024
1 parent ac2ba71 commit 362f984
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 12 deletions.
1 change: 1 addition & 0 deletions components/IscnUploadForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ const MODE = {
export default class IscnUploadForm extends Vue {
@Prop(Number) readonly step: number | undefined
@Prop({ default: MODE.REGISTER }) readonly mode: string | undefined
@Prop(String) readonly iscnId: string | undefined
@walletModule.Getter('getSigner') signer!: OfflineSigner | null
@walletModule.Action('initIfNecessary') initIfNecessary!: () => Promise<any>
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"form-data": "^4.0.0",
"image-type": "^4.1.0",
"ipfs-only-hash": "^4.0.0",
"jszip": "^3.10.1",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"mime-types": "^2.1.34",
Expand All @@ -58,6 +59,7 @@
"puppeteer-extra": "^3.3.4",
"puppeteer-extra-plugin-anonymize-ua": "^2.4.4",
"puppeteer-extra-plugin-stealth": "^2.11.1",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^8.3.2",
Expand All @@ -80,6 +82,7 @@
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/p5": "^1.3.0",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^8.3.1",
"@vue/test-utils": "^1.2.1",
"@vue/vue2-jest": "^27.0.0",
Expand Down
1 change: 1 addition & 0 deletions pages/edit/_iscnId.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
>
<IscnUploadForm
mode="edit"
:iscn-id="iscnId"
@submit="onSubmitUpload"
@arweaveUploaded="onArweaveIdUpload"
/>
Expand Down
60 changes: 60 additions & 0 deletions utils/epub/asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export const ISCN_CSS = `body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: sans-serif;
text-align: center;
}
#iscn-page-body {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
padding: 0.5em 2vw 4em;
}
#iscn-page-footer {
border-top: 1px solid #ccc;
padding: 0.5em;
}
#iscn-qr-code {
margin-top: 0.5em;
}
#iscn-prefix {
font-size: 0.75em;
font-family: monospace;
word-break: break-all;
}`

export const ISCN_XHTML = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>ISCN on LikeCoin Chain</title>
<link href="iscn.css" type="text/css" rel="stylesheet" />
</head>
<body>
<div id="iscn-page-body">
<div id="iscn-page-book-info" />
<div id="iscn-qr-code">
<img src="iscn-qr-code.png" width="180px" height="180px" />
<br />
<a id="iscn-prefix" />
</div>
</div>
<div id="iscn-page-footer">
<p id="depub-disclaimer" />
</div>
</body>
</html>
`

export const ISCN_LOGO_SVG = `<svg width="1024" height="1024" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M2.76,14.79H3.92V9.34H2.76Zm7.42-2.72a2.76,2.76,0,0,1,2.7-2.82H13a2.52,2.52,0,0,1,2.32,1.33l-1,.49a1.48,1.48,0,0,0-1.32-.8A1.69,1.69,0,0,0,11.37,12v0A1.7,1.7,0,0,0,13,13.81H13A1.49,1.49,0,0,0,14.36,13l1,.48A2.53,2.53,0,0,1,13,14.84a2.75,2.75,0,0,1-2.86-2.62ZM5.53,13.14a2.4,2.4,0,0,0,1.73.73c.64,0,1-.3,1-.61s-.47-.55-1.1-.69c-.89-.21-2-.45-2-1.67,0-.91.79-1.64,2.07-1.64A3,3,0,0,1,9.27,10l-.65.85a2.31,2.31,0,0,0-1.56-.6c-.52,0-.8.23-.8.56s.46.48,1.09.63c.9.2,2,.47,2,1.68,0,1-.71,1.75-2.18,1.75A3.1,3.1,0,0,1,4.9,14Zm12-2V14.8H16.32V9.34h1.19L20,12.87V9.34H21.2v5.45H20.08ZM8.68,8.22c1.3-.81,3-1.71,3.79-2.2A1.13,1.13,0,0,1,13,5.93a1.17,1.17,0,0,1,.45.09c.84.45,2.47,1.35,3.78,2.16h1.42a1.22,1.22,0,0,0-.32-.28c-1.4-.9-3.51-2.08-4.5-2.6a1.8,1.8,0,0,0-.87-.23,1.73,1.73,0,0,0-.86.23c-1,.52-3.1,1.7-4.5,2.6l-.39.28Zm8.38,7.69c-1.28.78-2.82,1.64-3.63,2.07a1.1,1.1,0,0,1-.47.13,1.19,1.19,0,0,1-.49-.13c-.81-.43-2.35-1.29-3.64-2.07H7.32l.27.19c1.4.9,3.51,2.08,4.5,2.6a1.73,1.73,0,0,0,1.73,0c1-.52,3.1-1.7,4.5-2.6a1.24,1.24,0,0,0,.22-.18H17.06Z" fill="white"/>
</svg>
`
175 changes: 175 additions & 0 deletions utils/epub/iscn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import QRCode from 'qrcode'
import JSZip from 'jszip'
import { Book } from 'epubjs'

import { SITE_URL } from '~/constant'
import { ISCN_CSS, ISCN_LOGO_SVG, ISCN_XHTML } from './asset'
import { extractIscnIdPrefix } from '../ui'

const LIKE_GREEN = '#28646e'
const QR_CODE_SIZE = 256
const LOGO_SIZE = 64

const ISCN_CSS_NAME = 'iscn.css'
const ISCN_XHTML_NAME = 'iscn.xhtml'
const ISCN_QR_CODE_PNG_NAME = 'iscn-qr-code.png'

function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const i = new Image();
i.onload = (() => resolve(i));
i.onerror = (e => reject(e));
i.src = url;
});
}

function getISCNURL(iscnPrefix: string) {
return `${SITE_URL}/view/${encodeURIComponent(iscnPrefix)}`
}

async function createQRCodeCanvas(iscnPrefix: string) {
const iscnURL = getISCNURL(iscnPrefix)
const initQRCode = await QRCode.toDataURL(iscnURL, {
color: {
light: LIKE_GREEN,
dark: '#fff',
},
errorCorrectionLevel: 'H',
margin: 2,
})
const canvas = document.createElement('canvas')
canvas.width = QR_CODE_SIZE
canvas.height = QR_CODE_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Cannot get 2d context')

const img = await loadImage(initQRCode)
ctx.drawImage(img, 0, 0, QR_CODE_SIZE, QR_CODE_SIZE)

// draw blank white circle
const circleCenter = canvas.width / 2
ctx.fillStyle = LIKE_GREEN
ctx.beginPath()
ctx.arc(circleCenter, circleCenter, LOGO_SIZE / 2 + 2, 0, 2 * Math.PI, false)
ctx.fill()

// draw logo
const logoBlob = new Blob([ISCN_LOGO_SVG], {type: 'image/svg+xml'});
const logoUrl = URL.createObjectURL(logoBlob);
const logoImage = await loadImage(logoUrl)
URL.revokeObjectURL(logoUrl)
const logoPosition = (canvas.width - LOGO_SIZE) / 2
ctx.drawImage(logoImage, logoPosition, logoPosition, LOGO_SIZE, LOGO_SIZE)

return canvas
}

function saveCanvas(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob: Blob | null) => blob ? resolve(blob) : reject(new Error('Cannot save canvas to blob')))
})
}

function updateContentOPF(doc: Document, iscnPageHref: string, iscnQRCodeHref: string) {
if (doc.querySelector('#iscn-page')) return
const manifest = doc.querySelector('manifest')
manifest?.insertAdjacentHTML('beforeend', ` <item id="iscn-css" href="iscn.css" media-type="text/css" />\n `)
manifest?.insertAdjacentHTML('beforeend', ` <item id="iscn-qr-code-image" href="${iscnQRCodeHref}" media-type="image/png" />\n `)
manifest?.insertAdjacentHTML('beforeend', ` <item id="iscn-page" href="${iscnPageHref}" media-type="application/xhtml+xml" />\n `)

const spine = doc.querySelector('spine')
spine?.insertAdjacentHTML('beforeend', ` <itemref idref="iscn-page" />\n `)
}

function readInfoMap(doc: Document) {
const titles = doc.querySelector("metadata title")
const title = titles?.textContent
const authors = doc.querySelector("metadata creator")
const author = authors?.textContent
const releaseDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const map = new Map()
map.set('Title', title)
map.set('Author', author)
map.set('Release Date', releaseDate)
return map
}

function addBookInfo(doc: Document, infoItemMap: Map<string, string>) {
const div = doc.querySelector('body #iscn-page-book-info')
const itemsString = [...infoItemMap]
.filter(([
key,
value,
]) => key && value)
.map(([
key,
value,
]) => (
`<p>${key}: ${value}</p>\n`
))
.join('')
div?.insertAdjacentHTML('afterbegin', itemsString)
}

function setISCNLink(doc: Document, iscnPrefix: string) {
const a = doc.querySelector('body a#iscn-prefix')
const iscnURL = getISCNURL(iscnPrefix)
a?.setAttribute('href', iscnURL)
if (a) a.textContent = iscnPrefix
}

function addFooterDisclaimer(doc: Document) {
const footer = doc.querySelector('body #depub-disclaimer')
if (footer) {
footer.textContent = 'This book is published on decentralized networks'
}
}

export async function injectISCNQRCodePage(buffer: ArrayBuffer, book: Book, iscnId: string) {
const zipObject = new JSZip()
await zipObject.loadAsync(buffer)
const iscnPrefix = extractIscnIdPrefix(iscnId)
// create QR code
const canvas = await createQRCodeCanvas(iscnPrefix)
const oebpsPath: string = (book.container as any).directory
const blob = await saveCanvas(canvas)
if (!blob) throw new Error('Cannot save canvas to blob')
await zipObject.file(`${oebpsPath}/${ISCN_QR_CODE_PNG_NAME}`, blob)

// read and update content.opf
const path = (book.container as any).packagePath
const opfString = await zipObject.file((book.container as any).packagePath)?.async('string') || ''
const doc = new DOMParser().parseFromString(opfString, 'text/xml')
updateContentOPF(doc, ISCN_XHTML_NAME, ISCN_QR_CODE_PNG_NAME)
const infoMap = readInfoMap(doc)
const updatedOpfString = new XMLSerializer().serializeToString(doc).toString()
await zipObject.file(path, updatedOpfString)

// add ISCN css
await zipObject.file(`${oebpsPath}/${ISCN_CSS_NAME}`, ISCN_CSS)

// add ISCN XHTML and update info
const iscnXHTMLPath = `${oebpsPath}/${ISCN_XHTML_NAME}`
const iscnXHTMLDoc = new DOMParser().parseFromString(ISCN_XHTML, 'text/xml')
addBookInfo(iscnXHTMLDoc, infoMap)
setISCNLink(iscnXHTMLDoc, iscnPrefix)
addFooterDisclaimer(iscnXHTMLDoc)
const updatedISCNXHTMLString = new XMLSerializer().serializeToString(iscnXHTMLDoc).toString()

await zipObject.file(iscnXHTMLPath, updatedISCNXHTMLString)
const epubBlob = await zipObject.generateAsync({
mimeType: 'application/epub+zip',
type: 'blob',
compression: 'DEFLATE',
compressionOptions: {
level: 9,
},
})
return epubBlob
}

export default injectISCNQRCodePage
20 changes: 10 additions & 10 deletions utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,19 @@ export function catchAxiosError(promise: AxiosPromise<any>) {
});
}

export function downloadFile (blob: Blob, filename: string) {
const file = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = file
a.style.display = 'none'
a.download = filename
a.click()
}

export function downloadJSON(data: Object, fileName: string) {
const jsonData = JSON.stringify(data, null, 2)

const jsonBlob = new Blob([jsonData], { type: 'application/json' })

const jsonUrl = URL.createObjectURL(jsonBlob)

const jsonLink = document.createElement('a')
jsonLink.href = jsonUrl
jsonLink.download = fileName
jsonLink.style.display = 'none'

document.body.appendChild(jsonLink)
jsonLink.click()
document.body.removeChild(jsonLink)
downloadFile(jsonBlob, fileName)
}
11 changes: 9 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6313,6 +6313,13 @@
resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.6.tgz#f830323c88172e66826d0bde413498b61054b5a6"
integrity sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==

"@types/qrcode@^1.5.5":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
dependencies:
"@types/node" "*"

"@types/qs@*":
version "6.9.6"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1"
Expand Down Expand Up @@ -14877,7 +14884,7 @@ jstransformer@1.0.0:
is-promise "^2.0.0"
promise "^7.0.1"

jszip@^3.7.1:
jszip@^3.10.1, jszip@^3.7.1:
version "3.10.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2"
integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==
Expand Down Expand Up @@ -18740,7 +18747,7 @@ qrcode@1.4.4:
pngjs "^3.3.0"
yargs "^13.2.4"

qrcode@1.5.3:
qrcode@1.5.3, qrcode@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170"
integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==
Expand Down

0 comments on commit 362f984

Please sign in to comment.