feat: PDF express download in CLI
parent
db1bcf3f4c
commit
878585546b
|
@ -75,8 +75,9 @@ export default [
|
|||
output: {
|
||||
file: "dist/cache/worker.js",
|
||||
format: "iife",
|
||||
name: 'worker',
|
||||
banner: "export const PDFWorker = function () { ",
|
||||
footer: "}\n",
|
||||
footer: "return worker\n}\n",
|
||||
sourcemap: false,
|
||||
},
|
||||
plugins,
|
||||
|
|
56
src/cli.ts
56
src/cli.ts
|
@ -11,7 +11,8 @@ import { ScoreInfo, ScoreInfoHtml, ScoreInfoObj, getActualId } from './scoreinfo
|
|||
import { getLibreScoreLink } from './librescore-link'
|
||||
import { escapeFilename, DISCORD_URL } from './utils'
|
||||
import { isNpx, getVerInfo, getSelfVer } from './npm-data'
|
||||
import { getFileUrl, FileType } from './file'
|
||||
import { getFileUrl } from './file'
|
||||
import { exportPDF } from './pdf'
|
||||
import i18n from './i18n'
|
||||
|
||||
const inquirer: typeof import('inquirer') = require('inquirer')
|
||||
|
@ -22,11 +23,13 @@ const SCORE_URL_PREFIX = 'https://(s.)musescore.com/'
|
|||
const SCORE_URL_REG = /https:\/\/(s\.)?musescore\.com\//
|
||||
const EXT = '.mscz'
|
||||
|
||||
type ExpDlType = 'midi' | 'mp3' | 'pdf'
|
||||
|
||||
interface Params {
|
||||
fileInit: string;
|
||||
confirmed: boolean;
|
||||
useExpDL: boolean;
|
||||
expDlType: FileType;
|
||||
expDlType: ExpDlType;
|
||||
part: number;
|
||||
types: number[];
|
||||
dest: string;
|
||||
|
@ -48,11 +51,19 @@ const promptDest = async () => {
|
|||
return dest
|
||||
}
|
||||
|
||||
const createSpinner = () => {
|
||||
return ora({
|
||||
text: i18n('PROCESSING')(),
|
||||
color: 'blue',
|
||||
spinner: 'bounce',
|
||||
indent: 0,
|
||||
}).start()
|
||||
}
|
||||
|
||||
/**
|
||||
* MIDI/MP3 express download using the file API (./file.ts)
|
||||
* @todo PDF
|
||||
* MIDI/MP3/PDF express download using the file API (./file.ts)
|
||||
*/
|
||||
const expDL = async (scoreinfo: ScoreInfo) => {
|
||||
const expDL = async (scoreinfo: ScoreInfoHtml) => {
|
||||
// print a blank line
|
||||
console.log()
|
||||
|
||||
|
@ -61,11 +72,29 @@ const expDL = async (scoreinfo: ScoreInfo) => {
|
|||
type: 'list',
|
||||
name: 'expDlType',
|
||||
message: 'Filetype Selection',
|
||||
choices: ['midi', 'mp3'] as FileType[],
|
||||
choices: ['midi', 'mp3', 'pdf'] as ExpDlType[],
|
||||
})
|
||||
|
||||
const fileUrl = await getFileUrl(scoreinfo.id, expDlType)
|
||||
console.log(`${chalk.blueBright('ℹ')} File URL: ${fileUrl} ${chalk.bgGray('click to open in browser')}`)
|
||||
switch (expDlType) {
|
||||
case 'midi':
|
||||
case 'mp3': {
|
||||
const fileUrl = await getFileUrl(scoreinfo.id, expDlType)
|
||||
console.log(`${chalk.blueBright('ℹ')} File URL: ${fileUrl} ${chalk.bgGray('click to open in browser')}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'pdf': {
|
||||
const dest = await promptDest()
|
||||
const spinner = createSpinner()
|
||||
const pdfData = Buffer.from(
|
||||
await exportPDF(scoreinfo, scoreinfo.sheet),
|
||||
)
|
||||
const f = path.join(dest, `${scoreinfo.fileName}.pdf`)
|
||||
await fs.promises.writeFile(f, pdfData)
|
||||
spinner.succeed('OK')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
|
@ -134,11 +163,11 @@ void (async () => {
|
|||
type: 'confirm',
|
||||
name: 'useExpDL',
|
||||
prefix: `${chalk.blueBright('ℹ')} ` +
|
||||
'MIDI/MP3 express download is now available.\n ',
|
||||
'MIDI/MP3/PDF express download is now available.\n ',
|
||||
message: '🚀 Give it a try?',
|
||||
default: true,
|
||||
})
|
||||
if (useExpDL) return expDL(scoreinfo)
|
||||
if (useExpDL) return expDL(scoreinfo as ScoreInfoHtml)
|
||||
|
||||
// initiate LibreScore link request
|
||||
librescoreLink = getLibreScoreLink(scoreinfo)
|
||||
|
@ -150,12 +179,7 @@ void (async () => {
|
|||
scoreinfo = new ScoreInfoObj(0, path.basename(fileInit, EXT))
|
||||
}
|
||||
|
||||
const spinner = ora({
|
||||
text: i18n('PROCESSING')(),
|
||||
color: 'blue',
|
||||
spinner: 'bounce',
|
||||
indent: 0,
|
||||
}).start()
|
||||
const spinner = createSpinner()
|
||||
|
||||
let score: WebMscore
|
||||
let metadata: import('webmscore/schemas').ScoreMetadata
|
||||
|
|
|
@ -34,7 +34,7 @@ const main = (): void => {
|
|||
|
||||
btnList.add({
|
||||
name: i18n('DOWNLOAD')('PDF'),
|
||||
action: BtnAction.process(() => downloadPDF(scoreinfo, new SheetInfoInPage(document)), fallback, 3 * 60 * 1000 /* 3min */),
|
||||
action: BtnAction.process(() => downloadPDF(scoreinfo, new SheetInfoInPage(document), saveAs), fallback, 3 * 60 * 1000 /* 3min */),
|
||||
})
|
||||
|
||||
btnList.add({
|
||||
|
|
60
src/pdf.ts
60
src/pdf.ts
|
@ -1,29 +1,35 @@
|
|||
|
||||
import isNodeJs from 'detect-node'
|
||||
import { PDFWorker } from '../dist/cache/worker'
|
||||
import { PDFWorkerHelper } from './worker-helper'
|
||||
import { getFileUrl } from './file'
|
||||
import FileSaver from 'file-saver'
|
||||
import { ScoreInfo, SheetInfo } from './scoreinfo'
|
||||
import { ScoreInfo, SheetInfo, Dimensions } from './scoreinfo'
|
||||
import { fetchBuffer } from './utils'
|
||||
|
||||
let pdfBlob: Blob
|
||||
|
||||
const _downloadPDF = async (imgURLs: string[], imgType: 'svg' | 'png', name = ''): Promise<void> => {
|
||||
if (pdfBlob) {
|
||||
return FileSaver.saveAs(pdfBlob, `${name}.pdf`)
|
||||
}
|
||||
|
||||
const cachedImg = document.querySelector('img[src*=score_]') as HTMLImageElement
|
||||
const { naturalWidth: width, naturalHeight: height } = cachedImg
|
||||
type _ExFn = (imgURLs: string[], imgType: 'svg' | 'png', dimensions: Dimensions) => Promise<ArrayBuffer>
|
||||
|
||||
const _exportPDFBrowser: _ExFn = async (imgURLs, imgType, dimensions) => {
|
||||
const worker = new PDFWorkerHelper()
|
||||
const pdfArrayBuffer = await worker.generatePDF(imgURLs, imgType, width, height)
|
||||
const pdfArrayBuffer = await worker.generatePDF(imgURLs, imgType, dimensions.width, dimensions.height)
|
||||
worker.terminate()
|
||||
|
||||
pdfBlob = new Blob([pdfArrayBuffer])
|
||||
|
||||
FileSaver.saveAs(pdfBlob, `${name}.pdf`)
|
||||
return pdfArrayBuffer
|
||||
}
|
||||
|
||||
export const downloadPDF = async (scoreinfo: ScoreInfo, sheet: SheetInfo): Promise<void> => {
|
||||
const _exportPDFNode: _ExFn = async (imgURLs, imgType, dimensions) => {
|
||||
const imgBufs = await Promise.all(imgURLs.map(url => fetchBuffer(url)))
|
||||
|
||||
const { generatePDF } = PDFWorker()
|
||||
const pdfArrayBuffer = await generatePDF(
|
||||
imgBufs,
|
||||
imgType,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
) as ArrayBuffer
|
||||
|
||||
return pdfArrayBuffer
|
||||
}
|
||||
|
||||
export const exportPDF = async (scoreinfo: ScoreInfo, sheet: SheetInfo): Promise<ArrayBuffer> => {
|
||||
const imgType = sheet.imgType
|
||||
const pageCount = sheet.pageCount
|
||||
|
||||
|
@ -36,5 +42,23 @@ export const downloadPDF = async (scoreinfo: ScoreInfo, sheet: SheetInfo): Promi
|
|||
})
|
||||
const sheetImgURLs = await Promise.all(rs)
|
||||
|
||||
return _downloadPDF(sheetImgURLs, imgType, scoreinfo.fileName)
|
||||
const args = [sheetImgURLs, imgType, sheet.dimensions] as const
|
||||
if (!isNodeJs) {
|
||||
return _exportPDFBrowser(...args)
|
||||
} else {
|
||||
return _exportPDFNode(...args)
|
||||
}
|
||||
}
|
||||
|
||||
let pdfBlob: Blob
|
||||
export const downloadPDF = async (scoreinfo: ScoreInfo, sheet: SheetInfo, saveAs: typeof import('file-saver').saveAs): Promise<void> => {
|
||||
const name = scoreinfo.fileName
|
||||
if (pdfBlob) {
|
||||
return saveAs(pdfBlob, `${name}.pdf`)
|
||||
}
|
||||
|
||||
const pdfArrayBuffer = await exportPDF(scoreinfo, sheet)
|
||||
|
||||
pdfBlob = new Blob([pdfArrayBuffer])
|
||||
saveAs(pdfBlob, `${name}.pdf`)
|
||||
}
|
||||
|
|
|
@ -82,6 +82,10 @@ export class ScoreInfoHtml extends ScoreInfo {
|
|||
return m[1]
|
||||
}
|
||||
|
||||
get sheet (): SheetInfo {
|
||||
return new SheetInfoHtml(this.html)
|
||||
}
|
||||
|
||||
static async request (url: string, _fetch = getFetch()): Promise<ScoreInfoHtml> {
|
||||
const r = await _fetch(url)
|
||||
if (!r.ok) return new ScoreInfoHtml('')
|
||||
|
@ -91,10 +95,16 @@ export class ScoreInfoHtml extends ScoreInfo {
|
|||
}
|
||||
}
|
||||
|
||||
export type Dimensions = { width: number; height: number }
|
||||
|
||||
export abstract class SheetInfo {
|
||||
abstract pageCount: number;
|
||||
|
||||
/** url to the image of the first page */
|
||||
abstract thumbnailUrl: string;
|
||||
|
||||
abstract dimensions: Dimensions;
|
||||
|
||||
get imgType (): 'svg' | 'png' {
|
||||
const thumbnail = this.thumbnailUrl
|
||||
const imgtype = thumbnail.match(/score_0\.(\w+)/)![1]
|
||||
|
@ -118,11 +128,42 @@ export class SheetInfoInPage extends SheetInfo {
|
|||
}
|
||||
|
||||
get thumbnailUrl (): string {
|
||||
// url to the image of the first page
|
||||
const el = this.document.querySelector<HTMLLinkElement>('link[as=image]')
|
||||
const url = (el?.href || this.sheet0Img?.src) as string
|
||||
return url.split('@')[0]
|
||||
}
|
||||
|
||||
get dimensions (): Dimensions {
|
||||
const { naturalWidth: width, naturalHeight: height } = this.sheet0Img as HTMLImageElement
|
||||
return { width, height }
|
||||
}
|
||||
}
|
||||
|
||||
export class SheetInfoHtml extends SheetInfo {
|
||||
private readonly PAGE_COUNT_REG = /pages(?:"|"):(\d+),/
|
||||
private readonly THUMBNAIL_REG = /<link (?:.*) href="(.*)" rel="preload" as="image"/
|
||||
|
||||
private readonly DIMENSIONS_REG = /dimensions(?:"|"):(?:"|")(\d+)x(\d+)(?:"|"),/
|
||||
|
||||
constructor (private html: string) { super() }
|
||||
|
||||
get pageCount (): number {
|
||||
const m = this.html.match(this.PAGE_COUNT_REG)
|
||||
if (!m) return NaN
|
||||
return +m[1]
|
||||
}
|
||||
|
||||
get thumbnailUrl (): string {
|
||||
const m = this.html.match(this.THUMBNAIL_REG)
|
||||
if (!m) return ''
|
||||
return m[1].split('@')[0]
|
||||
}
|
||||
|
||||
get dimensions (): Dimensions {
|
||||
const m = this.html.match(this.DIMENSIONS_REG)
|
||||
if (!m) return { width: NaN, height: NaN }
|
||||
return { width: +m[1], height: +m[2] }
|
||||
}
|
||||
}
|
||||
|
||||
export const getActualId = async (scoreinfo: ScoreInfoInPage | ScoreInfoHtml, _fetch = getFetch()): Promise<number> => {
|
||||
|
|
|
@ -41,11 +41,17 @@ export const getFetch = (): typeof fetch => {
|
|||
}
|
||||
|
||||
export const fetchData = async (url: string, init?: RequestInit): Promise<Uint8Array> => {
|
||||
const r = await fetch(url, init)
|
||||
const _fetch = getFetch()
|
||||
const r = await _fetch(url, init)
|
||||
const data = await r.arrayBuffer()
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
|
||||
export const fetchBuffer = async (url: string, init?: RequestInit): Promise<Buffer> => {
|
||||
const d = await fetchData(url, init)
|
||||
return Buffer.from(d.buffer)
|
||||
}
|
||||
|
||||
export const assertRes = (r: Response): void => {
|
||||
if (!r.ok) throw new Error(`${r.url} ${r.status} ${r.statusText}`)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,11 @@ const scriptUrlFromFunction = (fn: () => any): string => {
|
|||
return window.URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
// Node.js fix
|
||||
if (typeof Worker === 'undefined') {
|
||||
globalThis.Worker = class { } as any // noop shim
|
||||
}
|
||||
|
||||
export class PDFWorkerHelper extends Worker {
|
||||
constructor () {
|
||||
const url = scriptUrlFromFunction(PDFWorker)
|
||||
|
|
|
@ -8,22 +8,33 @@ type ImgType = 'svg' | 'png'
|
|||
|
||||
type DataResultType = 'dataUrl' | 'text'
|
||||
|
||||
const readData = (blob: Blob, type: DataResultType): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (): void => {
|
||||
const result = reader.result
|
||||
resolve(result as string)
|
||||
}
|
||||
reader.onerror = reject
|
||||
const readData = (data: Blob | Buffer, type: DataResultType): string | Promise<string> => {
|
||||
if (!(data instanceof Uint8Array)) { // blob
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (): void => {
|
||||
const result = reader.result
|
||||
resolve(result as string)
|
||||
}
|
||||
reader.onerror = reject
|
||||
if (type === 'dataUrl') {
|
||||
reader.readAsDataURL(data)
|
||||
} else {
|
||||
reader.readAsText(data)
|
||||
}
|
||||
})
|
||||
} else { // buffer
|
||||
if (type === 'dataUrl') {
|
||||
reader.readAsDataURL(blob)
|
||||
return 'data:image/png;base64,' + data.toString('base64')
|
||||
} else {
|
||||
reader.readAsText(blob)
|
||||
return data.toString('utf-8')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @platform browser
|
||||
*/
|
||||
const fetchBlob = async (imgUrl: string): Promise<Blob> => {
|
||||
const r = await fetch(imgUrl, {
|
||||
cache: 'no-cache',
|
||||
|
@ -31,7 +42,13 @@ const fetchBlob = async (imgUrl: string): Promise<Blob> => {
|
|||
return r.blob()
|
||||
}
|
||||
|
||||
const generatePDF = async (imgBlobs: Blob[], imgType: ImgType, width: number, height: number): Promise<ArrayBuffer> => {
|
||||
/**
|
||||
* @example
|
||||
* import { PDFWorker } from '../dist/cache/worker'
|
||||
* const { generatePDF } = PDFWorker()
|
||||
* const pdfData = await generatePDF(...)
|
||||
*/
|
||||
export const generatePDF = async (imgBlobs: Blob[] | Buffer[], imgType: ImgType, width: number, height: number): Promise<ArrayBuffer> => {
|
||||
// @ts-ignore
|
||||
const pdf = new (PDFDocument as typeof import('pdfkit'))({
|
||||
// compress: true,
|
||||
|
@ -70,22 +87,27 @@ const generatePDF = async (imgBlobs: Blob[], imgType: ImgType, width: number, he
|
|||
|
||||
export type PDFWorkerMessage = [string[], ImgType, number, number];
|
||||
|
||||
onmessage = async (e): Promise<void> => {
|
||||
const [
|
||||
imgUrls,
|
||||
imgType,
|
||||
width,
|
||||
height,
|
||||
] = e.data as PDFWorkerMessage
|
||||
/**
|
||||
* @platform browser (web worker)
|
||||
*/
|
||||
if (typeof onmessage !== 'undefined') {
|
||||
onmessage = async (e): Promise<void> => {
|
||||
const [
|
||||
imgUrls,
|
||||
imgType,
|
||||
width,
|
||||
height,
|
||||
] = e.data as PDFWorkerMessage
|
||||
|
||||
const imgBlobs = await Promise.all(imgUrls.map(url => fetchBlob(url)))
|
||||
const imgBlobs = await Promise.all(imgUrls.map(url => fetchBlob(url)))
|
||||
|
||||
const pdfBuf = await generatePDF(
|
||||
imgBlobs,
|
||||
imgType,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
const pdfBuf = await generatePDF(
|
||||
imgBlobs,
|
||||
imgType,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
postMessage(pdfBuf, [pdfBuf])
|
||||
postMessage(pdfBuf, [pdfBuf])
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue