feat: PDF express download in CLI

master
Xmader 2021-07-31 22:18:00 -04:00
parent db1bcf3f4c
commit 878585546b
No known key found for this signature in database
GPG Key ID: A20B97FB9EB730E4
8 changed files with 188 additions and 65 deletions

View File

@ -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,

View File

@ -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

View File

@ -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({

View File

@ -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`)
}

View File

@ -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(?:&quot;|"):(\d+),/
private readonly THUMBNAIL_REG = /<link (?:.*) href="(.*)" rel="preload" as="image"/
private readonly DIMENSIONS_REG = /dimensions(?:&quot;|"):(?:&quot;|")(\d+)x(\d+)(?:&quot;|"),/
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> => {

View File

@ -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}`)
}

View File

@ -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)

View File

@ -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])
}
}