/* MakeItPDF -- one private, local form for PDFs, images, and everyday documents. */
(() => {
"use strict";
const form = document.getElementById("composer");
const dropzone = document.getElementById("dropzone");
const tray = document.getElementById("tray");
const trayCount = document.getElementById("tray-count");
const sheetsEl = document.getElementById("sheets");
const picker = document.getElementById("picker");
const formats = document.getElementById("formats");
const template = document.getElementById("sheet-template");
const buildBtn = document.getElementById("build");
const clearBtn = document.getElementById("clear");
const progress = document.getElementById("progress");
const done = document.getElementById("done");
const doneMeta = document.getElementById("done-meta");
const doneNotes = document.getElementById("done-notes");
const downloadLink = document.getElementById("download");
const restartBtn = document.getElementById("restart");
const MAX_FILES = Number(form.dataset.maxFiles);
const MAX_FILE_BYTES = Number(form.dataset.maxFileBytes);
const MAX_SHEET_CELLS = 50000;
const OFFICE_TIMEOUT_MS = 30000;
const FORMAT_REGISTRY = [
{
kind: "pdf",
label: "PDF",
extensions: ["pdf"],
mimeTypes: ["application/pdf"],
},
{
kind: "image",
label: "Image",
extensions: ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"],
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp", "image/tiff"],
},
{
kind: "docx",
label: "Word",
extensions: ["docx"],
mimeTypes: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
},
{
kind: "xlsx",
label: "Excel",
extensions: ["xlsx"],
mimeTypes: ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
},
{ kind: "markdown", label: "Markdown", extensions: ["md", "markdown"], mimeTypes: ["text/markdown"] },
{ kind: "csv", label: "CSV", extensions: ["csv"], mimeTypes: ["text/csv", "application/csv"] },
];
const FORMAT_BY_EXTENSION = new Map(
FORMAT_REGISTRY.flatMap((format) => format.extensions.map((extension) => [extension, format]))
);
const FORMAT_BY_MIME = new Map(
FORMAT_REGISTRY.flatMap((format) => format.mimeTypes.map((mimeType) => [mimeType, format]))
);
/** @type {{id:number,file:File,kind:string,label:string,previewUrl:string|null}[]} */
let files = [];
let nextId = 1;
let dragId = null;
let busy = false;
let lastUrl = null;
const { formatBytes } = Pages;
const totalBytes = () => files.reduce((sum, item) => sum + item.file.size, 0);
function extensionOf(name) {
const match = String(name).toLowerCase().match(/\.([^.]+)$/);
return match ? match[1] : "";
}
function formatFor(file) {
return FORMAT_BY_EXTENSION.get(extensionOf(file.name)) || FORMAT_BY_MIME.get(file.type) || null;
}
function setAlert(message) {
let alert = document.querySelector("main > .alert");
if (!message) {
alert?.remove();
return;
}
if (!alert) {
alert = document.createElement("p");
alert.className = "alert";
alert.setAttribute("role", "alert");
form.parentNode.insertBefore(alert, form);
}
alert.textContent = message;
}
function accept(fileList) {
const problems = [];
let room = MAX_FILES - files.length;
for (const file of Array.from(fileList)) {
if (room <= 0) {
problems.push(`${MAX_FILES} files is the limit — skipped the rest.`);
break;
}
const format = formatFor(file);
if (!format) {
const extension = extensionOf(file.name).toUpperCase() || "That file type";
problems.push(`${extension} isn't supported. Use PDF, images, DOCX, XLSX, Markdown, or CSV.`);
continue;
}
if (file.size > MAX_FILE_BYTES) {
problems.push(
`${file.name} is ${formatBytes(file.size)}, over the ${formatBytes(MAX_FILE_BYTES)} limit.`
);
continue;
}
files.push({
id: nextId++,
file,
kind: format.kind,
label: format.label,
previewUrl: format.kind === "image" ? URL.createObjectURL(file) : null,
});
room -= 1;
}
setAlert(problems[0] || "");
showComposer();
render();
}
function remove(id) {
if (busy) return;
const index = files.findIndex((item) => item.id === id);
if (index === -1) return;
if (files[index].previewUrl) URL.revokeObjectURL(files[index].previewUrl);
files.splice(index, 1);
render();
}
function move(id, delta) {
if (busy) return;
const from = files.findIndex((item) => item.id === id);
const to = from + delta;
if (from === -1 || to < 0 || to >= files.length) return;
[files[from], files[to]] = [files[to], files[from]];
render();
sheetsEl
.querySelector(`.card[data-id="${id}"] .card__arrow--${delta < 0 ? "up" : "down"}`)
?.focus();
}
function clearAll() {
if (busy) return;
files.forEach((item) => item.previewUrl && URL.revokeObjectURL(item.previewUrl));
files = [];
setAlert("");
render();
}
function render() {
sheetsEl.textContent = "";
files.forEach((item, index) => {
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.id = String(item.id);
node.querySelector(".card__num").textContent = String(index + 1);
node.querySelector(".card__name").textContent = item.file.name;
node.querySelector(".card__meta").textContent = `${item.label} · ${formatBytes(item.file.size)}`;
node.querySelector(".card__type").textContent = extensionOf(item.file.name).toUpperCase();
node.classList.add(`card--${item.kind}`);
const img = node.querySelector(".card__img");
if (item.previewUrl) {
img.src = item.previewUrl;
img.alt = `Preview of ${item.file.name}`;
img.addEventListener("error", () => {
img.remove();
node.querySelector(".card__frame").classList.add("card__frame--document");
}, { once: true });
} else {
img.remove();
node.querySelector(".card__frame").classList.add("card__frame--document");
}
const up = node.querySelector(".card__arrow--up");
const down = node.querySelector(".card__arrow--down");
up.querySelector(".visually-hidden").textContent = `Move ${item.file.name} earlier`;
down.querySelector(".visually-hidden").textContent = `Move ${item.file.name} later`;
up.disabled = index === 0 || busy;
down.disabled = index === files.length - 1 || busy;
up.addEventListener("click", () => move(item.id, -1));
down.addEventListener("click", () => move(item.id, 1));
const removeButton = node.querySelector(".card__remove");
removeButton.querySelector(".visually-hidden").textContent = `Remove ${item.file.name}`;
removeButton.disabled = busy;
removeButton.addEventListener("click", () => remove(item.id));
node.addEventListener("dragstart", (event) => {
if (busy) return event.preventDefault();
dragId = item.id;
node.classList.add("is-lifted");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(item.id));
});
node.addEventListener("dragend", () => {
dragId = null;
node.classList.remove("is-lifted");
sheetsEl.querySelectorAll(".is-over").forEach((element) => element.classList.remove("is-over"));
});
node.addEventListener("dragover", (event) => {
if (dragId === null || dragId === item.id) return;
event.preventDefault();
node.classList.add("is-over");
});
node.addEventListener("dragleave", () => node.classList.remove("is-over"));
node.addEventListener("drop", (event) => {
if (dragId === null || busy) return;
event.preventDefault();
event.stopPropagation();
node.classList.remove("is-over");
const from = files.findIndex((candidate) => candidate.id === dragId);
const to = files.findIndex((candidate) => candidate.id === item.id);
if (from === -1 || to === -1 || from === to) return;
files.splice(to, 0, files.splice(from, 1)[0]);
render();
});
sheetsEl.appendChild(node);
});
const count = files.length;
form.classList.toggle("has-pages", count > 0);
tray.hidden = count === 0;
trayCount.textContent = `${count} ${count === 1 ? "file" : "files"} · ${formatBytes(totalBytes())}`;
buildBtn.disabled = count === 0 || busy;
buildBtn.hidden = count === 0;
clearBtn.disabled = busy;
if (!busy) buildBtn.textContent = buildActionLabel();
}
function buildActionLabel() {
if (!files.length || files.every((item) => item.kind === "pdf")) {
return files.length > 1 ? "Combine PDFs" : "Prepare PDF";
}
return files.some((item) => item.kind === "pdf") ? "Combine into one PDF" : "Convert to PDF";
}
function showComposer() {
done.hidden = true;
form.hidden = false;
}
function showDone(result) {
if (lastUrl) URL.revokeObjectURL(lastUrl);
lastUrl = URL.createObjectURL(result.blob);
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
downloadLink.href = lastUrl;
downloadLink.download = `makeitpdf-${stamp}.pdf`;
doneMeta.textContent = `${result.pageCount} ${result.pageCount === 1 ? "page" : "pages"} · ${formatBytes(result.blob.size)}`;
if (result.warnings.length) {
doneNotes.hidden = false;
doneNotes.textContent = `${result.warnings.length} conversion ${result.warnings.length === 1 ? "note" : "notes"}: ${result.warnings.slice(0, 3).join(" · ")}`;
} else {
doneNotes.hidden = true;
doneNotes.textContent = "";
}
form.hidden = true;
done.hidden = false;
downloadLink.focus();
}
function runOfficeWorker(kind, file) {
return file.arrayBuffer().then(
(buffer) =>
new Promise((resolve, reject) => {
const worker = new Worker("office-worker.js");
const timer = setTimeout(() => {
worker.terminate();
reject(new Error(`${file.name} took longer than 30 seconds to read.`));
}, OFFICE_TIMEOUT_MS);
worker.addEventListener("message", (event) => {
if (event.data.id !== 1) return;
clearTimeout(timer);
worker.terminate();
if (event.data.ok) resolve(event.data.result);
else reject(new Error(`${file.name} ${event.data.error}`));
});
worker.addEventListener("error", () => {
clearTimeout(timer);
worker.terminate();
reject(new Error(`${file.name} could not be read.`));
});
worker.postMessage({ id: 1, kind, buffer }, [buffer]);
})
);
}
async function parseItem(item) {
if (item.kind === "pdf") return { ...item, warnings: [] };
if (item.kind === "image") return { ...item, warnings: [] };
if (item.kind === "docx") {
const result = await runOfficeWorker("docx", item.file);
const parsed = Documents.htmlToBlocks(result.html);
if (!parsed.blocks.length) throw new Error(`${item.file.name} did not contain readable document content.`);
return { ...item, blocks: parsed.blocks, warnings: [...result.warnings, ...parsed.warnings] };
}
if (item.kind === "xlsx") {
const result = await runOfficeWorker("xlsx", item.file);
return { ...item, sheets: result.sheets, warnings: [] };
}
let text;
try {
text = Documents.decodeUtf8(await item.file.arrayBuffer());
} catch (error) {
throw new Error(`${item.file.name} ${error.message}`);
}
if (item.kind === "csv") {
let parsed;
try {
parsed = Documents.parseCsv(text);
} catch (error) {
throw new Error(`${item.file.name} ${error.message}`);
}
if (parsed.cells > MAX_SHEET_CELLS) {
throw new Error(`${item.file.name} has more than ${MAX_SHEET_CELLS.toLocaleString()} rendered cells.`);
}
const name = item.file.name.replace(/\.csv$/i, "") || "CSV";
return { ...item, sheets: [{ name, rows: parsed.rows, merges: [], widths: [] }], warnings: [] };
}
const markdown = window.markdownit({ html: false, linkify: true, typographer: false });
markdown.validateLink = (url) => Boolean(Documents.safeUrl(url) || Documents.SAFE_DATA_IMAGE.test(url));
const parsed = Documents.htmlToBlocks(markdown.render(text));
parsed.blocks.forEach((block) => {
if (block.type === "list") {
block.items = block.items.map((entry) =>
entry.replace(/^\[ \]\s*/, "☐ ").replace(/^\[[xX]\]\s*/, "☒ ")
);
}
});
if (!parsed.blocks.length) throw new Error(`${item.file.name} did not contain readable Markdown.`);
return { ...item, blocks: parsed.blocks, warnings: parsed.warnings };
}
formats.textContent =
`PDF · JPG · PNG · WEBP · GIF · BMP · TIFF · DOCX · XLSX · MD · CSV · ${formatBytes(MAX_FILE_BYTES)} each`;
picker.addEventListener("change", () => {
accept(picker.files);
picker.value = "";
});
clearBtn.addEventListener("click", clearAll);
restartBtn.addEventListener("click", () => {
clearAll();
showComposer();
picker.focus();
});
["dragenter", "dragover"].forEach((type) => {
dropzone.addEventListener(type, (event) => {
if (!event.dataTransfer?.types.includes("Files")) return;
event.preventDefault();
dropzone.classList.add("is-target");
});
});
["dragleave", "drop"].forEach((type) => {
dropzone.addEventListener(type, (event) => {
if (type === "dragleave" && dropzone.contains(event.relatedTarget)) return;
dropzone.classList.remove("is-target");
});
});
dropzone.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
accept(event.dataTransfer.files);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (busy || !files.length) return;
busy = true;
setAlert("");
progress.classList.add("is-running");
render();
try {
const parsed = [];
for (let index = 0; index < files.length; index += 1) {
buildBtn.textContent = `Reading ${index + 1} of ${files.length}…`;
parsed.push(await parseItem(files[index]));
await new Promise((resolve) => setTimeout(resolve, 0));
}
const result = await PdfBuilder.build(parsed, (_current, _total, label) => {
buildBtn.textContent = `${label}…`;
});
showDone(result);
} catch (error) {
setAlert(error.message || "Something went wrong building the PDF.");
} finally {
busy = false;
progress.classList.remove("is-running");
render();
}
});
render();
})();