Source: exporter/exporter.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/** @module */

import {remote, ipcRenderer} from "electron";
import path from "path";
import process from "process";
import * as tmp from "tmp";
import * as fs from "fs";
import {PDFDocument} from "pdf-lib";
import officegen from "officegen";
import {spawnSync} from "child_process";

/** Update the status of a sequence of frame numbers to include or exclude in the export.
 *
 * @param {boolean[]} list - The status of each frame of the presentation.
 * @param {number} first - The index of the first element to update.
 * @param {number} last - The index of the last element to update.
 * @param {number} step - The step between elements to update.
 * @param {boolean} value - The value to assign at each index.
 */
function markInterval(list, first, last, step, value) {
    if (step > 0) {
        for (let i = first; i <= last; i += step) {
            if (i >= 0 && i < list.length) {
                list[i] = value;
            }
        }
    }
}

/** Parse a list of frames to include or exclude from the export.
 *
 * ```
 * expr ::= interval ("," interval)*
 *
 * interval ::=
 *      INT                     // frame number
 *    | INT? ":" INT?           // first:last
 *    | INT? ":" INT? ":" INT?  // first:second:last
 * ```
 *
 * In an interval:
 * - If `first` is omitted, it is set to 1.
 * - If `second` is omitted, it is set to `first + 1`.
 * - If `last` is omitted, it is set to `list.length`.
 *
 * @param {boolean[]} list - The status of each frame of the presentation.
 * @param {string} expr - An expression that represents a list of frames.
 * @param {boolean} value - The value to assign for each frame to mark.
 */
function markFrames(list, expr, value) {
    expr = expr.trim();
    if (!expr.length) {
        expr = value ? "all" : "none";
    }
    switch (expr) {
        case "all":
            markInterval(list, 0, list.length - 1, 1, value);
            break;
        case "none":
            break;
        default:
            for (let intervalDef of expr.split(",")) {
                const interval = intervalDef.split(":").map(s => s.trim());
                if (interval.length > 0) {
                    const first  = interval[0]                        !== "" ? parseInt(interval[0])                   - 1 : 0;
                    const last   = interval[interval.length - 1]      !== "" ? parseInt(interval[interval.length - 1]) - 1 : list.length - 1;
                    const second = interval.length > 2 && interval[1] !== "" ? parseInt(interval[1])                   - 1 : first + 1;
                    if (!isNaN(first) && !isNaN(second) && !isNaN(last)) {
                        markInterval(list, first, last, second - first, value);
                    }
                }
            }
    }
}

/** Mark frames to include or exclude from export.
 *
 * @param {module:model/Presentation.Presentation} pres - The presentation to export.
 * @param {string} include - An expression that represents a list of frames to include.
 * @param {string} exclude - An expression that represents a list of frames to exclude.
 * @returns {boolean[]} - The status of each frame in the presentation.
 */
function markFramesFromLists(pres, include, exclude) {
    const result = new Array(pres.frames.length);
    markFrames(result, "all",   false);
    markFrames(result, include, true);
    markFrames(result, exclude, false);
    return result;
}

/** Get the index of a frame to include in the export, after a given index.
 *
 * @param {boolean[]} list - The status of each frame of the presentation.
 * @param {number} index - The index of the current frame.
 * @returns {number} - The index of the next frame that is marked for inclusion.
 */
function nextFrameIndex(list, index=-1) {
    for (let i = index + 1; i < list.length; i ++) {
        if (list[i]) {
            return i;
        }
    }
    return -1;
}

/** Pad an integer value with zeros.
 *
 * @param {number} value - A value to pad.
 * @param {number} digits - The number of digits in the result.
 * @returns {string} - A zero-padded representation of the given value.
 */
function zeroPadded(value, digits) {
    let result = value.toString();
    while(result.length < digits) {
        result = "0" + result;
    }
    return result;
}

/** The available page sizes for PDF export.
 *
 * Page sizes are in pixels, assuming a resolution of 96 dpi,
 * for each format supported by the `printToPDF` function of the Electron API.
 * All formats are in landscape mode by default.
 *
 * @readonly
 * @type {object} */
const pdfPageGeometry = {
    A3     : {width: 1587, height: 1123},
    A4     : {width: 1123, height: 794},
    A5     : {width: 794 , height: 559},
    Legal  : {width: 1344, height: 816},
    Letter : {width: 1056, height: 816},
    Tabloid: {width: 1632, height: 1056},
};

/** The available slide sizes for PPTX export.
 *
 * Slide sizes are represented by aspect ratios regardless of a physical size.
 * The export function will assume a screen height equal to 1080 pixels.
 * The keys represent all formats supported by the Officegen PPTX API.
 * All formats are in landscape mode by default.
 *
 * @readonly
 * @type {object} */
const pptxSlideGeometry = {
    "35mm"     : {width: 11.25, height: 7.5}, // in
    A3         : {width: 420,   height: 297}, // mm
    A4         : {width: 297,   height: 210}, // mm
    B4ISO      : {width: 353,   height: 250}, // mm
    B4JIS      : {width: 364,   height: 257}, // mm
    B5ISO      : {width: 250,   height: 176}, // mm
    B5JIS      : {width: 257,   height: 182}, // mm
    banner     : {width: 8,     height: 1},   // in
    hagakiCard : {width: 148,   height: 100}, // mm
    ledger     : {width: 17,    height: 11},  // in
    letter     : {width: 11,    height: 8.5}, // in
    overhead   : {width: 10,    height: 7.5}, // in
    screen16x10: {width: 16,    height: 10},  // no unit
    screen16x9 : {width: 16,    height: 9},   // no unit
    screen4x3  : {width: 4,     height: 3}    // no unit
};

/** The default height of PPTX slides, in pixels.
 *
 * @readonly
 * @default
 * @type {number} */
const pptxSlideHeightPx = 1080;

/** Export a presentation to a PDF document.
 *
 * @param {module:model/Presentation.Presentation} presentation - The presentation to export.
 * @param {string} htmlFileName - The name of the presentation HTML file.
 * @returns {Promise} - A promise that resolves when the operation completes.
 */
export async function exportToPDF(presentation, htmlFileName) {
    console.log(`Exporting ${htmlFileName} to PDF`);

    // Mark the list of frames that will be included in the target document.
    const frameSelection = markFramesFromLists(presentation, presentation.exportToPDFInclude, presentation.exportToPDFExclude);

    // Get the PDF page size and swap width and height in protrait orientation.
    let g = pdfPageGeometry[presentation.exportToPDFPageSize];
    if (presentation.exportToPDFPageOrientation === "portrait") {
        g = {width: g.height, height: g.width};
    }

    // Open the HTML presentation in a new browser window.
    const w = new remote.BrowserWindow({
        width : g.width,
        height: g.height,
        frame : false,
        show  : false,
        webPreferences: {
            preload: path.join(__dirname, "exporter-preload.js")
        }
    });
    await w.loadURL(`file://${htmlFileName}`);

    // Create a PDF document object.
    const pdfDoc = await PDFDocument.create();

    const callerId = remote.getCurrentWindow().webContents.id;

    return new Promise((resolve, reject) => {
        // On each jumpToFrame event in the player, save the current web contents.
        ipcRenderer.on("jumpToFrame.done", async (evt, index) => {
            // Convert the current web contents to PDF.
            const pdfData = await w.webContents.printToPDF({
                pageSize:  presentation.exportToPDFPageSize,
                landscape: presentation.exportToPDFPageOrientation === "landscape",
                marginsType: 2, // No margin
            });

            // Add the current PDF page to the document.
            const pdfDocForFrame = await PDFDocument.load(pdfData);
            const [pdfPage]      = await pdfDoc.copyPages(pdfDocForFrame, [0]);
            pdfDoc.addPage(pdfPage);

            // Jump to the next frame.
            const frameIndex = nextFrameIndex(frameSelection, index);
            if (frameIndex >= 0) {
                // If there are frames remaining, move to the next frame.
                w.webContents.send("jumpToFrame", {callerId, frameIndex});
            }
            else {
                // If this is the last frame, close the presentation window
                // and write the PDF document to a file.
                ipcRenderer.removeAllListeners("jumpToFrame.done");
                w.close();

                const pdfFileName = htmlFileName.replace(/html$/, "pdf");
                const pdfBytes    = await pdfDoc.save();
                fs.writeFile(pdfFileName, pdfBytes, err => {
                    if (err) {
                        reject();
                    }
                    else {
                        resolve();
                    }
                });
            }
        });

        // Start the player in the presentation window.
        w.webContents.send("initializeExporter", {callerId, frameIndex: nextFrameIndex(frameSelection)});
    });
}

/** Export a presentation to a PPTX document.
 *
 * @param {module:model/Presentation.Presentation} presentation - The presentation to export.
 * @param {string} htmlFileName - The name of the presentation HTML file.
 * @returns {Promise} - A promise that resolves when the operation completes.
 */
export async function exportToPPTX(presentation, htmlFileName) {
    console.log(`Exporting ${htmlFileName} to PPTX`);

    // Mark the list of frames that will be included in the target document.
    const frameSelection = markFramesFromLists(presentation, presentation.exportToPPTXInclude, presentation.exportToPPTXExclude);

    // Get the PPTX slide size and convert it to pixels.
    const g = pptxSlideGeometry[presentation.exportToPPTXSlideSize];
    g.width  = Math.round(g.width * pptxSlideHeightPx / g.height);
    g.height = pptxSlideHeightPx;

    // Open the HTML presentation in a new browser window.
    // The window must be visible to work with the Page.captureScreenshot
    // message of the Chrome DevTools Protocol.
    const w = new remote.BrowserWindow({
        width : g.width,
        height: g.height,
        frame : false,
        show  : true,
        webPreferences: {
            preload: path.join(__dirname, "exporter-preload.js")
        }
    });
    await w.loadURL(`file://${htmlFileName}`);

    // Create a PPTX document object.
    const pptxDoc = officegen("pptx");
    pptxDoc.setSlideSize(g.width, g.height, presentation.exportToPPTXSlideSize);

    // Create a temporary directory.
    // Force deletion on cleanup, even if not empty.
    const destDir = tmp.dirSync({unsafeCleanup: true});
    console.log("Exporting to " + destDir.name);

    // Generate a list of PNG file names.
    const digits = presentation.frames.length.toString().length;
    const pngFileNames = presentation.frames.map((frame, index) => {
        const indexStr = zeroPadded(index, digits);
        return path.join(destDir.name, `${indexStr}.png`)
    });

    // We will use the Chrome DevTools protocol instead of
    // the unreliable Electron capturePage method.
    w.webContents.debugger.attach("1.2");

    const callerId = remote.getCurrentWindow().webContents.id;

    return new Promise((resolve, reject) => {
        pptxDoc.on("finalize", () => {
            // Remove the temporary image folder, ignoring exceptions on failure.
            try {
                destDir.removeCallback();
            }
            catch (e) {
                console.log(e);
            }
            resolve();
        });
        
        pptxDoc.on("error", () => {
            // Remove the temporary image folder, ignoring exceptions on failure.
            try {
                destDir.removeCallback();
            }
            catch (e) {
                console.log(e);
            }
            reject();
        });

        // On each jumpToFrame event in the player, save the current web contents.
        ipcRenderer.on("jumpToFrame.done", async (evt, index) => {
            // Capture the current web contents into a PNG image file.
            const img = await w.webContents.debugger.sendCommand("Page.captureScreenshot", {format: "png"});
            fs.writeFileSync(pngFileNames[index], Buffer.from(img.data, "base64"));

            // Add the PNG file to its own slide.
            pptxDoc.makeNewSlide().addImage(pngFileNames[index], {x: 0, y: 0, cx: "100%", cy: "100%" });

            // Jump to the next frame.
            const frameIndex = nextFrameIndex(frameSelection, index);
            if (frameIndex >= 0) {
                // If there are frames remaining, move to the next frame.
                w.webContents.send("jumpToFrame", {callerId, frameIndex});
            }
            else {
                // If this is the last frame, close the presentation window
                // and write the PPTX document to a file.
                ipcRenderer.removeAllListeners("jumpToFrame.done");
                w.close();

                const pptxFileName = htmlFileName.replace(/html$/, "pptx");
                const pptxFile = fs.createWriteStream(pptxFileName);
                pptxDoc.generate(pptxFile);
            }
        });

        // Start the player in the presentation window.
        w.webContents.send("initializeExporter", {callerId, frameIndex: nextFrameIndex(frameSelection)});
    });
}

/** Export a presentation to a video document.
 *
 * @param {module:model/Presentation.Presentation} presentation - The presentation to export.
 * @param {string} htmlFileName - The name of the presentation HTML file.
 * @returns {Promise} - A promise that resolves when the operation completes.
 */
 export async function exportToVideo(presentation, htmlFileName) {
     console.log(`Exporting ${htmlFileName} to video`);

     // Open the HTML presentation in a new browser window.
     // The window must be visible to work with the Page.captureScreenshot
     // message of the Chrome DevTools Protocol.
     const w = new remote.BrowserWindow({
         width : presentation.exportToVideoWidth,
         height: presentation.exportToVideoHeight,
         frame : false,
         show  : true,
         webPreferences: {
             preload: path.join(__dirname, "exporter-preload.js")
         }
     });
     await w.loadURL(`file://${htmlFileName}`);

     // Create a temporary directory.
     // Force deletion on cleanup, even if not empty.
     let destDir, destDirName;
     if (presentation.exportToVideoFormat === "png") {
         destDirName = htmlFileName.replace(/\.sozi\.html$/, "-sozi-export");
         if (!fs.existsSync(destDirName)) {
             fs.mkdirSync(destDirName);
         }
     }
     else {
         destDir = tmp.dirSync({unsafeCleanup: true});
         destDirName = destDir.name;
     }

     console.log("Exporting to " + destDirName);

     // We will use the Chrome DevTools protocol instead of
     // the unreliable Electron capturePage method.
     w.webContents.debugger.attach("1.2");

     const callerId = remote.getCurrentWindow().webContents.id;

     return new Promise((resolve, reject) => {
         const timeStepMs = 1000 / presentation.exportToVideoFrameRate;
         const frameCount = presentation.frames.length;
         let imgIndex = 0;

         function onDone() {
             ipcRenderer.removeAllListeners("jumpToFrame.done");
             w.close();

             if (presentation.exportToVideoFormat === "png") {
                 resolve();
                 return;
             }

             const ffmpegOptions = [
                 // Frames per second
                 "-r", presentation.exportToVideoFrameRate,
                 // Convert a sequence of image files
                 "-f", "image2",
                 // The list of image files
                 "-i", path.join(destDirName, "img%d.png"),
                 // The video bit rate
                 "-b:v", presentation.exportToVideoBitRate,
                 // Overwrite the output file without asking
                 "-y",
                 // The name of the output video file
                 htmlFileName.replace(/html$/, presentation.exportToVideoFormat)
             ];

             // Attempt to launch the system-wide FFMPEG executable.
             let res = spawnSync("ffmpeg", ffmpegOptions, {stdio: "inherit"});
             if (res.error) {
                 console.log("No global installation of FFMPEG found. Trying the local FFMPEG executable shipped with Sozi.");
                 // Attempt to launch the local FFMPEG executable.
                 const ffmpegPath = path.join(process.resourcesPath,
                     process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"
                 );
                 res = spawnSync(ffmpegPath, ffmpegOptions, {stdio: "inherit"});
             }

             // Remove the temporary image folder, ignoring exceptions on failure.
             try {
                 destDir.removeCallback();
             }
             catch (e) {
                 console.log(e);
             }

             if (res.error) {
                 console.log("Could not launch FFMPEG.");
                 reject();
             }
             else {
                 console.log("Video export complete.");
                 resolve();
             }
         }

         // On each frame change, capture the web contents and animate the transition.
         ipcRenderer.on("jumpToFrame.done", async (evt, index) => {
             // If we jumped to the first frame after a transition,
             // terminate the video export.
             if (index === 0 && imgIndex > 0) {
                 onDone();
             }

             const currentFrame = presentation.frames[index];

             // Generate images for the duration of the current frame.
             let firstImgFileName;
             for (let timeMs = 0; timeMs < currentFrame.timeoutMs; timeMs += timeStepMs, imgIndex ++) {
                 const imgFileName = path.join(destDirName, `img${imgIndex}.png`)
                 if (timeMs === 0) {
                     // Capture the first image of the current frame.
                     const img = await w.webContents.debugger.sendCommand("Page.captureScreenshot", {format: "png"});
                     fs.writeFileSync(imgFileName, Buffer.from(img.data, "base64"));
                     firstImgFileName = imgFileName;
                 }
                 else {
                     // For the remaining duration of the frame, copy the first image.
                     fs.copyFileSync(firstImgFileName, imgFileName);
                 }
             }

             // Get the index of the next frame.
             const targetIndex = (index + 1) % frameCount;

             // Generate images for the transition to the next frame.
             // If the last frame has a timeout enabled, transition to the first frame.
             // Else terminate the video export.
             if (targetIndex > 0 || currentFrame.timeoutEnable) {
                 w.webContents.send("moveToNext", {callerId, timeStepMs});
             }
             else {
                 onDone();
             }
         });

         // On each animation step, capture the current web contents.
         ipcRenderer.on("moveToNext.step", async evt => {
             const img = await w.webContents.debugger.sendCommand("Page.captureScreenshot", {format: "png"});
             const imgFileName = path.join(destDirName, `img${imgIndex}.png`);
             fs.writeFileSync(imgFileName, Buffer.from(img.data, "base64"));
             w.webContents.send("moveToNext.more");
             imgIndex ++;
         });

         // Start the player in the presentation window.
         w.webContents.send("initializeExporter", {frameIndex: 0, callerId});
     });
}