decktape/decktape.js

497 lines
16 KiB
JavaScript
Raw Normal View History

2017-08-18 13:51:31 +03:00
#!/usr/bin/env node
2017-08-22 11:48:55 +03:00
'use strict';
const chalk = require('chalk'),
crypto = require('crypto'),
Font = require('fonteditor-core').Font,
fs = require('fs'),
os = require('os'),
parser = require('./libs/nomnom'),
path = require('path'),
puppeteer = require('puppeteer'),
URI = require('urijs'),
util = require('util');
2017-08-21 23:56:27 +03:00
const { PDFDocument, PDFName, ParseSpeeds, decodePDFRawStream } = require('pdf-lib');
2017-08-25 16:10:44 +03:00
const { delay, pause } = require('./libs/util');
2017-08-18 13:51:31 +03:00
const plugins = loadAvailablePlugins(path.join(path.dirname(__filename), 'plugins'));
2017-08-18 13:51:31 +03:00
2017-08-22 11:48:55 +03:00
parser.script('decktape').options({
url : {
position : 1,
required : true,
transform : parseUrl,
help : 'URL of the slides deck',
2017-08-22 11:48:55 +03:00
},
filename : {
position : 2,
required : true,
help : 'Filename of the output PDF file',
},
size : {
abbr : 's',
metavar : '<size>',
type : 'string',
callback : parseSize,
transform : parseSize,
2018-01-03 15:13:31 +03:00
help : 'Size of the slides deck viewport: <width>x<height> (e.g. \'1280x720\')',
2017-08-22 11:48:55 +03:00
},
pause : {
abbr : 'p',
metavar : '<ms>',
default : 1000,
help : 'Duration in milliseconds before each slide is exported',
},
loadPause : {
full : 'load-pause',
metavar : '<ms>',
default : 0,
help : 'Duration in milliseconds between the page has loaded and starting to export slides',
},
screenshots : {
default : false,
flag : true,
help : 'Capture each slide as an image',
},
screenshotDirectory : {
full : 'screenshots-directory',
metavar : '<dir>',
default : 'screenshots',
help : 'Screenshots output directory',
},
screenshotSizes : {
2017-08-22 11:48:55 +03:00
full : 'screenshots-size',
metavar : '<size>',
type : 'string',
list : true,
callback : parseSize,
transform : parseSize,
help : 'Screenshots resolution, can be repeated',
},
screenshotFormat : {
full : 'screenshots-format',
metavar : '<format>',
default : 'png',
choices : ['jpg', 'png'],
help : 'Screenshots image format, one of [jpg, png]',
},
slides : {
metavar : '<range>',
type : 'string',
callback : parseRange,
transform : parseRange,
help : 'Range of slides to be exported, a combination of slide indexes and ranges (e.g. \'1-3,5,8\')',
},
// Chrome options
chromePath : {
full : 'chrome-path',
metavar : '<path>',
type : 'string',
help : 'Path to the Chromium or Chrome executable to run instead of the bundled Chromium',
},
chromeArgs : {
full : 'chrome-arg',
metavar : '<arg>',
type : 'string',
list : true,
help : 'Additional argument to pass to the Chrome instance, can be repeated',
},
2017-08-22 11:48:55 +03:00
});
2017-08-18 13:51:31 +03:00
function parseSize(size) {
// we may want to support height and width labeled with units
// /^(\d+(?:px)?|\d+(?:\.\d+)?(?:in|cm|mm)?)\s?x\s?(\d+(?:px)?|\d+(?:\.\d+)?(?:in|cm|mm)?)$/
const match = size.match(/^(\d+)x(\d+)$/);
if (match) {
const [, width, height] = match;
return { width: parseInt(width, 10), height: parseInt(height, 10) };
} else {
return '<size> must follow the <width>x<height> notation, e.g., \'1280x720\'';
}
2017-08-18 13:51:31 +03:00
}
function parseRange(range) {
2017-08-22 11:48:55 +03:00
const regex = /(\d+)(?:-(\d+))?/g;
if (!range.match(regex))
return '<range> must be a combination of slide indexes and ranges, e.g., \'1-3,5,8\'';
let slide, slides = {};
while ((slide = regex.exec(range)) !== null) {
2017-08-31 19:36:58 +03:00
const [, m, n] = slide.map(i => parseInt(i));
if (isNaN(n)) {
slides[m] = true;
} else {
for (let i = m; i <= n; i++) {
2017-08-22 11:48:55 +03:00
slides[i] = true;
}
}
}
return slides;
2017-08-18 13:51:31 +03:00
}
function parseUrl(url) {
const uri = URI(url);
if (!uri.protocol()) {
2017-11-18 17:25:31 +03:00
if (path.isAbsolute(url)) {
return 'file://' + path.normalize(url);
} else {
return 'file://' + path.normalize(path.join(process.cwd(), url));
}
}
return url;
}
2017-08-22 18:53:50 +03:00
parser.command('version')
.root(true)
.help('Display decktape package version')
.callback(_ => {
console.log(require('./package.json').version);
process.exit();
});
parser.nocommand()
.help(
2017-08-22 11:48:55 +03:00
`Defaults to the automatic command.
Iterates over the available plugins, picks the compatible one for presentation at the
specified <url> and uses it to export and write the PDF into the specified <filename>.`
2017-08-22 18:53:50 +03:00
);
parser.command('automatic')
.help(
2017-08-22 11:48:55 +03:00
`Iterates over the available plugins, picks the compatible one for presentation at the
specified <url> and uses it to export and write the PDF into the specified <filename>.`
2017-08-22 18:53:50 +03:00
);
2017-09-04 11:32:42 +03:00
Object.entries(plugins).forEach(([id, plugin]) => {
2017-08-22 11:48:55 +03:00
const command = parser.command(id);
if (typeof plugin.options === 'object') {
2017-09-04 11:32:42 +03:00
command.options(plugin.options);
}
if (typeof plugin.help === 'string') {
2017-09-04 11:32:42 +03:00
command.help(plugin.help);
}
2017-08-18 13:51:31 +03:00
});
// TODO: should be deactivated as well when it does not execute in a TTY context
2017-08-22 11:48:55 +03:00
if (os.name === 'windows') parser.nocolors();
const options = parser.parse(process.argv.slice(2));
const color = type => {
switch (type) {
case 'error': return chalk.red;
case 'warning': return chalk.keyword('orange');
default: return chalk.gray;
}
};
process.on('unhandledRejection', error => {
console.log(error.stack);
process.exit(1);
});
2017-08-22 11:48:55 +03:00
(async () => {
const browser = await puppeteer.launch({
headless : true,
2018-07-11 20:52:06 +03:00
// TODO: add a verbose option
// dumpio : true,
executablePath : options.chromePath,
args : options.chromeArgs,
});
2017-08-25 18:49:39 +03:00
const page = await browser.newPage();
2020-01-02 22:01:47 +03:00
await page.emulateMediaType('screen');
const pdf = await PDFDocument.create();
pdf.setCreator('Decktape');
2017-08-22 11:48:55 +03:00
2017-08-23 11:43:03 +03:00
page
.on('console', async msg => {
const args = msg.args().length
? await Promise.all(msg.args().map(arg => arg.executionContext().evaluate(obj => obj, arg)))
: [msg.text()];
console.log(...args.map(arg => color(msg.type())(util.format(arg))));
})
.on('requestfailed', request => {
// do not output warning for cancelled requests
if (request.failure() && request.failure().errorText === 'net::ERR_ABORTED') return;
console.log(chalk`\n{keyword('orange') Unable to load resource from URL: ${request.url()}}`);
})
.on('pageerror', error => console.log(chalk`\n{red Page error: ${error.message}}`));
2017-08-22 11:48:55 +03:00
2017-08-23 11:43:03 +03:00
console.log('Loading page', options.url, '...');
const load = page.waitForNavigation({ waitUntil: 'load', timeout: 20000 });
page.goto(options.url, { waitUntil: 'networkidle0', timeout: 60000 })
// wait until the load event is dispatched
2018-05-09 19:11:19 +03:00
.then(response => load
.catch(error => response.status() !== 200 ? Promise.reject(error) : response)
.then(_ => response))
// TODO: improve message when reading file locally
2018-02-28 17:26:23 +03:00
.then(response => console.log('Loading page finished with status:', response.status()))
2017-08-22 11:48:55 +03:00
.then(delay(options.loadPause))
2017-08-23 18:04:37 +03:00
.then(_ => createPlugin(page))
.then(plugin => configurePlugin(plugin)
.then(_ => configurePage(plugin, page))
.then(_ => exportSlides(plugin, page, pdf))
.then(async context => {
2020-08-11 18:18:56 +03:00
await writePdf(options.filename, pdf);
console.log(chalk`{green \nPrinted {bold ${context.exportedSlides}} slides}`);
2017-09-11 15:50:59 +03:00
browser.close();
process.exit();
2017-08-23 18:04:37 +03:00
}))
2017-09-11 15:50:59 +03:00
.catch(error => {
console.log(chalk`{red \n${error}}`);
2017-08-22 11:48:55 +03:00
browser.close();
process.exit(1);
2017-08-18 13:51:31 +03:00
});
2017-08-22 11:48:55 +03:00
2017-08-23 18:04:37 +03:00
})();
2017-08-18 13:51:31 +03:00
2020-08-11 18:18:56 +03:00
async function writePdf(filename, pdf) {
const pdfDir = path.dirname(filename);
try {
fs.accessSync(pdfDir, fs.constants.F_OK);
} catch {
2020-08-11 22:52:02 +03:00
fs.mkdirSync(pdfDir, { recursive: true });
2020-08-11 18:18:56 +03:00
}
fs.writeFileSync(filename, await pdf.save({ addDefaultPage: false }));
}
function loadAvailablePlugins(pluginsPath) {
2017-08-22 11:48:55 +03:00
return fs.readdirSync(pluginsPath).reduce((plugins, pluginPath) => {
const [, plugin] = pluginPath.match(/^(.*)\.js$/);
if (plugin && fs.statSync(path.join(pluginsPath, pluginPath)).isFile()) {
2017-08-22 11:48:55 +03:00
plugins[plugin] = require('./plugins/' + plugin);
}
2017-08-22 11:48:55 +03:00
return plugins;
}, {});
2017-08-18 13:51:31 +03:00
}
2017-08-23 18:04:37 +03:00
async function createPlugin(page) {
let plugin;
if (!options.command || options.command === 'automatic') {
plugin = await createActivePlugin(page);
if (!plugin) {
console.log('No supported DeckTape plugin detected, falling back to generic plugin');
plugin = plugins['generic'].create(page, options);
}
} else {
plugin = plugins[options.command].create(page, options);
if (!await plugin.isActive()) {
throw Error(`Unable to activate the ${plugin.getName()} DeckTape plugin for the address: ${options.url}`);
}
}
2017-09-07 21:19:41 +03:00
console.log(chalk`{cyan {bold ${plugin.getName()}} plugin activated}`);
2017-08-23 18:04:37 +03:00
return plugin;
}
async function createActivePlugin(page) {
for (let id in plugins) {
if (id === 'generic') continue;
const plugin = plugins[id].create(page, options);
if (await plugin.isActive()) return plugin;
}
}
async function configurePage(plugin, page) {
if (!options.size) {
options.size = typeof plugin.size === 'function' ? await plugin.size() : { width: 1280, height: 720 };
2017-08-23 18:04:37 +03:00
}
await page.setViewport(options.size);
}
2017-08-22 11:48:55 +03:00
async function configurePlugin(plugin) {
if (typeof plugin.configure === 'function') {
2017-08-22 11:48:55 +03:00
await plugin.configure();
}
2017-08-18 13:51:31 +03:00
}
async function exportSlides(plugin, page, pdf) {
const context = {
progressBarOverflow : 0,
currentSlide : 1,
exportedSlides : 0,
pdfFonts : {},
pdfXObjects : {},
totalSlides : await plugin.slideCount(),
};
2017-08-23 18:04:37 +03:00
// TODO: support a more advanced "fragment to pause" mapping
// for special use cases like GIF animations
// TODO: support plugin optional promise to wait until a particular mutation
// instead of a pause
if (options.slides && !options.slides[context.currentSlide]) {
process.stdout.write('\r' + await progressBar(plugin, context, { skip: true }));
} else {
await pause(options.pause);
await exportSlide(plugin, page, pdf, context);
}
2017-09-04 11:32:42 +03:00
const maxSlide = options.slides ? Math.max(...Object.keys(options.slides)) : Infinity;
let hasNext = await hasNextSlide(plugin, context);
while (hasNext && context.currentSlide < maxSlide) {
await nextSlide(plugin, context);
2017-08-23 18:04:37 +03:00
await pause(options.pause);
if (options.slides && !options.slides[context.currentSlide]) {
process.stdout.write('\r' + await progressBar(plugin, context, { skip: true }));
2017-08-23 20:37:14 +03:00
} else {
await exportSlide(plugin, page, pdf, context);
2017-08-23 20:37:14 +03:00
}
hasNext = await hasNextSlide(plugin, context);
2017-08-23 18:04:37 +03:00
}
// Flush consolidated fonts
Object.values(context.pdfFonts).forEach(({ ref, font }) => {
pdf.context.assign(ref, pdf.context.flateStream(font.write({ type: 'ttf', hinting: true })));
});
return context;
2017-08-23 18:04:37 +03:00
}
async function exportSlide(plugin, page, pdf, context) {
process.stdout.write('\r' + await progressBar(plugin, context));
const buffer = await page.pdf({
width : options.size.width,
height : options.size.height,
printBackground : true,
pageRanges : '1',
displayHeaderFooter : false,
});
printSlide(pdf, await PDFDocument.load(buffer, { parseSpeed: ParseSpeeds.Fastest }), context);
context.exportedSlides++;
if (options.screenshots) {
for (let resolution of options.screenshotSizes || [options.size]) {
await page.setViewport(resolution);
// Delay page rendering to wait for the resize event to complete,
// e.g. for impress.js (may be needed to be configurable)
await pause(1000);
await page.screenshot({
path: path.join(options.screenshotDirectory, options.filename
.replace('.pdf', `_${context.currentSlide}_${resolution.width}x${resolution.height}.${options.screenshotFormat}`)),
fullPage: false,
omitBackground: true,
});
await page.setViewport(options.size);
await pause(1000);
}
}
2017-08-23 18:04:37 +03:00
}
async function printSlide(pdf, slide, context) {
const [page] = await pdf.copyPages(slide, [0]);
pdf.addPage(page);
parseResources(page.node);
function parseResources(dictionary) {
const resources = dictionary.get(PDFName.of('Resources'));
if (resources.has(PDFName.XObject)) {
const xObject = resources.get(PDFName.XObject);
xObject.entries().forEach(entry => parseXObject(entry, xObject));
}
if (resources.has(PDFName.Font)) {
resources.get(PDFName.Font).entries().forEach(parseFont);
}
}
function parseXObject([name, entry], xObject) {
const object = page.node.context.lookup(entry);
const subtype = object.dict.get(PDFName.of('Subtype'));
if (subtype === PDFName.of('Image')) {
const digest = crypto.createHash('SHA1').update(object.contents).digest('hex');
if (!context.pdfXObjects[digest]) {
context.pdfXObjects[digest] = entry;
} else {
xObject.set(name, context.pdfXObjects[digest]);
pdf.context.delete(entry);
}
} else {
parseResources(object.dict);
}
};
function parseFont([_, entry]) {
const object = page.node.context.lookup(entry);
const subtype = object.get(PDFName.of('Subtype'));
// See "Introduction to Font Data Structures" from PDF specification
if (subtype === PDFName.of('Type0')) {
// TODO: properly support composite fonts with multiple descendants
const descendant = page.node.context.lookup(object.get(PDFName.of('DescendantFonts')).get(0));
if (descendant.get(PDFName.of('Subtype')) === PDFName.of('CIDFontType2')) {
const descriptor = page.node.context.lookup(descendant.get(PDFName.of('FontDescriptor')));
const ref = descriptor.get(PDFName.of('FontFile2'));
const file = page.node.context.lookup(ref);
if (!file) {
// The font has already been processed and removed
return;
}
const bytes = decodePDFRawStream(file).decode();
const font = Font.create(Buffer.from(bytes), { type: 'ttf', hinting: true });
2017-12-19 17:50:44 +03:00
// PDF font name does not contain sub family on Windows 10 so a more robust key
// is computed from the font metadata
const id = descriptor.get(PDFName.of('FontName')).value() + ' - ' + fontMetadataKey(font.data.name);
if (context.pdfFonts[id]) {
const f = context.pdfFonts[id].font;
font.data.glyf.forEach((g, i) => {
if (g.contours && g.contours.length > 0) {
2020-01-02 22:01:47 +03:00
if (!f.data.glyf[i] || !f.data.glyf[i].contours || f.data.glyf[i].contours.length === 0) {
mergeGlyph(f, i, g);
}
} else if (g.compound) {
2020-01-02 22:01:47 +03:00
if (!f.data.glyf[i] || typeof f.data.glyf[i].compound === 'undefined') {
mergeGlyph(f, i, g);
}
}
});
descriptor.set(PDFName.of('FontFile2'), context.pdfFonts[id].ref);
pdf.context.delete(ref);
} else {
context.pdfFonts[id] = { ref: ref, font: font };
}
}
}
};
2020-01-02 22:01:47 +03:00
function mergeGlyph(font, index, glyf) {
if (font.data.glyf.length <= index) {
for (let i = font.data.glyf.length; i < index; i++) {
font.data.glyf.push({ contours: Array(0), advanceWidth: 0, leftSideBearing: 0 });
}
font.data.glyf.push(glyf);
} else {
font.data.glyf[index] = glyf;
}
}
2017-12-19 17:50:44 +03:00
function fontMetadataKey(font) {
const keys = ['fontFamily', 'fontSubFamily', 'fullName', 'preferredFamily', 'preferredSubFamily', 'uniqueSubFamily'];
return Object.entries(font)
.filter(([key, _]) => keys.includes(key))
.reduce((r, [k, v], i) => r + (i > 0 ? ',' : '') + k + '=' + v, '');
}
2017-09-08 18:10:38 +03:00
}
async function hasNextSlide(plugin, context) {
if (typeof plugin.hasNextSlide === 'function') {
2017-08-23 18:04:37 +03:00
return await plugin.hasNextSlide();
} else {
return context.currentSlide < context.totalSlides;
}
2017-08-18 13:51:31 +03:00
}
async function nextSlide(plugin, context) {
context.currentSlide++;
2017-08-23 18:04:37 +03:00
return plugin.nextSlide();
2017-08-18 13:51:31 +03:00
}
// TODO: add progress bar, duration, ETA and file size
async function progressBar(plugin, context, { skip } = { skip : false }) {
2017-08-22 11:48:55 +03:00
const cols = [];
const index = await plugin.currentSlideIndex();
2017-08-23 20:37:14 +03:00
cols.push(`${skip ? 'Skipping' : 'Printing'} slide `);
2017-09-04 11:32:42 +03:00
cols.push(`#${index}`.padEnd(8));
2017-08-22 11:48:55 +03:00
cols.push(' (');
cols.push(`${context.currentSlide}`.padStart(context.totalSlides ? context.totalSlides.toString().length : 3));
2017-08-22 11:48:55 +03:00
cols.push('/');
cols.push(context.totalSlides || ' ?');
2017-08-22 11:48:55 +03:00
cols.push(') ...');
// erase overflowing slide fragments
cols.push(' '.repeat(Math.max(context.progressBarOverflow - Math.max(index.length + 1 - 8, 0), 0)));
context.progressBarOverflow = Math.max(index.length + 1 - 8, 0);
2017-08-22 11:48:55 +03:00
return cols.join('');
2017-08-18 13:51:31 +03:00
}