~robertgzr/latex-in-a-box

5ad31082a0983044431860927c7b71cdc4d34cd8 — Robert Günzler 9 months ago
initial commit

Signed-off-by: Robert Günzler <r@gnzler.io>
A  => APIKEY.txt +1 -0
@@ 1,1 @@
lol

A  => Dockerfile +27 -0
@@ 1,27 @@
# syntax=localhost/dockerfile:dev

FROM node:lts-slim AS tectonic
ADD --checksum=sha256:977ee60517be0c0bf37176df5e7465c91f22d8b9b213dd2da5b2570e4a34734f \
	https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%400.12.0/tectonic-0.12.0-x86_64-unknown-linux-musl.tar.gz \
    /tectonic.tar.gz
RUN --network=none tar -xzf /tectonic.tar.gz -C /opt

# FROM node:lts-slim AS latexmk
# ADD git+https://git.sr.ht/~robertgzr/latex-toolbox#master /opt/latex-toolbox

FROM node:lts-slim AS pdfjs
ADD --checksum=sha256:b1e42ba479d4a2af0db1a6b815f3e2c028c9121c4a11d49faf52afb0003a89c2 \
    https://github.com/mozilla/pdf.js/releases/download/v3.6.172/pdfjs-3.6.172-dist.zip \
    /pdfjs.zip
RUN --network=default apt-get update -y && apt-get install -y unzip
RUN --network=none unzip -d /opt/pdfjs /pdfjs.zip


FROM docker.io/etherpad/etherpad:latest
COPY --from=tectonic /opt/tectonic /usr/local/bin/
COPY --from=pdfjs /opt/pdfjs /opt/etherpad-lite/node_modules/ep_latex/static/pdfjs
# COPY --from=latexmk /opt/latex-toolbox/scripts/latexmk /usr/local/bin/

COPY ep_latex /opt/etherpad-lite/node_modules/ep_latex
COPY APIKEY.txt /opt/etherpad-lite/APIKEY.txt
COPY settings.json /opt/etherpad-lite/settings.json

A  => Makefile +3 -0
@@ 1,3 @@
run:
	nerdctl build -t latex-in-a-box -f Dockerfile .
	nerdctl run --rm -it --name ep -p 9001:9001 latex-in-a-box

A  => ep_latex/.gitignore +2 -0
@@ 1,2 @@
node_modules/
package-lock.json

A  => ep_latex/TODO_reloads +7 -0
@@ 1,7 @@
The default reload behavior of the pdf.js viewer is indeed to preserve the scroll position (and some other GUI states) if the file is exactly the same. However, it doesn't check the complete file contents (via a hash or fingerprint) but relies on the document ID that is typically (but not necessarily) embedded in a PDF file (see 14.4 in the specification).

In my case, the (contentwise slightly different) PDFs are generated by pdflatex / xelatex / lualatex, and apparently they generate the PDF ID based on the current time and the pathname of the document. Therefore, even if I don't change anything in the LaTeX document, just process it again to generate a PDF, this PDF has a different ID, and the pdf.js viewer does not treat it as the same document (preserving the scroll position), but as a new one (starting with the scroll position at the top).

Fortunately, there is a way around this. If the environment variable SOURCE_DATE_EPOCH is set to a Unix time stamp (number of seconds since 1 Jan 1970 00:00 UTC), then this time will be used for the PDF ID instead of the current time. Since the pathname of the document doesn't change either, this ensures an identical ID across regenerations of the PDF, and therefore the viewer shows its scroll-preserving behavior on reload.

https://github.com/mozilla/pdf.js/issues/11359

A  => ep_latex/debounce.js +156 -0
@@ 1,156 @@
const nativeMax = Math.max,
    nativeMin = Math.min,
    now = Date.now;

function isObject(value) {
    var type = typeof value;
    return value != null && (type == 'object' || type == 'function');
}

function toNumber(value) {
    if (typeof value == 'number') {
        return value;
    }
    if (isSymbol(value)) {
        return NAN;
    }
    if (isObject(value)) {
        var other = typeof value.valueOf == 'function' ? value.valueOf() : value;
        value = isObject(other) ? (other + '') : other;
    }
    if (typeof value != 'string') {
        return value === 0 ? value : +value;
    }
    value = value.replace(reTrim, '');
    var isBinary = reIsBinary.test(value);
    return (isBinary || reIsOctal.test(value)) ?
        freeParseInt(value.slice(2), isBinary ? 2 : 8) :
        (reIsBadHex.test(value) ? NAN : +value);
}

function debounce(func, wait, options) {
    var lastArgs,
        lastThis,
        maxWait,
        result,
        timerId,
        lastCallTime,
        lastInvokeTime = 0,
        leading = false,
        maxing = false,
        trailing = true;

    if (typeof func != 'function') {
        throw new TypeError(FUNC_ERROR_TEXT);
    }
    wait = toNumber(wait) || 0;
    if (isObject(options)) {
        leading = !!options.leading;
        maxing = 'maxWait' in options;
        maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
        trailing = 'trailing' in options ? !!options.trailing : trailing;
    }

    function invokeFunc(time) {
        var args = lastArgs,
            thisArg = lastThis;

        lastArgs = lastThis = undefined;
        lastInvokeTime = time;
        result = func.apply(thisArg, args);
        return result;
    }

    function leadingEdge(time) {
        // Reset any `maxWait` timer.
        lastInvokeTime = time;
        // Start the timer for the trailing edge.
        timerId = setTimeout(timerExpired, wait);
        // Invoke the leading edge.
        return leading ? invokeFunc(time) : result;
    }

    function remainingWait(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime,
            timeWaiting = wait - timeSinceLastCall;

        return maxing ?
            nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) :
            timeWaiting;
    }

    function shouldInvoke(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime;

        // Either this is the first call, activity has stopped and we're at the
        // trailing edge, the system time has gone backwards and we're treating
        // it as the trailing edge, or we've hit the `maxWait` limit.
        return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
            (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
    }

    function timerExpired() {
        var time = now();
        if (shouldInvoke(time)) {
            return trailingEdge(time);
        }
        // Restart the timer.
        timerId = setTimeout(timerExpired, remainingWait(time));
    }

    function trailingEdge(time) {
        timerId = undefined;

        // Only invoke if we have `lastArgs` which means `func` has been
        // debounced at least once.
        if (trailing && lastArgs) {
            return invokeFunc(time);
        }
        lastArgs = lastThis = undefined;
        return result;
    }

    function cancel() {
        if (timerId !== undefined) {
            clearTimeout(timerId);
        }
        lastInvokeTime = 0;
        lastArgs = lastCallTime = lastThis = timerId = undefined;
    }

    function flush() {
        return timerId === undefined ? result : trailingEdge(now());
    }

    function debounced() {
        var time = now(),
            isInvoking = shouldInvoke(time);

        lastArgs = arguments;
        lastThis = this;
        lastCallTime = time;

        if (isInvoking) {
            if (timerId === undefined) {
                return leadingEdge(lastCallTime);
            }
            if (maxing) {
                // Handle invocations in a tight loop.
                clearTimeout(timerId);
                timerId = setTimeout(timerExpired, wait);
                return invokeFunc(lastCallTime);
            }
        }
        if (timerId === undefined) {
            timerId = setTimeout(timerExpired, wait);
        }
        return result;
    }
    debounced.cancel = cancel;
    debounced.flush = flush;
    return debounced;
}

exports.debounce = debounce;

A  => ep_latex/ep.json +21 -0
@@ 1,21 @@
{
	"parts": [
		{
			"name": "ep_latex",
			"client_hooks": {
				"postAceInit": "ep_latex/static/js/latex",
				"postToolbarInit": "ep_latex/static/js/latex",
				"handleClientMessage_CUSTOM": "ep_latex/static/js/latex"
			},
			"hooks": {
				"expressConfigure": "ep_latex/latex",
				"eejsBlock_styles": "ep_latex/latex",
				"padInitToolbar": "ep_latex/latex",
				"padLoad": "ep_latex/latex",
				"padUpdate": "ep_latex/latex",
				"handleMessage": "ep_latex/latex",
				"socketio": "ep_latex/latex"
			}
		}
	]
}

A  => ep_latex/latex.js +173 -0
@@ 1,173 @@
'use strict';

const {
    join
} = require('node:path');

const PLUGIN_NAME = 'ep_latex'
const logger = require('ep_etherpad-lite/node_modules/log4js').getLogger(PLUGIN_NAME);
const express = require('ep_etherpad-lite/node_modules/express');
const bodyParser = require('ep_etherpad-lite/node_modules/body-parser');

const dbAPI = require('ep_etherpad-lite/node/db/API');
const eejs = require('ep_etherpad-lite/node/eejs/');
const padMessageHandler = require('ep_etherpad-lite/node/handler/PadMessageHandler');
const settings = require('ep_etherpad-lite/node/utils/Settings');

const _ = require('./debounce');
const tectonic = require('./tectonic');

let io = null;
exports.socketio = (hook, context, cb) => {
    io = context.io;
    return cb(true);
}

const sendMessage = (padId, data) => {
    const time = Date.now();
    const msg = {
        type: 'COLLABROOM',
        data: {
            type: 'CUSTOM',
            payload: {
                data: data,
            },
            time,
        },
    };
    logger.debug('sending message', msg.data.payload.data);
    io.sockets.in(padId).json.send(msg);
};

const staticPath = (f) => join('/ep_latex/build', f);
exports.expressConfigure = async (hook, {
    app
}) => {
    app.use(staticPath('.'), express.static(tectonic.buildDir()));
    app.use(bodyParser.raw({
        inflate: true,
        type: 'application/zip'
    }));
    app.post('/ep_latex/import/:padId', async (req, res) => {
        await tectonic.import(logger, req.params.padId, req.body);
        res.sendStatus(200);
    });
}

exports.eejsBlock_styles = function(hook, context, cb) {
    if (context.renderContext.isReadOnly) return cb();
    context.content += eejs.require('ep_latex/templates/styles.html');
    return cb(true);
};

exports.padInitToolbar = (hook, {
    toolbar
}, cb) => {
    const renderButton = toolbar.button({
        command: 'latexRender',
        localizationId: 'ep_latex.toolbar.latexRender.title',
        class: 'buttonicon buttonicon-latexRender ep_latex',
    });
    const uploadButton = toolbar.button({
        command: 'latexUpload',
        localizationId: 'ep_latex.toolbar.latexUpload.title',
        class: 'buttonicon buttonicon-latexUpload ep_latex',
    });
    const downloadButton = toolbar.button({
        command: 'latexDownload',
        localizationId: 'ep_latex.toolbar.latexDownload.title',
        class: 'buttonicon buttonicon-latexDownload ep_latex',
    });
    const hidePdfButton = toolbar.button({
        command: 'latexHidePdf',
        localizationId: 'ep_latex.toolbar.latexHidePdf.title',
        class: 'buttonicon buttonicon-latexHidePdf ep_latex',
    });

    toolbar.registerButton('latexrender', renderButton);
    toolbar.registerButton('latexupload', uploadButton);
    toolbar.registerButton('latexdownload', downloadButton);
    toolbar.registerButton('latexhidepdf', hidePdfButton);

    return cb(true);
};

const handleBuild = _.debounce((padId, padContents) => {
        logger.debug(`building (${padId})`);
        tectonic.build(logger, padId, padContents);
        logger.debug(`messaging client (${padId})`);
        sendMessage(padId, {
            type: 'EPLATEX_RENDER_DONE',
            data: {
                uri: staticPath(`${padId}`),
            }
        });
    },
    1000, {
        maxWait: 5000,
        leading: true,
        trailing: true,
    });

exports.padLoad = (hook, {
    pad
}, cb) => {
    logger.debug(`new pad ${pad.id}`);
    handleBuild(pad.id, pad.text());
    return cb(true);
}

exports.padUpdate = (hook, {
    pad,
    author
}, cb) => {
    logger.debug(`pad ${pad.id} updated by ${author}`);
    handleBuild(pad.id, pad.text());
    return cb(true);
}

const syncPadToTectonic = async (padId) => {
    await dbAPI.setText(padId, tectonic.getText(padId));
}

exports.handleMessage = (hook, context, cb) => {
    if (context.message.type === 'CUSTOM') {
        const padId = padMessageHandler.sessioninfos[context.socket.id].padId;
        const payload = context.message.data;
        logger.debug('received message:', payload);

        switch (payload.type) {
            case 'EPLATEX_RENDER':
                handleBuild(padId, null);
                return cb(null);

            case 'EPLATEX_UPLOAD_PING':
                sendMessage(padId, {
                    type: 'EPLATEX_UPLOAD_PONG',
                    data: {
                        url: '/ep_latex/import/' + padId,
                    },
                });
                return cb(null);

            case 'EPLATEX_UPLOAD_DONE':
                syncPadToTectonic(padId);
                return cb(null);

            case 'EPLATEX_DOWNLOAD_PING':
                tectonic.export(logger, padId).then(({
                    name
                }) => {
                    sendMessage(padId, {
                        type: 'EPLATEX_DOWNLOAD_PONG',
                        data: {
                            name: name,
                            url: staticPath(name),
                        },
                    })
                });
                return cb(null)
        }
    }
    return cb(true);
}

A  => ep_latex/locales/en.json +5 -0
@@ 1,5 @@
{
	"ep_latex.toolbar.latexRender.title": "Render PDF",
	"ep_latex.toolbar.latexUpload.title": "Upload project",
	"ep_latex.toolbar.latexDownload.title": "Download project"
}

A  => ep_latex/package.json +13 -0
@@ 1,13 @@
{
	"name": "ep_latex",
	"version": "0.1.0",
	"author": "robertgzr <r@gnzler.io>",
	"contributors": [],
	"devDependencies": {
		"browser-fs-access": "^0.33.1",
		"jszip": "^3.10.1"
	},
	"scripts": {
		"preinstall": "node ./scripts/preinstall.js"
	}
}

A  => ep_latex/scripts/preinstall.js +11 -0
@@ 1,11 @@
const fs = require('node:fs');
try {
    fs.mkdirSync('static/js/vendor');
    fs.cpSync('node_modules/browser-fs-access', 'static/js/vendor/browser-fs-access', {
        dereference: true,
        recursive: true,
    });

} catch (e) {
    console.error(e);
}

A  => ep_latex/static/css/styles.css +15 -0
@@ 1,15 @@
.buttonicon-latexRender::before {
    content: "\e800"
}

.buttonicon-latexUpload::before {
    content: "\e857"
}

.buttonicon-latexDownload::before {
    content: "\e858"
}

.buttonicon-latexHidePdf::before {
    content: "\e859"
}

A  => ep_latex/static/js/latex.js +140 -0
@@ 1,140 @@
'use strict';

const padcookie = require('ep_etherpad-lite/static/js/pad_cookie').padcookie;

const browserFsAccess = require('./vendor/browser-fs-access/dist/index.cjs');

let clientPad = null;
const clientSendMessage = (msg) => {
    const getSocket = () => clientPad && clientPad.socket;
    getSocket().json.send({
        type: 'CUSTOM',
        component: 'pad',
        data: msg,
    });
};

const embedViewer = (url) => {
    const $viewer = $('iframe#pdfjs');
    if ($viewer.length === 0) {
        $('#editorcontainerbox').append(`<iframe id="pdfjs" src="/static/plugins/ep_latex/static/pdfjs/web/viewer.html?file=${encodeURIComponent(url)}" style="width:40vw;height:100%;border:none;"></iframe>`);
    } else {
        const viewer = $viewer[0].contentWindow;
        console.log('viewer', viewer);
        viewer.PDFViewerApplication.open({
            url
        });
        //
        // // Binary pdf contents stored in data
        // let doc = viewer.PDFViewerApplication.pdfDocument;
        // // Override the fingerprint after parsing but before passing to the view layer.
        // // This means that the view state (page, scroll offset, etc.) is preserved.
        // doc._pdfInfo.fingerprint = 'constantFingerprint';
        // viewer.PDFViewerApplication.load(doc);
    }
};

exports.postAceInit = (hook, context) => {
    const $outer = $('iframe[name="ace_outer"]').contents().find('iframe');
    const $inner = $outer.contents().find('#innerdocbody');

    clientPad = context.pad;
    return true;
};

exports.postToolbarInit = (hook, context) => {
    const toolbar = context.toolbar; // toolbar is actually editbar - http://etherpad.org/doc/v1.5.7/#index_editbar

    toolbar.registerCommand('latexRender', () => {
        clientSendMessage({
            type: 'EPLATEX_RENDER',
        });
    });

    toolbar.registerCommand('latexUpload', () => {
        clientSendMessage({
            type: 'EPLATEX_UPLOAD_PING',
        });
    });

    toolbar.registerCommand('latexDownload', () => {
        clientSendMessage({
            type: 'EPLATEX_DOWNLOAD_PING',
        });
    });

    toolbar.registerCommand('latexHidePdf', () => {
        const $viewer = $('iframe#pdfjs');
        if ($viewer.length === 0) {
            return;
        }
        if ($viewer.css('display') === 'none') {
            $viewer.css('display', 'inherit');
        } else {
            $viewer.css('display', 'none');
        }
    });

    return true;
};

exports.handleClientMessage_CUSTOM = (hook, {
    payload
}) => {
    if (!payload.data) return;
    console.debug('received message:', payload.data);

    switch (payload.data.type) {
        case 'EPLATEX_PING':
            clientSendMessage({
                type: 'EPLATEX_PONG',
            });
            break;

        case 'EPLATEX_RENDER_DONE':
            embedViewer(payload.data.data.uri);
            break;

        case 'EPLATEX_UPLOAD_PONG': {
            browserFsAccess.fileOpen({
                    mimeTypes: ['application/zip'],
                    multiple: false
                })
                .then(blob => {
                    fetch(payload.data.data.url, {
                            method: 'POST',
                            headers: {
                                "content-type": blob.type,
                            },
                            body: blob,
                        })
                        .then(res => {
                            if (!res.ok) {
                                console.error(res);
                                return;
                            }
                            clientSendMessage({
                                type: 'EPLATEX_UPLOAD_DONE',
                            });
                        })
                });
            break;
        }

        case 'EPLATEX_DOWNLOAD_PONG': {
            const {
                name,
                url
            } = payload.data.data;

            fetch(url)
                .then((blob) => {
                    browserFsAccess.fileSave(blob, {
                        fileName: name,
                        extensions: ['.zip']
                    });
                })
            break;
        }
    }
};

A  => ep_latex/static/js/vendor/browser-fs-access/LICENSE +201 -0
@@ 1,201 @@
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.

A  => ep_latex/static/js/vendor/browser-fs-access/README.md +270 -0
@@ 1,270 @@
# Browser-FS-Access

This module allows you to easily use the
[File System Access API](https://wicg.github.io/file-system-access/) on supporting browsers,
with a transparent fallback to the `<input type="file">` and `<a download>` legacy methods.
This library is a [ponyfill](https://ponyfill.com/).

Read more on the background of this module in my post
[Progressive Enhancement In the Age of Fugu APIs](https://blog.tomayac.com/2020/01/23/progressive-enhancement-in-the-age-of-fugu-apis/).

## Live Demo

Try the library in your browser: https://googlechromelabs.github.io/browser-fs-access/demo/.

## Installation

You can install the module with npm.

```bash
npm install --save browser-fs-access
```

## Usage Examples

The module feature-detects support for the File System Access API and
only loads the actually relevant code.

### Importing what you need

Import only the features that you need. In the code sample below, all
features are loaded. The imported methods will use the File System
Access API or a fallback implementation.

```js
import {
  fileOpen,
  directoryOpen,
  fileSave,
  supported,
} from 'https://unpkg.com/browser-fs-access';
```

### Feature detection

You can check `supported` to see if the File System Access API is
supported.

```js
if (supported) {
  console.log('Using the File System Access API.');
} else {
  console.log('Using the fallback implementation.');
}
```

### Opening a file

```js
const blob = await fileOpen({
  mimeTypes: ['image/*'],
});
```

### Opening multiple files

```js
const blobs = await fileOpen({
  mimeTypes: ['image/*'],
  multiple: true,
});
```

### Opening files of different MIME types

```js
const blobs = await fileOpen([
  {
    description: 'Image files',
    mimeTypes: ['image/jpg', 'image/png', 'image/gif', 'image/webp'],
    extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
    multiple: true,
  },
  {
    description: 'Text files',
    mimeTypes: ['text/*'],
    extensions: ['.txt'],
  },
]);
```

### Opening all files in a directory

Optionally, you can recursively include subdirectories.

```js
const blobsInDirectory = await directoryOpen({
  recursive: true,
});
```

### Saving a file

```js
await fileSave(blob, {
  fileName: 'Untitled.png',
  extensions: ['.png'],
});
```

### Saving a `Response` that will be streamed

```js
const response = await fetch('foo.png');
await fileSave(response, {
  fileName: 'foo.png',
  extensions: ['.png'],
});
```

### Saving a `Promise<Blob>` that will be streamed.

No need to `await` the `Blob` to be created.

```js
const blob = createBlobAsyncWhichMightTakeLonger(someData);
await fileSave(blob, {
  fileName: 'Untitled.png',
  extensions: ['.png'],
});
```

## API Documentation

### Opening files:

```js
// Options are optional. You can pass an array of options, too.
const options = {
  // List of allowed MIME types, defaults to `*/*`.
  mimeTypes: ['image/*'],
  // List of allowed file extensions (with leading '.'), defaults to `''`.
  extensions: ['.png', '.jpg', '.jpeg', '.webp'],
  // Set to `true` for allowing multiple files, defaults to `false`.
  multiple: true,
  // Textual description for file dialog , defaults to `''`.
  description: 'Image files',
  // Suggested directory in which the file picker opens. A well-known directory, or a file or directory handle.
  startIn: 'downloads',
  // By specifying an ID, the user agent can remember different directories for different IDs.
  id: 'projects',
  // Include an option to not apply any filter in the file picker, defaults to `false`.
  excludeAcceptAllOption: true,
};

const blobs = await fileOpen(options);
```

### Opening directories:

```js
// Options are optional.
const options = {
  // Set to `true` to recursively open files in all subdirectories, defaults to `false`.
  recursive: true,
  // Open the directory with `"read"` or `"readwrite"` permission, defaults to `"read"`.
  mode:
  // Suggested directory in which the file picker opens. A well-known directory, or a file or directory handle.
  startIn: 'downloads',
  // By specifying an ID, the user agent can remember different directories for different IDs.
  id: 'projects',
  // Callback to determine whether a directory should be entered, return `true` to skip.
  skipDirectory: (entry) => entry.name[0] === '.',
};

const blobs = await directoryOpen(options);
```

The module also polyfills a [`webkitRelativePath`](https://developer.mozilla.org/en-US/docs/Web/API/File/webkitRelativePath) property on returned files in a consistent way, regardless of the underlying implementation.

### Saving files:

```js
// Options are optional. You can pass an array of options, too.
const options = {
  // Suggested file name to use, defaults to `''`.
  fileName: 'Untitled.txt',
  // Suggested file extensions (with leading '.'), defaults to `''`.
  extensions: ['.txt'],
  // Suggested directory in which the file picker opens. A well-known directory, or a file or directory handle.
  startIn: 'downloads',
  // By specifying an ID, the user agent can remember different directories for different IDs.
  id: 'projects',
  // Include an option to not apply any filter in the file picker, defaults to `false`.
  excludeAcceptAllOption: true,
};

// Optional file handle to save back to an existing file.
// This will only work with the File System Access API.
// Get a `FileHandle` from the `handle` property of the `Blob`
// you receive from `fileOpen()` (this is non-standard).
const existingHandle = previouslyOpenedBlob.handle;

// Optional flag to determine whether to throw (rather than open a new file
// save dialog) when `existingHandle` is no longer good, for example, because
// the underlying file was deleted. Defaults to `false`.
const throwIfExistingHandleNotGood = true;

// `blobOrPromiseBlobOrResponse` is a `Blob`, a `Promise<Blob>`, or a `Response`.
await fileSave(
  blobOrResponseOrPromiseBlob,
  options,
  existingHandle,
  throwIfExistingHandleNotGood
);
```

### File operations and exceptions

The File System Access API supports exceptions, so apps can throw when problems occur (permissions
not granted, out of disk space,…), or when the user cancels the dialog. The legacy methods,
unfortunately, do not support exceptions (albeit there is an
[HTML issue](https://github.com/whatwg/html/issues/6376) open for this request). If your app depends
on exceptions, see the file
[`index.d.ts`](https://github.com/GoogleChromeLabs/browser-fs-access/blob/main/index.d.ts) for the
documentation of the `legacySetup` parameter.

## Browser-FS-Access in Action

You can see the module in action in the [Excalidraw](https://excalidraw.com/) drawing app.

![excalidraw](https://user-images.githubusercontent.com/145676/73060246-b4a64200-3e97-11ea-8f70-fa5edd63f78e.png)

It also powers the [SVGcode](https://svgco.de/) app that converts raster images to SVGs.

![svgcode](https://github.com/tomayac/SVGcode/raw/main/public/screenshots/desktop.png)

## Alternatives

A similar, but more extensive library called
[native-file-system-adapter](https://github.com/jimmywarting/native-file-system-adapter/)
is provided by [@jimmywarting](https://github.com/jimmywarting).

## Ecosystem

If you are looking for a similar solution for dragging and dropping of files,
check out [@placemarkio/flat-drop-files](https://github.com/placemark/flat-drop-files).

## Acknowledgements

Thanks to [@developit](https://github.com/developit)
for improving the dynamic module loading
and [@dwelle](https://github.com/dwelle) for the helpful feedback,
issue reports, and the Windows build fix.
Directory operations were made consistent regarding `webkitRelativePath`
and parallelized and sped up significantly by
[@RReverser](https://github.com/RReverser).
The TypeScript type annotations were initially provided by
[@nanaian](https://github.com/nanaian).
Dealing correctly with cross-origin iframes was contributed by
[@nikhilbghodke](https://github.com/nikhilbghodke) and
[@kbariotis](https://github.com/kbariotis).
The exception handling of the legacy methods was contributed by
[@jmrog](https://github.com/jmrog).
The streams and blob saving was improved by [@tmcw](https://github.com/tmcw).

## License and Note

Apache 2.0.

This is not an official Google product.

A  => ep_latex/static/js/vendor/browser-fs-access/dist/index.cjs +1 -0
@@ 1,1 @@
var e=function(){if("undefined"==typeof self)return!1;if("top"in self&&self!==top)try{top}catch(e){return!1}return"showOpenFilePicker"in self}(),n=e?Promise.resolve().then(function(){return u}):Promise.resolve().then(function(){return P}),t=e?Promise.resolve().then(function(){return p}):Promise.resolve().then(function(){return b}),r=e?Promise.resolve().then(function(){return m}):Promise.resolve().then(function(){return _}),i=function(e){try{return Promise.resolve(e.getFile()).then(function(n){return n.handle=e,n})}catch(e){return Promise.reject(e)}},o=function(e){void 0===e&&(e=[{}]);try{Array.isArray(e)||(e=[e]);var n=[];return e.forEach(function(e,t){n[t]={description:e.description||"Files",accept:{}},e.mimeTypes?e.mimeTypes.map(function(r){n[t].accept[r]=e.extensions||[]}):n[t].accept["*/*"]=e.extensions||[]}),Promise.resolve(window.showOpenFilePicker({id:e[0].id,startIn:e[0].startIn,types:n,multiple:e[0].multiple||!1,excludeAcceptAllOption:e[0].excludeAcceptAllOption||!1})).then(function(n){return Promise.resolve(Promise.all(n.map(i))).then(function(n){return e[0].multiple?n:n[0]})})}catch(e){return Promise.reject(e)}},u={__proto__:null,default:o};function c(e){function n(e){if(Object(e)!==e)return Promise.reject(new TypeError(e+" is not an object."));var n=e.done;return Promise.resolve(e.value).then(function(e){return{value:e,done:n}})}return c=function(e){this.s=e,this.n=e.next},c.prototype={s:null,n:null,next:function(){return n(this.n.apply(this.s,arguments))},return:function(e){var t=this.s.return;return void 0===t?Promise.resolve({value:e,done:!0}):n(t.apply(this.s,arguments))},throw:function(e){var t=this.s.return;return void 0===t?Promise.reject(e):n(t.apply(this.s,arguments))}},new c(e)}function s(e,n,t){if(!e.s){if(t instanceof a){if(!t.s)return void(t.o=s.bind(null,e,n));1&n&&(n=t.s),t=t.v}if(t&&t.then)return void t.then(s.bind(null,e,n),s.bind(null,e,2));e.s=n,e.v=t;var r=e.o;r&&r(e)}}var l=function e(n,t,r,i){try{var o=function(e){return Promise.resolve(Promise.all(l)).then(function(e){var n=e.flat();return Promise.resolve(Promise.all(h)).then(function(e){return[].concat(n,e)})})};void 0===r&&(r=n.name);var u,l=[],h=[],p=!1,d=!1,m=v(function(){return function(o,u){try{var v=function(){var o,u=function(e){var n,t,r,i=2;for("undefined"!=typeof Symbol&&(t=Symbol.asyncIterator,r=Symbol.iterator);i--;){if(t&&null!=(n=e[t]))return n.call(e);if(r&&null!=(n=e[r]))return new c(n.call(e));t="@@asyncIterator",r="@@iterator"}throw new TypeError("Object is not async iterable")}(n.values()),v=function(e,n,t){for(var r;;){var i=e();if(f(i)&&(i=i.v),!i)return o;if(i.then){r=0;break}var o=t();if(o&&o.then){if(!f(o)){r=1;break}o=o.s}if(n){var u=n();if(u&&u.then&&!f(u)){r=2;break}}}var c=new a,l=s.bind(null,c,2);return(0===r?i.then(h):1===r?o.then(v):u.then(p)).then(void 0,l),c;function v(r){o=r;do{if(n&&(u=n())&&u.then&&!f(u))return void u.then(p).then(void 0,l);if(!(i=e())||f(i)&&!i.v)return void s(c,1,o);if(i.then)return void i.then(h).then(void 0,l);f(o=t())&&(o=o.v)}while(!o||!o.then);o.then(v).then(void 0,l)}function h(e){e?(o=t())&&o.then?o.then(v).then(void 0,l):v(o):s(c,1,o)}function p(){(i=e())?i.then?i.then(h).then(void 0,l):h(i):s(c,1,o)}}(function(){return Promise.resolve(u.next()).then(function(e){return p=!(o=e).done})},function(){return!!(p=!1)},function(){var u=o.value,c=r+"/"+u.name;"file"===u.kind?h.push(u.getFile().then(function(e){return e.directoryHandle=n,e.handle=u,Object.defineProperty(e,"webkitRelativePath",{configurable:!0,enumerable:!0,get:function(){return c}})})):"directory"!==u.kind||!t||i&&i(u)||l.push(e(u,t,c,i))});if(v&&v.then)return v.then(function(){})}()}catch(e){return u(e)}return v&&v.then?v.then(void 0,u):v}(0,function(e){d=!0,u=e})},function(e,n){function t(t){if(e)throw n;return n}var r=v(function(){var e=function(){if(p&&null!=_iterator.return)return Promise.resolve(_iterator.return()).then(function(){})}();if(e&&e.then)return e.then(function(){})},function(e,n){if(d)throw u;if(e)throw n;return n});return r&&r.then?r.then(t):t()});return Promise.resolve(m&&m.then?m.then(o):o())}catch(e){return Promise.reject(e)}};const a=/*#__PURE__*/function(){function e(){}return e.prototype.then=function(n,t){const r=new e,i=this.s;if(i){const e=1&i?n:t;if(e){try{s(r,1,e(this.v))}catch(e){s(r,2,e)}return r}return this}return this.o=function(e){try{const i=e.v;1&e.s?s(r,1,n?n(i):i):t?s(r,1,t(i)):s(r,2,i)}catch(e){s(r,2,e)}},r},e}();function f(e){return e instanceof a&&1&e.s}function v(e,n){try{var t=e()}catch(e){return n(!0,e)}return t&&t.then?t.then(n.bind(null,!1),n.bind(null,!0)):n(!1,t)}var h=function(e){void 0===e&&(e={});try{return e.recursive=e.recursive||!1,e.mode=e.mode||"read",Promise.resolve(window.showDirectoryPicker({id:e.id,startIn:e.startIn,mode:e.mode})).then(function(n){return Promise.resolve(n.values()).then(function(t){return Promise.resolve(t.next()).then(function(t){return t.done?[n]:l(n,e.recursive,void 0,e.skipDirectory)})})})}catch(e){return Promise.reject(e)}},p={__proto__:null,default:h},d=function(e,n,t,r,i){void 0===n&&(n=[{}]),void 0===t&&(t=null),void 0===r&&(r=!1),void 0===i&&(i=null);try{var o=function(r){function o(n){return!t&&i&&i(n),Promise.resolve(n.createWritable()).then(function(t){var r;function i(i){if(r)return i;var o=t.write;return Promise.resolve(e).then(function(e){return Promise.resolve(o.call(t,e)).then(function(){return Promise.resolve(t.close()).then(function(){return n})})})}var o=function(){if("stream"in e){var i=e.stream();return Promise.resolve(i.pipeTo(t)).then(function(){return r=1,n})}return function(){if("body"in e)return Promise.resolve(e.body.pipeTo(t)).then(function(){return r=1,n})}()}();return o&&o.then?o.then(i):i(o)})}return t?o(t):Promise.resolve(window.showSaveFilePicker({suggestedName:n[0].fileName,id:n[0].id,startIn:n[0].startIn,types:u,excludeAcceptAllOption:n[0].excludeAcceptAllOption||!1})).then(o)};Array.isArray(n)||(n=[n]),n[0].fileName=n[0].fileName||"Untitled";var u=[],c=null;e instanceof Blob&&e.type?c=e.type:e.headers&&e.headers.get("content-type")&&(c=e.headers.get("content-type")),n.forEach(function(e,n){u[n]={description:e.description||"Files",accept:{}},e.mimeTypes?(0===n&&c&&e.mimeTypes.push(c),e.mimeTypes.map(function(t){u[n].accept[t]=e.extensions||[]})):c?u[n].accept[c]=e.extensions||[]:u[n].accept["*/*"]=e.extensions||[]});var s=function(){if(t)return function(e,n){try{var r=Promise.resolve(t.getFile()).then(function(){})}catch(e){return n(e)}return r&&r.then?r.then(void 0,n):r}(0,function(e){if(t=null,r)throw e})}();return Promise.resolve(s&&s.then?s.then(o):o())}catch(e){return Promise.reject(e)}},m={__proto__:null,default:d},y=function(e){void 0===e&&(e=[{}]);try{return Array.isArray(e)||(e=[e]),Promise.resolve(new Promise(function(n,t){var r=document.createElement("input");r.type="file";var i=[].concat(e.map(function(e){return e.mimeTypes||[]}),e.map(function(e){return e.extensions||[]})).join();r.multiple=e[0].multiple||!1,r.accept=i||"",r.style.display="none",document.body.append(r);var o=function(e){"function"==typeof u&&u(),n(e)},u=e[0].legacySetup&&e[0].legacySetup(o,function(){return u(t)},r),c=function e(){window.removeEventListener("focus",e),r.remove()};r.addEventListener("click",function(){window.addEventListener("focus",c)}),r.addEventListener("change",function(){window.removeEventListener("focus",c),r.remove(),o(r.multiple?Array.from(r.files):r.files[0])}),"showPicker"in HTMLInputElement.prototype?r.showPicker():r.click()}))}catch(e){return Promise.reject(e)}},P={__proto__:null,default:y},w=function(e){void 0===e&&(e=[{}]);try{return Array.isArray(e)||(e=[e]),e[0].recursive=e[0].recursive||!1,Promise.resolve(new Promise(function(n,t){var r=document.createElement("input");r.type="file",r.webkitdirectory=!0;var i=function(e){"function"==typeof o&&o(),n(e)},o=e[0].legacySetup&&e[0].legacySetup(i,function(){return o(t)},r);r.addEventListener("change",function(){var n=Array.from(r.files);e[0].recursive?e[0].recursive&&e[0].skipDirectory&&(n=n.filter(function(n){return n.webkitRelativePath.split("/").every(function(n){return!e[0].skipDirectory({name:n,kind:"directory"})})})):n=n.filter(function(e){return 2===e.webkitRelativePath.split("/").length}),i(n)}),"showPicker"in HTMLInputElement.prototype?r.showPicker():r.click()}))}catch(e){return Promise.reject(e)}},b={__proto__:null,default:w},k=function(e,n){void 0===n&&(n={});try{var t=function(){return r.download=n.fileName||"Untitled",Promise.resolve(i).then(function(e){r.href=URL.createObjectURL(e);var t=function(){"function"==typeof i&&i()},i=n.legacySetup&&n.legacySetup(t,function(){return i()},r);return r.addEventListener("click",function(){setTimeout(function(){return URL.revokeObjectURL(r.href)},3e4),t()}),r.click(),null})};Array.isArray(n)&&(n=n[0]);var r=document.createElement("a"),i=e,o=function(){if("body"in e)return Promise.resolve(function(e,n){try{var t=e.getReader(),r=new ReadableStream({start:function(e){return function n(){try{return Promise.resolve(t.read().then(function(t){if(!t.done)return e.enqueue(t.value),n();e.close()}))}catch(e){return Promise.reject(e)}}()}}),i=new Response(r);return Promise.resolve(i.blob()).then(function(e){return t.releaseLock(),new Blob([e],{type:n})})}catch(e){return Promise.reject(e)}}(e.body,e.headers.get("content-type"))).then(function(e){i=e})}();return Promise.resolve(o&&o.then?o.then(t):t())}catch(e){return Promise.reject(e)}},_={__proto__:null,default:k};exports.directoryOpen=function(){try{var e=arguments;return Promise.resolve(t).then(function(n){return n.default.apply(n,[].slice.call(e))})}catch(e){return Promise.reject(e)}},exports.directoryOpenLegacy=w,exports.directoryOpenModern=h,exports.fileOpen=function(){try{var e=arguments;return Promise.resolve(n).then(function(n){return n.default.apply(n,[].slice.call(e))})}catch(e){return Promise.reject(e)}},exports.fileOpenLegacy=y,exports.fileOpenModern=o,exports.fileSave=function(){try{var e=arguments;return Promise.resolve(r).then(function(n){return n.default.apply(n,[].slice.call(e))})}catch(e){return Promise.reject(e)}},exports.fileSaveLegacy=k,exports.fileSaveModern=d,exports.supported=e;

A  => ep_latex/static/js/vendor/browser-fs-access/dist/index.modern.js +1 -0
@@ 1,1 @@
const e=(()=>{if("undefined"==typeof self)return!1;if("top"in self&&self!==top)try{top}catch(e){return!1}return"showOpenFilePicker"in self})(),t=e?Promise.resolve().then(function(){return l}):Promise.resolve().then(function(){return v});async function n(...e){return(await t).default(...e)}const r=e?Promise.resolve().then(function(){return y}):Promise.resolve().then(function(){return b});async function i(...e){return(await r).default(...e)}const a=e?Promise.resolve().then(function(){return m}):Promise.resolve().then(function(){return k});async function o(...e){return(await a).default(...e)}const s=async e=>{const t=await e.getFile();return t.handle=e,t};var c=async(e=[{}])=>{Array.isArray(e)||(e=[e]);const t=[];e.forEach((e,n)=>{t[n]={description:e.description||"Files",accept:{}},e.mimeTypes?e.mimeTypes.map(r=>{t[n].accept[r]=e.extensions||[]}):t[n].accept["*/*"]=e.extensions||[]});const n=await window.showOpenFilePicker({id:e[0].id,startIn:e[0].startIn,types:t,multiple:e[0].multiple||!1,excludeAcceptAllOption:e[0].excludeAcceptAllOption||!1}),r=await Promise.all(n.map(s));return e[0].multiple?r:r[0]},l={__proto__:null,default:c};function u(e){function t(e){if(Object(e)!==e)return Promise.reject(new TypeError(e+" is not an object."));var t=e.done;return Promise.resolve(e.value).then(function(e){return{value:e,done:t}})}return u=function(e){this.s=e,this.n=e.next},u.prototype={s:null,n:null,next:function(){return t(this.n.apply(this.s,arguments))},return:function(e){var n=this.s.return;return void 0===n?Promise.resolve({value:e,done:!0}):t(n.apply(this.s,arguments))},throw:function(e){var n=this.s.return;return void 0===n?Promise.reject(e):t(n.apply(this.s,arguments))}},new u(e)}const p=async(e,t,n=e.name,r)=>{const i=[],a=[];var o,s=!1,c=!1;try{for(var l,d=function(e){var t,n,r,i=2;for("undefined"!=typeof Symbol&&(n=Symbol.asyncIterator,r=Symbol.iterator);i--;){if(n&&null!=(t=e[n]))return t.call(e);if(r&&null!=(t=e[r]))return new u(t.call(e));n="@@asyncIterator",r="@@iterator"}throw new TypeError("Object is not async iterable")}(e.values());s=!(l=await d.next()).done;s=!1){const o=l.value,s=`${n}/${o.name}`;"file"===o.kind?a.push(o.getFile().then(t=>(t.directoryHandle=e,t.handle=o,Object.defineProperty(t,"webkitRelativePath",{configurable:!0,enumerable:!0,get:()=>s})))):"directory"!==o.kind||!t||r&&r(o)||i.push(p(o,t,s,r))}}catch(e){c=!0,o=e}finally{try{s&&null!=d.return&&await d.return()}finally{if(c)throw o}}return[...(await Promise.all(i)).flat(),...await Promise.all(a)]};var d=async(e={})=>{e.recursive=e.recursive||!1,e.mode=e.mode||"read";const t=await window.showDirectoryPicker({id:e.id,startIn:e.startIn,mode:e.mode});return(await(await t.values()).next()).done?[t]:p(t,e.recursive,void 0,e.skipDirectory)},y={__proto__:null,default:d},f=async(e,t=[{}],n=null,r=!1,i=null)=>{Array.isArray(t)||(t=[t]),t[0].fileName=t[0].fileName||"Untitled";const a=[];let o=null;if(e instanceof Blob&&e.type?o=e.type:e.headers&&e.headers.get("content-type")&&(o=e.headers.get("content-type")),t.forEach((e,t)=>{a[t]={description:e.description||"Files",accept:{}},e.mimeTypes?(0===t&&o&&e.mimeTypes.push(o),e.mimeTypes.map(n=>{a[t].accept[n]=e.extensions||[]})):o?a[t].accept[o]=e.extensions||[]:a[t].accept["*/*"]=e.extensions||[]}),n)try{await n.getFile()}catch(e){if(n=null,r)throw e}const s=n||await window.showSaveFilePicker({suggestedName:t[0].fileName,id:t[0].id,startIn:t[0].startIn,types:a,excludeAcceptAllOption:t[0].excludeAcceptAllOption||!1});!n&&i&&i(s);const c=await s.createWritable();if("stream"in e){const t=e.stream();return await t.pipeTo(c),s}return"body"in e?(await e.body.pipeTo(c),s):(await c.write(await e),await c.close(),s)},m={__proto__:null,default:f},w=async(e=[{}])=>(Array.isArray(e)||(e=[e]),new Promise((t,n)=>{const r=document.createElement("input");r.type="file";const i=[...e.map(e=>e.mimeTypes||[]),...e.map(e=>e.extensions||[])].join();r.multiple=e[0].multiple||!1,r.accept=i||"",r.style.display="none",document.body.append(r);const a=e=>{"function"==typeof o&&o(),t(e)},o=e[0].legacySetup&&e[0].legacySetup(a,()=>o(n),r),s=()=>{window.removeEventListener("focus",s),r.remove()};r.addEventListener("click",()=>{window.addEventListener("focus",s)}),r.addEventListener("change",()=>{window.removeEventListener("focus",s),r.remove(),a(r.multiple?Array.from(r.files):r.files[0])}),"showPicker"in HTMLInputElement.prototype?r.showPicker():r.click()})),v={__proto__:null,default:w},h=async(e=[{}])=>(Array.isArray(e)||(e=[e]),e[0].recursive=e[0].recursive||!1,new Promise((t,n)=>{const r=document.createElement("input");r.type="file",r.webkitdirectory=!0;const i=e=>{"function"==typeof a&&a(),t(e)},a=e[0].legacySetup&&e[0].legacySetup(i,()=>a(n),r);r.addEventListener("change",()=>{let t=Array.from(r.files);e[0].recursive?e[0].recursive&&e[0].skipDirectory&&(t=t.filter(t=>t.webkitRelativePath.split("/").every(t=>!e[0].skipDirectory({name:t,kind:"directory"})))):t=t.filter(e=>2===e.webkitRelativePath.split("/").length),i(t)}),"showPicker"in HTMLInputElement.prototype?r.showPicker():r.click()})),b={__proto__:null,default:h},P=async(e,t={})=>{Array.isArray(t)&&(t=t[0]);const n=document.createElement("a");let r=e;"body"in e&&(r=await async function(e,t){const n=e.getReader(),r=new ReadableStream({start:e=>async function t(){return n.read().then(({done:n,value:r})=>{if(!n)return e.enqueue(r),t();e.close()})}()}),i=new Response(r),a=await i.blob();return n.releaseLock(),new Blob([a],{type:t})}(e.body,e.headers.get("content-type"))),n.download=t.fileName||"Untitled",n.href=URL.createObjectURL(await r);const i=()=>{"function"==typeof a&&a()},a=t.legacySetup&&t.legacySetup(i,()=>a(),n);return n.addEventListener("click",()=>{setTimeout(()=>URL.revokeObjectURL(n.href),3e4),i()}),n.click(),null},k={__proto__:null,default:P};export{i as directoryOpen,h as directoryOpenLegacy,d as directoryOpenModern,n as fileOpen,w as fileOpenLegacy,c as fileOpenModern,o as fileSave,P as fileSaveLegacy,f as fileSaveModern,e as supported};

A  => ep_latex/static/js/vendor/browser-fs-access/index.d.ts +248 -0
@@ 1,248 @@
/**
 * Properties shared by all `options` provided to file save and open operations
 */
export interface CoreFileOptions {
  /** Acceptable file extensions. Defaults to `[""]`. */
  extensions?: string[];
  /** Suggested file description. Defaults to `""`. */
  description?: string;
  /** Acceptable MIME types. Defaults to `[]`. */
  mimeTypes?: string[];
}

/**
 * Properties shared by the _first_ `options` object provided to file save and
 * open operations (any additional options objects provided to those operations
 * cannot have these properties)
 */
export interface FirstCoreFileOptions extends CoreFileOptions {
  startIn?: WellKnownDirectory | FileSystemHandle;
  /** By specifying an ID, the user agent can remember different directories for different IDs. */
  id?: string;
  excludeAcceptAllOption?: boolean | false;
}

/**
 * The first `options` object passed to file save operations can also specify
 * a filename
 */
export interface FirstFileSaveOptions extends FirstCoreFileOptions {
  /** Suggested file name. Defaults to `"Untitled"`. */
  fileName?: string;
  /**
   * Configurable cleanup and `Promise` rejector usable with legacy API for
   * determining when (and reacting if) a user cancels the operation. The
   * method will be passed a reference to the internal `rejectionHandler` that
   * can, e.g., be attached to/removed from the window or called after a
   * timeout. The method should return a function that will be called when
   * either the user chooses to open a file or the `rejectionHandler` is
   * called. In the latter case, the returned function will also be passed a
   * reference to the `reject` callback for the `Promise` returned by
   * `fileOpen`, so that developers may reject the `Promise` when desired at
   * that time.
   * Example rejector:
   *
   * const file = await fileOpen({
   *   legacySetup: (rejectionHandler) => {
   *     const timeoutId = setTimeout(rejectionHandler, 10_000);
   *     return (reject) => {
   *       clearTimeout(timeoutId);
   *       if (reject) {
   *         reject('My error message here.');
   *       }
   *     };
   *   },
   * });
   *
   * ToDo: Remove this workaround once
   *   https://github.com/whatwg/html/issues/6376 is specified and supported.
   */
  legacySetup?: (
    resolve: () => void,
    rejectionHandler: () => void,
    anchor: HTMLAnchorElement
  ) => () => void;
}

/**
 * The first `options` object passed to file open operations can specify
 * whether multiple files can be selected (the return type of the operation
 * will be updated appropriately) and a way of handling cleanup and rejection
 * for legacy open operations.
 */
export interface FirstFileOpenOptions<M extends boolean | undefined>
  extends FirstCoreFileOptions {
  /** Allow multiple files to be selected. Defaults to `false`. */
  multiple?: M;
  /**
   * Configurable cleanup and `Promise` rejector usable with legacy API for
   * determining when (and reacting if) a user cancels the operation. The
   * method will be passed a reference to the internal `rejectionHandler` that
   * can, e.g., be attached to/removed from the window or called after a
   * timeout. The method should return a function that will be called when
   * either the user chooses to open a file or the `rejectionHandler` is
   * called. In the latter case, the returned function will also be passed a
   * reference to the `reject` callback for the `Promise` returned by
   * `fileOpen`, so that developers may reject the `Promise` when desired at
   * that time.
   * Example rejector:
   *
   * const file = await fileOpen({
   *   legacySetup: (rejectionHandler) => {
   *     const timeoutId = setTimeout(rejectionHandler, 10_000);
   *     return (reject) => {
   *       clearTimeout(timeoutId);
   *       if (reject) {
   *         reject('My error message here.');
   *       }
   *     };
   *   },
   * });
   *
   * ToDo: Remove this workaround once
   *   https://github.com/whatwg/html/issues/6376 is specified and supported.
   */
  legacySetup?: (
    resolve: (
      value: M extends false | undefined ? FileWithHandle : FileWithHandle[]
    ) => void,
    rejectionHandler: () => void,
    input: HTMLInputElement
  ) => (reject?: (reason?: any) => void) => void;
}

/**
 * Opens file(s) from disk.
 */
export function fileOpen<M extends boolean | undefined = false>(
  options?:
    | [FirstFileOpenOptions<M>, ...CoreFileOptions[]]
    | FirstFileOpenOptions<M>
): M extends false | undefined
  ? Promise<FileWithHandle>
  : Promise<FileWithHandle[]>;

export type WellKnownDirectory =
  | 'desktop'
  | 'documents'
  | 'downloads'
  | 'music'
  | 'pictures'
  | 'videos';

export type FileSystemPermissionMode = 'read' | 'readwrite';

/**
 * Saves a file to disk.
 * @returns Optional file handle to save in place.
 */
export function fileSave(
  /** To-be-saved `Blob` or `Response` */
  blobOrPromiseBlobOrResponse: Blob | Promise<Blob> | Response,
  options?: [FirstFileSaveOptions, ...CoreFileOptions[]] | FirstFileSaveOptions,
  /**
   * A potentially existing file handle for a file to save to. Defaults to
   * `null`.
   */
  existingHandle?: FileSystemFileHandle | null,
  /**
   * Determines whether to throw (rather than open a new file save dialog)
   * when `existingHandle` is no longer good. Defaults to `false`.
   */
  throwIfExistingHandleNotGood?: boolean | false,
  /**
   * A callback to be called when the file picker was shown (which only happens
   * when no `existingHandle` is provided). Defaults to `null`.
   */
  filePickerShown?: (handle: FileSystemFileHandle | null) => void | null
): Promise<FileSystemFileHandle | null>;

/**
 * Opens a directory from disk using the File System Access API.
 * @returns Contained files.
 */
export function directoryOpen(options?: {
  /** Whether to recursively get subdirectories. */
  recursive: boolean;
  /** Suggested directory in which the file picker opens. */
  startIn?: WellKnownDirectory | FileSystemHandle;
  /** By specifying an ID, the user agent can remember different directories for different IDs. */
  id?: string;
  /** By specifying a mode of `'readwrite'`, you can open a directory with write access. */
  mode?: FileSystemPermissionMode;
  /** Callback to determine whether a directory should be entered, return `true` to skip. */
  skipDirectory?: (
    entry: FileSystemDirectoryEntry | FileSystemDirectoryHandle
  ) => boolean;
  /**
   * Configurable setup, cleanup and `Promise` rejector usable with legacy API
   * for determining when (and reacting if) a user cancels the operation. The
   * method will be passed a reference to the internal `rejectionHandler` that
   * can, e.g., be attached to/removed from the window or called after a
   * timeout. The method should return a function that will be called when
   * either the user chooses to open a file or the `rejectionHandler` is
   * called. In the latter case, the returned function will also be passed a
   * reference to the `reject` callback for the `Promise` returned by
   * `fileOpen`, so that developers may reject the `Promise` when desired at
   * that time.
   * Example rejector:
   *
   * const file = await directoryOpen({
   *   legacySetup: (rejectionHandler) => {
   *     const timeoutId = setTimeout(rejectionHandler, 10_000);
   *     return (reject) => {
   *       clearTimeout(timeoutId);
   *       if (reject) {
   *         reject('My error message here.');
   *       }
   *     };
   *   },
   * });
   *
   * ToDo: Remove this workaround once
   *   https://github.com/whatwg/html/issues/6376 is specified and supported.
   */
  legacySetup?: (
    resolve: (value: FileWithDirectoryAndFileHandle) => void,
    rejectionHandler: () => void,
    input: HTMLInputElement
  ) => (reject?: (reason?: any) => void) => void;
}): Promise<FileWithDirectoryAndFileHandle[] | FileSystemDirectoryHandle[]>;

/**
 * Whether the File System Access API is supported.
 */
export const supported: boolean;

export function imageToBlob(img: HTMLImageElement): Promise<Blob>;

export interface FileWithHandle extends File {
  handle?: FileSystemFileHandle;
}

export interface FileWithDirectoryAndFileHandle extends File {
  directoryHandle?: FileSystemDirectoryHandle;
  handle?: FileSystemFileHandle;
}

// The following typings implement the relevant parts of the File System Access
// API. This can be removed once the specification reaches the Candidate phase
// and is implemented as part of microsoft/TSJS-lib-generator.

export interface FileSystemHandlePermissionDescriptor {
  mode?: 'read' | 'readwrite';
}

export interface FileSystemHandle {
  readonly kind: 'file' | 'directory';
  readonly name: string;

  isSameEntry: (other: FileSystemHandle) => Promise<boolean>;

  queryPermission: (
    descriptor?: FileSystemHandlePermissionDescriptor
  ) => Promise<PermissionState>;
  requestPermission: (
    descriptor?: FileSystemHandlePermissionDescriptor
  ) => Promise<PermissionState>;
}

A  => ep_latex/static/js/vendor/browser-fs-access/package.json +74 -0
@@ 1,74 @@
{
  "name": "browser-fs-access",
  "version": "0.33.1",
  "description": "File System Access API with legacy fallback in the browser.",
  "type": "module",
  "source": "./src/index.js",
  "exports": {
    ".": {
      "types": "./index.d.ts",
      "module": "./dist/index.modern.js",
      "import": "./dist/index.modern.js",
      "require": "./dist/index.cjs",
      "browser": "./dist/index.modern.js"
    },
    "./package.json": "./package.json"
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.modern.js",
  "types": "./index.d.ts",
  "files": [
    "dist/",
    "index.d.ts"
  ],
  "scripts": {
    "start": "npx http-server -o /demo/",
    "clean": "shx rm -rf ./dist",
    "build": "npm run clean && microbundle -f modern,cjs --no-sourcemap --no-generateTypes",
    "dev": "microbundle watch",
    "prepare": "npm run lint && npm run fix && npm run build",
    "lint": "npx eslint . --ext .js,.mjs --fix --ignore-pattern dist/",
    "fix": "npx prettier --write ."
  },
  "publishConfig": {
    "access": "public"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/GoogleChromeLabs/browser-fs-access.git"
  },
  "keywords": [
    "file system access",
    "file system access api",
    "file system",
    "ponyfill"
  ],
  "author": "Thomas Steiner (https://blog.tomayac.com/)",
  "license": "Apache-2.0",
  "bugs": {
    "url": "https://github.com/GoogleChromeLabs/browser-fs-access/issues"
  },
  "homepage": "https://github.com/GoogleChromeLabs/browser-fs-access#readme",
  "devDependencies": {
    "eslint": "^8.38.0",
    "eslint-config-google": "^0.14.0",
    "eslint-config-prettier": "^8.8.0",
    "http-server": "^14.1.1",
    "microbundle": "^0.15.1",
    "prettier": "^2.8.7",
    "shx": "^0.3.4"
  },
  "eslintConfig": {
    "parserOptions": {
      "ecmaVersion": 2020,
      "sourceType": "module"
    },
    "extends": [
      "google",
      "prettier"
    ],
    "rules": {
      "valid-jsdoc": "off"
    }
  }
}

A  => ep_latex/tectonic.js +131 -0
@@ 1,131 @@
const {
    spawnSync
} = require('node:child_process');
const {
    createWriteStream,
    mkdtempSync,
    renameSync,
    openSync,
    writeSync,
    readFileSync,
    statSync,
    ftruncateSync
} = require('node:fs');
const {
    tmpdir
} = require('node:os');
const {
    join
} = require('node:path');

const zip = require('./zip');

let globalBuildOutput = null;
let globalDocs = {};
let globalLock = {};

buildDir = () => {
    if (globalBuildOutput) return globalBuildOutput;
    globalBuildOutput = mkdtempSync(join(tmpdir(), 'eplatex-buildoutput-'));
    return globalBuildOutput;
}

// - create tempdir
// - create initial globalDocs[id]
const newDocument = (logger, id) => {
    globalDocs[id] = mkdtempSync(join(tmpdir(), 'eplatex-'));

    spawnSync('tectonic', ['-X', 'new'], {
        cwd: globalDocs[id],
        stdio: 'ignore'
    });
}

// - copy pad contents to <texdir>/src/index.tex
// - run latexmk -1
const buildDocument = (logger, id, contents) => {
    if (globalLock[id]) {
        return;
    }

    // take lock ourselves
    globalLock[id] = true;

    if (!globalDocs[id]) {
        logger.warn(`new doc for ${id}`);
        newDocument(logger, id);
    }

    logger.warn(`building doc for ${id}`);

    if (contents) {
        const fd = openSync(join(globalDocs[id], 'src', 'index.tex'), 'a');
        ftruncateSync(fd, 0);
        writeSync(fd, contents);
    }

    spawnSync('tectonic', ['-X', 'build'], {
        cwd: globalDocs[id],
        stdio: 'ignore'
    });

    logger.warn(`building done for ${id}`);

    const oldfile = join(globalDocs[id], 'build', 'default', 'default.pdf');
    const newfile = join(buildDir(), id);

    // handle empty globalDocs[id]uments, which cause tectonic to not create a pdf
    try {
        const info = statSync(oldfile);
    } catch (error) {
        logger.warn(`missing pdf for ${id}`);
        globalLock[id] = false;
        return;
    }

    logger.warn(`renaming pdf for ${id}`);
    renameSync(oldfile, newfile);

    // release lock
    globalLock[id] = false;

    logger.warn(`done for ${id}`);
}

const exportDocument = async (logger, id, cb) => {
    if (!globalDocs[id]) {
        throw new Error(`no document for ${id}`);
    }
    const zipfilePath = join(buildDir(), id + '.zip');
    logger.debug(`exporting ${id} as ZIP...`);
    await zip.compressFolder(logger, globalDocs[id], zipfilePath);
    logger.debug(`exporting ${id} as ZIP... done`);
    return {
        name: id + '.zip',
        path: zipfilePath
    }
}

const importDocument = async (logger, id, zipstream) => {
    if (!globalDocs[id]) {
        throw new Error(`no document for ${id}`);
    }

    logger.debug(`importing ${id} from ZIP...`);
    await zip.decompressStream(logger, zipstream, globalDocs[id]);
    logger.debug(`importing ${id} from ZIP... done`);

    return;
}

const getText = (id) => {
    return readFileSync(join(globalDocs[id], 'src', 'index.tex'), {
        encoding: 'utf-8'
    });
}

exports.buildDir = buildDir;
exports.export = exportDocument;
exports.import = importDocument;
exports.build = buildDocument;
exports.getText = getText;

A  => ep_latex/templates/button.ejs +5 -0
@@ 1,5 @@
<li data-type="button" data-key="latex-render">
    <a class="grouped-right ep_latex" title="Render PDF">
        <button class="buttonicon buttonicon-latex-render" title="Render PDF"></button>
    </a>
</li>

A  => ep_latex/templates/settings.ejs +1 -0
@@ 1,1 @@
<code>hello from ep_latex</code>

A  => ep_latex/templates/styles.html +1 -0
@@ 1,1 @@
<link rel="stylesheet" href="../static/plugins/ep_latex/static/css/styles.css" types="text/css" />

A  => ep_latex/zip.js +87 -0
@@ 1,87 @@
// https://github.com/Stuk/jszip/issues/386#issuecomment-1283099454
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const JSZip = require('jszip');

/**
 * Returns a flat list of all files and subfolders for a directory (recursively).
 * @param {string} dir
 * @returns {Promise<string[]>}
 */
const getFilePathsRecursively = async (dir) => {
    // returns a flat array of absolute paths of all files recursively contained in the dir
    const list = await fsp.readdir(dir);
    const statPromises = list.map(async (file) => {
        const fullPath = path.resolve(dir, file);
        const stat = await fsp.stat(fullPath);
        if (stat && stat.isDirectory()) {
            return getFilePathsRecursively(fullPath);
        }
        return fullPath;
    });

    return (await Promise.all(statPromises)).flat(Infinity);
};

/**
 * Creates an in-memory zip stream from a folder in the file system
 * @param {string} dir
 * @returns {JSZip}
 */
const createZipFromFolder = async (dir) => {
    const absRoot = path.resolve(dir);
    const filePaths = await getFilePathsRecursively(dir);
    return filePaths.reduce((z, filePath) => {
        const relative = filePath.replace(absRoot, '');
        // create folder trees manually :(
        const zipFolder = path
            .dirname(relative)
            .split(path.sep)
            .reduce((zf, dirName) => zf.folder(dirName), z);

        zipFolder.file(path.basename(filePath), fs.createReadStream(filePath));
        return z;
    }, new JSZip());
};

/**
 * Compresses a folder to the specified zip file.
 * @param {string} srcDir
 * @param {string} destFile
 */
const compressFolder = async (logger, srcDir, destFile) => {
    const start = Date.now();
    const zip = await createZipFromFolder(srcDir);
    zip
        .generateNodeStream({
            streamFiles: true,
            compression: 'DEFLATE'
        })
        .pipe(fs.createWriteStream(destFile));
    logger.debug('ZIP compressed successfully, took', Date.now() - start, 'ms');
};

/**
 * Decompresses a stream into the specified directory.
 * @param {string} srcStream
 * @param {string} destDir
 */
const decompressStream = async (logger, srcStream, destDir) => {
    const start = Date.now();
    const zip = await JSZip.loadAsync(srcStream, { createFolders: true });
    zip.forEach(async (relPath, file) => {
        const destPath = path.join(destDir, file.name);
        if (file.dir) {
            await fsp.mkdir(destPath, {recursive: true});
        } else {
            await file
                .nodeStream()
                .pipe(fs.createWriteStream(destPath));
        }
    });
    logger.debug('ZIP decompressed successfully, took', Date.now() - start, 'ms');
};

exports.compressFolder = compressFolder;
exports.decompressStream = decompressStream;

A  => settings.json +590 -0
@@ 1,590 @@
/**
 * THIS IS THE SETTINGS FILE THAT IS COPIED INSIDE THE DOCKER CONTAINER.
 *
 * By default, some runtime customizations are supported (see the
 * documentation).
 *
 * If you need more control, edit this file and rebuild the container.
 */

/*
 * This file must be valid JSON. But comments are allowed
 *
 * Please edit settings.json, not settings.json.template
 *
 * Please note that starting from Etherpad 1.6.0 you can store DB credentials in
 * a separate file (credentials.json).
 *
 *
 * ENVIRONMENT VARIABLE SUBSTITUTION
 * =================================
 *
 * All the configuration values can be read from environment variables using the
 * syntax "${ENV_VAR}" or "${ENV_VAR:default_value}".
 *
 * This is useful, for example, when running in a Docker container.
 *
 * DETAILED RULES:
 *   - If the environment variable is set to the string "true" or "false", the
 *     value becomes Boolean true or false.
 *   - If the environment variable is set to the string "null", the value
 *     becomes null.
 *   - If the environment variable is set to the string "undefined", the setting
 *     is removed entirely, except when used as the member of an array in which
 *     case it becomes null.
 *   - If the environment variable is set to a string representation of a finite
 *     number, the string is converted to that number.
 *   - If the environment variable is set to any other string, including the
 *     empty string, the value is that string.
 *   - If the environment variable is unset and a default value is provided, the
 *     value is as if the environment variable was set to the provided default:
 *       - "${UNSET_VAR:}" becomes the empty string.
 *       - "${UNSET_VAR:foo}" becomes the string "foo".
 *       - "${UNSET_VAR:true}" and "${UNSET_VAR:false}" become true and false.
 *       - "${UNSET_VAR:null}" becomes null.
 *       - "${UNSET_VAR:undefined}" causes the setting to be removed (or be set
 *         to null, if used as a member of an array).
 *   - If the environment variable is unset and no default value is provided,
 *     the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF
 *     ETHERPAD; if you want the default value to be null, you should explicitly
 *     specify "null" as the default value.
 *
 * EXAMPLE:
 *    "port":     "${PORT:9001}"
 *    "minify":   "${MINIFY}"
 *    "skinName": "${SKIN_NAME:colibris}"
 *
 * Would read the configuration values for those items from the environment
 * variables PORT, MINIFY and SKIN_NAME.
 *
 * If PORT and SKIN_NAME variables were not defined, the default values 9001 and
 * "colibris" would be used.
 * The configuration value "minify", on the other hand, does not have a
 * designated default value. Thus, if the environment variable MINIFY were
 * undefined, "minify" would be null.
 *
 * REMARKS:
 * 1) please note that variable substitution always needs to be quoted.
 *
 *    "port":     9001,            <-- Literal values. When not using
 *    "minify":   false                substitution, only strings must be
 *    "skinName": "colibris"           quoted. Booleans and numbers must not.
 *
 *    "port":     "${PORT:9001}"   <-- CORRECT: if you want to use a variable
 *    "minify":   "${MINIFY:true}"     substitution, put quotes around its name,
 *    "skinName": "${SKIN_NAME}"       even if the required value is a number or
 *                                     a boolean.
 *                                     Etherpad will take care of rewriting it
 *                                     to the proper type if necessary.
 *
 *    "port":     ${PORT:9001}     <-- ERROR: this is not valid json. Quotes
 *    "minify":   ${MINIFY}            around variable names are missing.
 *    "skinName": ${SKIN_NAME}
 *
 * 2) Beware of undefined variables and default values: nulls and empty strings
 *    are different!
 *
 *    This is particularly important for user's passwords (see the relevant
 *    section):
 *
 *    "password": "${PASSW}"  // if PASSW is not defined would result in password === null
 *    "password": "${PASSW:}" // if PASSW is not defined would result in password === ''
 *
 *    If you want to use an empty value (null) as default value for a variable,
 *    simply do not set it, without putting any colons: "${ABIWORD}".
 *
 * 3) if you want to use newlines in the default value of a string parameter,
 *    use "\n" as usual.
 *
 *    "defaultPadText" : "${DEFAULT_PAD_TEXT}Line 1\nLine 2"
 */
{
  /*
   * Name your instance!
   */
  "title": "${TITLE:Etherpad}",

  /*
   * Pathname of the favicon you want to use. If null, the skin's favicon is
   * used if one is provided by the skin, otherwise the default Etherpad favicon
   * is used. If this is a relative path it is interpreted as relative to the
   * Etherpad root directory.
   */
  "favicon": "${FAVICON:null}",

  /*
   * Skin name.
   *
   * Its value has to be an existing directory under src/static/skins.
   * You can write your own, or use one of the included ones:
   *
   * - "no-skin":  an empty skin (default). This yields the unmodified,
   *               traditional Etherpad theme.
   * - "colibris": the new experimental skin (since Etherpad 1.8), candidate to
   *               become the default in Etherpad 2.0
   */
  "skinName": "${SKIN_NAME:colibris}",

  /*
   * Skin Variants
   *
   * Use the UI skin variants builder at /p/test#skinvariantsbuilder
   *
   * For the colibris skin only, you can choose how to render the three main
   * containers:
   * - toolbar (top menu with icons)
   * - editor (containing the text of the pad)
   * - background (area outside of editor, mostly visible when using page style)
   *
   * For each of the 3 containers you can choose 4 color combinations:
   * super-light, light, dark, super-dark.
   *
   * For example, to make the toolbar dark, you will include "dark-toolbar" into
   * skinVariants.
   *
   * You can provide multiple skin variants separated by spaces. Default
   * skinVariant is "super-light-toolbar super-light-editor light-background".
   *
   * For the editor container, you can also make it full width by adding
   * "full-width-editor" variant (by default editor is rendered as a page, with
   * a max-width of 900px).
   */
  "skinVariants": "${SKIN_VARIANTS:super-light-toolbar super-light-editor light-background}",

  /*
   * IP and port which Etherpad should bind at.
   *
   * Binding to a Unix socket is also supported: just use an empty string for
   * the ip, and put the full path to the socket in the port parameter.
   *
   * EXAMPLE USING UNIX SOCKET:
   *    "ip": "",                             // <-- has to be an empty string
   *    "port" : "/somepath/etherpad.socket", // <-- path to a Unix socket
   */
  "ip": "${IP:0.0.0.0}",
  "port": "${PORT:9001}",

  /*
   * Option to hide/show the settings.json in admin page.
   *
   * Default option is set to true
   */
  "showSettingsInAdminPage": "${SHOW_SETTINGS_IN_ADMIN_PAGE:true}",

  /*
   * Node native SSL support
   *
   * This is disabled by default.
   * Make sure to have the minimum and correct file access permissions set so
   * that the Etherpad server can access them
   */

  /*
  "ssl" : {
            "key"  : "/path-to-your/epl-server.key",
            "cert" : "/path-to-your/epl-server.crt",
            "ca": ["/path-to-your/epl-intermediate-cert1.crt", "/path-to-your/epl-intermediate-cert2.crt"]
          },
  */

  /*
   * The type of the database.
   *
   * You can choose between many DB drivers, for example: dirty, postgres,
   * sqlite, mysql.
   *
   * You shouldn't use "dirty" for for anything else than testing or
   * development.
   *
   *
   * Database specific settings are dependent on dbType, and go in dbSettings.
   * Remember that since Etherpad 1.6.0 you can also store this information in
   * credentials.json.
   *
   * For a complete list of the supported drivers, please refer to:
   * https://www.npmjs.com/package/ueberdb2
   */

  "dbType": "${DB_TYPE:dirty}",
  "dbSettings": {
    "host":     "${DB_HOST:undefined}",
    "port":     "${DB_PORT:undefined}",
    "database": "${DB_NAME:undefined}",
    "user":     "${DB_USER:undefined}",
    "password": "${DB_PASS:undefined}",
    "charset":  "${DB_CHARSET:undefined}",
    "filename": "${DB_FILENAME:var/dirty.db}",
    "collection": "${DB_COLLECTION:undefined}",
    "url":      "${DB_URL:undefined}"
  },

  /*
   * The default text of a pad
   */
  "defaultPadText" : "${DEFAULT_PAD_TEXT:Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https:\/\/etherpad.org\n}",

  /*
   * Default Pad behavior.
   *
   * Change them if you want to override.
   */
  "padOptions": {
    "noColors":         "${PAD_OPTIONS_NO_COLORS:false}",
    "showControls":     "${PAD_OPTIONS_SHOW_CONTROLS:true}",
    "showChat":         "${PAD_OPTIONS_SHOW_CHAT:false}",
    "showLineNumbers":  "${PAD_OPTIONS_SHOW_LINE_NUMBERS:true}",
    "useMonospaceFont": "${PAD_OPTIONS_USE_MONOSPACE_FONT:true}",
    "userName":         "${PAD_OPTIONS_USER_NAME:false}",
    "userColor":        "${PAD_OPTIONS_USER_COLOR:false}",
    "rtl":              "${PAD_OPTIONS_RTL:false}",
    "alwaysShowChat":   "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
    "chatAndUsers":     "${PAD_OPTIONS_CHAT_AND_USERS:false}",
    "lang":             "${PAD_OPTIONS_LANG:en-gb}"
  },

  /*
   * Pad Shortcut Keys
   */
  "padShortcutEnabled" : {
    "altF9":     "${PAD_SHORTCUTS_ENABLED_ALT_F9:true}",      /* focus on the File Menu and/or editbar */
    "altC":      "${PAD_SHORTCUTS_ENABLED_ALT_C:true}",       /* focus on the Chat window */
    "cmdShift2": "${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_2:true}", /* shows a gritter popup showing a line author */
    "delete":    "${PAD_SHORTCUTS_ENABLED_DELETE:true}",
    "return":    "${PAD_SHORTCUTS_ENABLED_RETURN:true}",
    "esc":       "${PAD_SHORTCUTS_ENABLED_ESC:true}",         /* in mozilla versions 14-19 avoid reconnecting pad */
    "cmdS":      "${PAD_SHORTCUTS_ENABLED_CMD_S:true}",       /* save a revision */
    "tab":       "${PAD_SHORTCUTS_ENABLED_TAB:true}",         /* indent */
    "cmdZ":      "${PAD_SHORTCUTS_ENABLED_CMD_Z:true}",       /* undo/redo */
    "cmdY":      "${PAD_SHORTCUTS_ENABLED_CMD_Y:true}",       /* redo */
    "cmdI":      "${PAD_SHORTCUTS_ENABLED_CMD_I:true}",       /* italic */
    "cmdB":      "${PAD_SHORTCUTS_ENABLED_CMD_B:true}",       /* bold */
    "cmdU":      "${PAD_SHORTCUTS_ENABLED_CMD_U:true}",       /* underline */
    "cmd5":      "${PAD_SHORTCUTS_ENABLED_CMD_5:true}",       /* strike through */
    "cmdShiftL": "${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_L:true}", /* unordered list */
    "cmdShiftN": "${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_N:true}", /* ordered list */
    "cmdShift1": "${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_1:true}", /* ordered list */
    "cmdShiftC": "${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_C:true}", /* clear authorship */
    "cmdH":      "${PAD_SHORTCUTS_ENABLED_CMD_H:true}",       /* backspace */
    "ctrlHome":  "${PAD_SHORTCUTS_ENABLED_CTRL_HOME:true}",   /* scroll to top of pad */
    "pageUp":    "${PAD_SHORTCUTS_ENABLED_PAGE_UP:true}",
    "pageDown":  "${PAD_SHORTCUTS_ENABLED_PAGE_DOWN:true}"
  },

  /*
   * Should we suppress errors from being visible in the default Pad Text?
   */
  "suppressErrorsInPadText": "${SUPPRESS_ERRORS_IN_PAD_TEXT:true}",

  /*
   * If this option is enabled, a user must have a session to access pads.
   * This effectively allows only group pads to be accessed.
   */
  "requireSession": "${REQUIRE_SESSION:false}",

  /*
   * Users may edit pads but not create new ones.
   *
   * Pad creation is only via the API.
   * This applies both to group pads and regular pads.
   */
  "editOnly": "${EDIT_ONLY:false}",

  /*
   * If true, all css & js will be minified before sending to the client.
   *
   * This will improve the loading performance massively, but makes it difficult
   * to debug the javascript/css
   */
  "minify": "${MINIFY:true}",

  /*
   * How long may clients use served javascript code (in seconds)?
   *
   * Not setting this may cause problems during deployment.
   * Set to 0 to disable caching.
   */
  "maxAge": "${MAX_AGE:21600}", // 60 * 60 * 6 = 6 hours

  /*
   * Absolute path to the Abiword executable.
   *
   * Abiword is needed to get advanced import/export features of pads. Setting
   * it to null disables Abiword and will only allow plain text and HTML
   * import/exports.
   */
  "abiword": "${ABIWORD:null}",

  /*
   * This is the absolute path to the soffice executable.
   *
   * LibreOffice can be used in lieu of Abiword to export pads.
   * Setting it to null disables LibreOffice exporting.
   */
  "soffice": "${SOFFICE:null}",

  /*
   * Path to the Tidy executable.
   *
   * Tidy is used to improve the quality of exported pads.
   * Setting it to null disables Tidy.
   */
  "tidyHtml": "${TIDY_HTML:null}",

  /*
   * Allow import of file types other than the supported ones:
   * txt, doc, docx, rtf, odt, html & htm
   */
  "allowUnknownFileEnds": "${ALLOW_UNKNOWN_FILE_ENDS:true}",

  /*
   * This setting is used if you require authentication of all users.
   *
   * Note: "/admin" always requires authentication.
   */
  "requireAuthentication": "${REQUIRE_AUTHENTICATION:false}",

  /*
   * Require authorization by a module, or a user with is_admin set, see below.
   */
  "requireAuthorization": "${REQUIRE_AUTHORIZATION:false}",

  /*
   * When you use NGINX or another proxy/load-balancer set this to true.
   *
   * This is especially necessary when the reverse proxy performs SSL
   * termination, otherwise the cookies will not have the "secure" flag.
   *
   * The other effect will be that the logs will contain the real client's IP,
   * instead of the reverse proxy's IP.
   */
  "trustProxy": "${TRUST_PROXY:false}",

  /*
   * Settings controlling the session cookie issued by Etherpad.
   */
  "cookie": {
    /*
     * Value of the SameSite cookie property. "Lax" is recommended unless
     * Etherpad will be embedded in an iframe from another site, in which case
     * this must be set to "None". Note: "None" will not work (the browser will
     * not send the cookie to Etherpad) unless https is used to access Etherpad
     * (either directly or via a reverse proxy with "trustProxy" set to true).
     *
     * "Strict" is not recommended because it has few security benefits but
     * significant usability drawbacks vs. "Lax". See
     * https://stackoverflow.com/q/41841880 for discussion.
     */
    "sameSite": "${COOKIE_SAME_SITE:Lax}"
  },

  /*
   * Privacy: disable IP logging
   */
  "disableIPlogging": "${DISABLE_IP_LOGGING:false}",

  /*
   * Time (in seconds) to automatically reconnect pad when a "Force reconnect"
   * message is shown to user.
   *
   * Set to 0 to disable automatic reconnection.
   */
  "automaticReconnectionTimeout": "${AUTOMATIC_RECONNECTION_TIMEOUT:0}",

  /*
   * By default, when caret is moved out of viewport, it scrolls the minimum
   * height needed to make this line visible.
   */
  "scrollWhenFocusLineIsOutOfViewport": {

    /*
     * Percentage of viewport height to be additionally scrolled.
     *
     * E.g.: use "percentage.editionAboveViewport": 0.5, to place caret line in
     *       the middle of viewport, when user edits a line above of the
     *       viewport
     *
     * Set to 0 to disable extra scrolling
     */
    "percentage": {
      "editionAboveViewport": "${FOCUS_LINE_PERCENTAGE_ABOVE:0}",
      "editionBelowViewport": "${FOCUS_LINE_PERCENTAGE_BELOW:0}"
    },

    /*
     * Time (in milliseconds) used to animate the scroll transition.
     * Set to 0 to disable animation
     */
    "duration": "${FOCUS_LINE_DURATION:0}",

    /*
     * Flag to control if it should scroll when user places the caret in the
     * last line of the viewport
     */
    "scrollWhenCaretIsInTheLastLineOfViewport": "${FOCUS_LINE_CARET_SCROLL:false}",

    /*
     * Percentage of viewport height to be additionally scrolled when user
     * presses arrow up in the line of the top of the viewport.
     *
     * Set to 0 to let the scroll to be handled as default by Etherpad
     */
    "percentageToScrollWhenUserPressesArrowUp": "${FOCUS_LINE_PERCENTAGE_ARROW_UP:0}"
  },

  /*
   * User accounts. These accounts are used by:
   *   - default HTTP basic authentication if no plugin handles authentication
   *   - some but not all authentication plugins
   *   - some but not all authorization plugins
   *
   * User properties:
   *   - password: The user's password. Some authentication plugins will ignore
   *     this.
   *   - is_admin: true gives access to /admin. Defaults to false. If you do not
   *     uncomment this, /admin will not be available!
   *   - readOnly: If true, this user will not be able to create new pads or
   *     modify existing pads. Defaults to false.
   *   - canCreate: If this is true and readOnly is false, this user can create
   *     new pads. Defaults to true.
   *
   * Authentication and authorization plugins may define additional properties.
   *
   * WARNING: passwords should not be stored in plaintext in this file.
   *          If you want to mitigate this, please install ep_hash_auth and
   *          follow the section "secure your installation" in README.md
   */

  "users": {
    "admin": {
      // 1) "password" can be replaced with "hash" if you install ep_hash_auth
      // 2) please note that if password is null, the user will not be created
      "password": "${ADMIN_PASSWORD:null}",
      "is_admin": true
    },
    "user": {
      // 1) "password" can be replaced with "hash" if you install ep_hash_auth
      // 2) please note that if password is null, the user will not be created
      "password": "${USER_PASSWORD:null}",
      "is_admin": false
    }
  },

  /*
   * Restrict socket.io transport methods
   */
  "socketTransportProtocols" : ["xhr-polling", "jsonp-polling", "htmlfile"],

  "socketIo": {
    /*
     * Maximum permitted client message size (in bytes). All messages from
     * clients that are larger than this will be rejected. Large values make it
     * possible to paste large amounts of text, and plugins may require a larger
     * value to work properly, but increasing the value increases susceptibility
     * to denial of service attacks (malicious clients can exhaust memory).
     */
    "maxHttpBufferSize": "${SOCKETIO_MAX_HTTP_BUFFER_SIZE:10000}"
  },

  /*
   * Allow Load Testing tools to hit the Etherpad Instance.
   *
   * WARNING: this will disable security on the instance.
   */
  "loadTest": "${LOAD_TEST:false}",

  /**
  * Disable dump of objects preventing a clean exit
  */
  "dumpOnUncleanExit": "${DUMP_ON_UNCLEAN_EXIT:false}",

  /*
   * Disable indentation on new line when previous line ends with some special
   * chars (':', '[', '(', '{')
   */

  /*
  "indentationOnNewLine": false,
  */

  /*
   * From Etherpad 1.8.3 onwards, import and export of pads is always rate
   * limited.
   *
   * The default is to allow at most 10 requests per IP in a 90 seconds window.
   * After that the import/export request is rejected.
   *
   * See https://github.com/nfriedly/express-rate-limit for more options
   */
  "importExportRateLimiting": {
    // duration of the rate limit window (milliseconds)
    "windowMs": "${IMPORT_EXPORT_RATE_LIMIT_WINDOW:90000}",

    // maximum number of requests per IP to allow during the rate limit window
    "max": "${IMPORT_EXPORT_MAX_REQ_PER_IP:10}"
  },

  /*
   * From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported
   * file is always bounded.
   *
   * File size is specified in bytes. Default is 50 MB.
   */
  "importMaxFileSize": "${IMPORT_MAX_FILE_SIZE:52428800}", // 50 * 1024 * 1024

  /*
   * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited
   *
   * The default is to allow at most 10 changes per IP in a 1 second window.
   * After that the change is rejected.
   *
   * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options
   */
  "commitRateLimiting": {
    // duration of the rate limit window (seconds)
    "duration": "${COMMIT_RATE_LIMIT_DURATION:1}",

    // maximum number of changes per IP to allow during the rate limit window
    "points": "${COMMIT_RATE_LIMIT_POINTS:10}"
  },

  /*
   * Toolbar buttons configuration.
   *
   * Uncomment to customize.
   */
  "toolbar": {
    "left": [
      //["bold", "italic", "underline", "strikethrough"],
      //["orderedlist", "unorderedlist", "indent", "outdent"],
      ["indent", "outdent"],
      ["undo", "redo"],
      ["clearauthorship"]
    ],
    "right": [
      ["latexrender", "latexdownload", "latexupload", "latexhidepdf"],
      ["importexport", "timeslider", "savedrevision"],
      ["settings", "embed"],
      ["showusers"]
    ],
    "timeslider": [
      ["timeslider_export", "timeslider_returnToPad"]
    ]
  },

  /*
   * Expose Etherpad version in the web interface and in the Server http header.
   *
   * Do not enable on production machines.
   */
  "exposeVersion": "${EXPOSE_VERSION:false}",

  /*
   * The log level we are using.
   *
   * Valid values: DEBUG, INFO, WARN, ERROR
   */
  "loglevel": "${LOGLEVEL:INFO}",

  /* Override any strings found in locale directories */
  "customLocaleStrings": {}
}