patx/makeitpdf

/* 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();
})();