#!/usr/bin/env node const fs = require('fs').promises; const process = require('process'); const puppeteer = require('puppeteer'); const quality = require('./quality.js'); const uuid = require('uuid').v4; // Size of the rendering of the web page const size = { width: 1280, height: 720 }; // Logging helper function write(stream, chunk, encoding='utf8') { return new Promise((resolve, reject) => { const errListener = (err) => { stream.removeListener('error', errListener); reject(err); }; stream.addListener('error', errListener); const callback = () => { stream.removeListener('error', errListener); resolve(undefined); }; stream.write(chunk, encoding, callback); }); } async function print(data) { await write(process.stdout, data); } async function eprint(data) { await write(process.stderr, data); } async function println(data) { await write(process.stdout, data + '\n'); } async function eprintln(data) { await write(process.stderr, data + '\n'); } async function main() { let outputDir = null; let filename = process.argv[2]; if (process.argv[2] === "-o" || process.argv[2] === "--output") { outputDir = process.argv[3]; filename = process.argv[4]; } try { await fs.mkdir(outputDir); } catch (e) { eprintln("Couldn't create directory " + outputDir + ": " + e); process.exit(1); } if (filename === undefined) { eprintln('This program expects an argument.'); eprintln('USAGE: locator '); process.exit(1); } // Path to the HTML file to analyse (given as relative path from current directory) // We need the full path so that puppeteer is able to access it const path = filename.startsWith('/') ? filename : process.cwd() + '/' + filename; // Check that the file exists try { await fs.access(path, fs.constants.F_OK); } catch (e) { eprintln('No such file: ' + path); process.exit(1); } // Initialize browser const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setViewport(size); await page.goto('file://' + path); // Only consider the first slide (#\\331 === #1, which is the id of the first slide) // We don't take into account the other slides because it will mess up with our screenshot varification let root = await page.$('#\\31'); // If there is no slide, try to run on HTML body if (root === null) { eprintln('Not a marp HTML file.'); process.exit(1); } // Hide slides controls await page.evaluate(() => { for (let elt of document.getElementsByClassName('bespoke-marp-osc')) { elt.style.visibility = "hidden"; } }); // Take a first screenshot await page.screenshot({path: (outputDir === null ? __dirname : outputDir) + '/' + 'screenshot1.png'}); // Edit the page to shrink elements in order to get better bounding boxes let withSpan = await addSpan(root); // Take another screenshot and check the modification we made didn't change the layout of the page await page.screenshot({path: (outputDir === null ? __dirname : outputDir) + '/' + 'screenshot2.png'}); // Compare both screenshots let file1 = await fs.readFile(__dirname + '/' + 'screenshot1.png'); let file2 = await fs.readFile(__dirname + '/' + 'screenshot2.png'); let filesAreSame = file1.map((x, i) => x === file2[i]).reduce((a, b) => a && b, true); if (!filesAreSame) { // Check psnr let psnr = await quality.psnr(__dirname + '/' + 'screenshot1.png', __dirname + '/' + 'screenshot2.png'); // Crash if they're different if (psnr > 70) { eprintln("\x1b[33mWarning: " + filename + " produced slight diff: psnr = " + psnr + '\x1b[0m'); } else { await eprintln("\x1b[31mError: age edit changed the layout: psnr = " + psnr + '\x1b[0m'); process.exit(1); } } // Analyse the root and output the result let analyse = await analyseElement(root, page, outputDir); if (process.argv[3] === '--flatten') { analyse = flatten(analyse); } let json = JSON.stringify(analyse, undefined, 4); if (outputDir === null) { console.log(); } else { await fs.writeFile(outputDir + '/annotations.json', json); } await browser.close(); } // Traverses the text nodes of the element and put every text into a single span. async function addSpan(element) { let elts = await element.$$('*'); for (let elt of elts) { let value = await elt.evaluate(el => el.textContent, element); if (value !== "") { let html = await elt.evaluate(el => { if (el.innerHTML.indexOf('
  • ') === -1 && el.innerHTML.indexOf('

    ') === -1) { let tmp = el.innerHTML; el.innerHTML = '' + el.innerHTML + ''; return [tmp, el.innerHTML]; } else { return null; } }); if (html !== null) { await eprintln("\x1b[36mReplaced " + JSON.stringify(html[0]) + " by " + JSON.stringify(html[1]) + '\x1b[0m'); } } } } // Recursive function to analyse an HTML element. // The output is written in hierarchy. async function analyseElement(element, page, outputDir = null) { // Get some information on the element let tagAttr = await element.getProperty('tagName'); let tagName = await tagAttr.jsonValue(); let classAttr = await element.getProperty('className'); let className = await classAttr.jsonValue(); let textContent = await element.evaluate(el => el.textContent, element); let box = await element.boundingBox(); // Register it into the return value let analyse = {}; analyse.tag = tagName; analyse.class = className; analyse.uuid = uuid(); analyse.box = box; box.x /= size.width; box.width /= size.width; box.y /= size.height; box.height /= size.height; analyse.children = []; if (outputDir !== null) { // Perform a screenshot where the element is hidden let previousVisibility = await element.evaluate(el => { let previousVisibility = el.style.visibility; el.style.visibility = "hidden"; return previousVisibility; }); await page.screenshot({path: outputDir + '/' + analyse.uuid + '.png'}); await element.evaluate((el, previousVisibility) => { el.style.visibility = previousVisibility; }, [ previousVisibility ]); } // Extract the text content if it is a span (we made those spans by ourselves in the addSpan function) if (tagName === 'SPAN' && textContent !== "") { analyse.text = textContent; } else { analyse.text = null; } // Select the children of this HTML element. let children = await element.$$('> *'); for (let child of children) { // Recursively analyse the children analyse.children.push(await analyseElement(child, page, outputDir)); } return analyse; } // Flattens the tree into a list. function flatten(input, acc = []) { let children = input.children; let child = children[0]; delete input["children"]; switch (children.length) { case 0: acc.push(input); break; case 1: if (child.tag === "SPAN" && child.class.indexOf("sized-span") !== -1) { child.tag = input.tag; acc.push(child); break; } // There is purposefully no break here, if the condition above is false, we want to do the default treatment default: acc.push(input); for (let child of children) { flatten(child, acc); } break; } return acc; } main();