patx/makeitpdf

/* Mixed-format PDF writer. PDFKit creates new searchable pages; pdf-lib copies
   existing PDF pages into their exact position in the final queue. */

(function (root, factory) {
  if (typeof module === "object" && module.exports) module.exports = factory();
  else root.PdfBuilder = factory();
})(typeof self !== "undefined" ? self : globalThis, function () {
  "use strict";

  const FONT_FILES = {
    NotoSans: "vendor/fonts/noto-sans-regular.ttf",
    NotoSansBold: "vendor/fonts/noto-sans-bold.ttf",
    NotoSansItalic: "vendor/fonts/noto-sans-italic.ttf",
    NotoSansBoldItalic: "vendor/fonts/noto-sans-bolditalic.ttf",
    NotoArabic: "vendor/fonts/noto-arabic-regular.ttf",
    NotoArabicBold: "vendor/fonts/noto-arabic-bold.ttf",
    NotoHebrew: "vendor/fonts/noto-hebrew-regular.ttf",
    NotoHebrewBold: "vendor/fonts/noto-hebrew-bold.ttf",
    NotoDevanagari: "vendor/fonts/noto-devanagari-regular.ttf",
    NotoDevanagariBold: "vendor/fonts/noto-devanagari-bold.ttf",
    NotoCJK: "vendor/fonts/noto-cjk-regular.ttc",
  };
  const FONT_CACHE = new Map();
  const INK = "#101113";
  const MUTED = "#666b72";
  const LINE = "#dfe2e6";
  const SURFACE = "#f5f6f7";
  const ACCENT = "#f4402d";
  const RTL_RE = /[\u0590-\u08ff\ufb1d-\ufefc]/;

  async function loadFont(name) {
    if (!FONT_CACHE.has(name)) {
      FONT_CACHE.set(
        name,
        fetch(FONT_FILES[name]).then((response) => {
          if (!response.ok) throw new Error(`Could not load the offline font ${name}.`);
          return response.arrayBuffer();
        })
      );
    }
    return FONT_CACHE.get(name);
  }

  function scriptsIn(text) {
    const value = String(text || "");
    return {
      arabic: /[\u0600-\u08ff\ufb50-\ufefc]/.test(value),
      hebrew: /[\u0590-\u05ff\ufb1d-\ufb4f]/.test(value),
      devanagari: /[\u0900-\u097f]/.test(value),
      japanese: /[\u3040-\u30ff]/.test(value),
      korean: /[\uac00-\ud7af\u1100-\u11ff]/.test(value),
      han: /[\u3400-\u9fff\uf900-\ufaff]/.test(value),
    };
  }

  function collectText(items) {
    const parts = [];
    for (const item of items) {
      parts.push(item.file?.name || "");
      for (const block of item.blocks || []) {
        parts.push(block.text || "");
        parts.push((block.spans || []).map((span) => span.text).join(""));
        parts.push((block.items || []).join(" "));
        for (const row of block.rows || []) parts.push(row.map((cell) => cell.text || cell).join(" "));
      }
      for (const sheet of item.sheets || []) {
        parts.push(sheet.name);
        for (const row of sheet.rows || []) parts.push(row.join(" "));
      }
    }
    return parts.join("\n");
  }

  async function registerFonts(doc, items) {
    const text = collectText(items);
    const scripts = scriptsIn(text);
    const names = ["NotoSans", "NotoSansBold", "NotoSansItalic", "NotoSansBoldItalic"];
    if (scripts.arabic) names.push("NotoArabic", "NotoArabicBold");
    if (scripts.hebrew) names.push("NotoHebrew", "NotoHebrewBold");
    if (scripts.devanagari) names.push("NotoDevanagari", "NotoDevanagariBold");
    if (scripts.japanese || scripts.korean || scripts.han) names.push("NotoCJK");
    const loaded = await Promise.all(names.map(async (name) => [name, await loadFont(name)]));
    for (const [name, buffer] of loaded) {
      const bytes = new Uint8Array(buffer);
      if (name === "NotoCJK") {
        doc.registerFont("NotoCJKSC", bytes, "NotoSansCJKsc-Regular");
        doc.registerFont("NotoCJKTC", bytes, "NotoSansCJKtc-Regular");
        doc.registerFont("NotoCJKJP", bytes, "NotoSansCJKjp-Regular");
        doc.registerFont("NotoCJKKR", bytes, "NotoSansCJKkr-Regular");
      } else {
        doc.registerFont(name, bytes);
      }
    }
  }

  function fontFor(text, bold, italic) {
    const scripts = scriptsIn(text);
    if (scripts.arabic) return bold ? "NotoArabicBold" : "NotoArabic";
    if (scripts.hebrew) return bold ? "NotoHebrewBold" : "NotoHebrew";
    if (scripts.devanagari) return bold ? "NotoDevanagariBold" : "NotoDevanagari";
    if (scripts.japanese) return "NotoCJKJP";
    if (scripts.korean) return "NotoCJKKR";
    if (scripts.han) return "NotoCJKSC";
    if (bold && italic) return "NotoSansBoldItalic";
    if (bold) return "NotoSansBold";
    if (italic) return "NotoSansItalic";
    return "NotoSans";
  }

  function visualRtl(text) {
    if (!RTL_RE.test(text) || typeof bidi_js !== "function") return text;
    const bidi = bidi_js();
    const levels = bidi.getEmbeddingLevels(text);
    const chars = Array.from(text);
    for (const [start, end] of bidi.getReorderSegments(text, levels)) {
      const reversed = chars.slice(start, end + 1).reverse();
      chars.splice(start, reversed.length, ...reversed);
    }
    const mirrored = bidi.getMirroredCharactersMap(text, levels);
    mirrored.forEach((replacement, index) => {
      if (index < chars.length) chars[index] = replacement;
    });
    return chars.join("");
  }

  function addLogicalText(doc, logical, options) {
    const rtl = RTL_RE.test(logical);
    if (rtl) doc.markContent("Span", { actual: logical });
    doc.text(rtl ? visualRtl(logical) : logical, { ...(options || {}), align: rtl ? "right" : options?.align });
    if (rtl) doc.endMarkedContent();
  }

  function fileToDataUrl(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(new Error(`${file.name} could not be read.`));
      reader.readAsDataURL(file);
    });
  }

  function canvasDataUrl(bitmap, orientation) {
    const turn = orientation >= 5 && orientation <= 8;
    const canvas = document.createElement("canvas");
    canvas.width = turn ? bitmap.height : bitmap.width;
    canvas.height = turn ? bitmap.width : bitmap.height;
    const context = canvas.getContext("2d");
    context.fillStyle = "#ffffff";
    context.fillRect(0, 0, canvas.width, canvas.height);
    const transforms = {
      2: [-1, 0, 0, 1, bitmap.width, 0],
      3: [-1, 0, 0, -1, bitmap.width, bitmap.height],
      4: [1, 0, 0, -1, 0, bitmap.height],
      5: [0, 1, 1, 0, 0, 0],
      6: [0, 1, -1, 0, bitmap.height, 0],
      7: [0, -1, -1, 0, bitmap.height, bitmap.width],
      8: [0, -1, 1, 0, 0, bitmap.width],
    };
    if (transforms[orientation]) context.setTransform(...transforms[orientation]);
    context.drawImage(bitmap, 0, 0);
    return canvas.toDataURL("image/png");
  }

  async function decodeTiff(buffer, name) {
    try {
      const ifds = UTIF.decode(buffer);
      if (!ifds.length) throw new Error("no pages");
      UTIF.decodeImage(buffer, ifds[0], ifds);
      const rgba = UTIF.toRGBA8(ifds[0]);
      const canvas = document.createElement("canvas");
      canvas.width = ifds[0].width;
      canvas.height = ifds[0].height;
      canvas.getContext("2d").putImageData(
        new ImageData(new Uint8ClampedArray(rgba), ifds[0].width, ifds[0].height),
        0,
        0
      );
      return createImageBitmap(canvas);
    } catch {
      throw new Error(`${name} is a TIFF this browser couldn't read.`);
    }
  }

  async function prepareImage(file) {
    const buffer = await file.arrayBuffer();
    const bytes = new Uint8Array(buffer);
    const metadata = Pages.parseRasterMetadata(bytes);
    const tiff = Pages.isTiff(bytes);
    let bitmap;
    try {
      bitmap = tiff
        ? await decodeTiff(buffer, file.name)
        : await createImageBitmap(file, { imageOrientation: "from-image" });
    } catch {
      throw new Error(`${file.name} isn't an image this browser can read.`);
    }

    const page = Pages.imagePage(
      metadata.width || bitmap.width,
      metadata.height || bitmap.height,
      metadata.dpiX,
      metadata.dpiY,
      metadata.orientation || 1
    );
    const simpleJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && (metadata.orientation || 1) === 1;
    const simplePng = bytes.length > 8 && String.fromCharCode(...bytes.slice(1, 4)) === "PNG";
    const src = simpleJpeg || simplePng
      ? await fileToDataUrl(file)
      : canvasDataUrl(bitmap, tiff ? metadata.orientation || 1 : 1);
    bitmap.close();
    return { src, page };
  }

  function addImagePage(doc, image) {
    doc.addPage({ size: [image.page.width, image.page.height], margin: 0 });
    doc.image(image.src, 0, 0, {
      width: image.page.width,
      height: image.page.height,
      ignoreOrientation: true,
    });
  }

  function addTextPage(doc) {
    doc.addPage({ size: "A4", layout: "portrait", margin: 54 });
    doc.font("NotoSans").fontSize(11).fillColor(INK);
  }

  function renderSpans(doc, spans, options) {
    const logical = spans.map((span) => span.text).join("");
    if (RTL_RE.test(logical)) {
      doc.font(fontFor(logical, spans.some((span) => span.bold), false));
      addLogicalText(doc, logical, options);
      return;
    }
    const usable = spans.filter((span) => span.text);
    usable.forEach((span, index) => {
      doc.font(span.code ? "NotoSans" : fontFor(span.text, span.bold, span.italic));
      doc.fillColor(span.link ? ACCENT : options?.color || INK);
      doc.text(span.text, {
        continued: index !== usable.length - 1,
        link: span.link || undefined,
        underline: Boolean(span.link),
        strike: Boolean(span.strike),
        lineGap: options?.lineGap,
      });
    });
    doc.fillColor(INK);
  }

  function renderTable(doc, rows, widths) {
    if (!rows.length) return;
    const data = rows.map((row, rowIndex) =>
      row.map((raw) => {
        const cell = typeof raw === "object" && raw !== null ? raw : { text: String(raw ?? "") };
        const header = rowIndex === 0 || cell.header;
        return {
          text: cell.text || "",
          colSpan: cell.colSpan || 1,
          rowSpan: cell.rowSpan || 1,
          type: header ? "TH" : "TD",
          scope: header ? "Column" : undefined,
          backgroundColor: header ? "#eceef1" : undefined,
          textColor: INK,
          borderColor: LINE,
          padding: 4,
          font: { src: fontFor(cell.text || "", header, false), size: 8 },
          textOptions: { lineGap: 1 },
        };
      })
    );
    doc.table({
      maxWidth: doc.page.contentWidth,
      columnStyles: widths || Array.from({ length: Math.max(...rows.map((row) => row.length)) }, () => "*"),
      defaultStyle: { border: 0.5, borderColor: LINE, padding: 4 },
      data,
    });
    doc.moveDown(0.75);
  }

  function renderBlocks(doc, blocks) {
    for (const block of blocks) {
      if (block.type === "heading") {
        const sizes = [0, 25, 20, 16, 14, 12, 11];
        const text = block.spans.map((span) => span.text).join("");
        doc.moveDown(block.level === 1 ? 0.3 : 0.55);
        doc.font(fontFor(text, true, false)).fontSize(sizes[block.level] || 12).fillColor(INK);
        addLogicalText(doc, text, { lineGap: 2 });
        doc.moveDown(0.25);
      } else if (block.type === "paragraph") {
        doc.fontSize(11).fillColor(INK);
        renderSpans(doc, block.spans, { lineGap: 3 });
        doc.moveDown(0.55);
      } else if (block.type === "list") {
        const joined = block.items.join(" ");
        doc.font(fontFor(joined, false, false)).fontSize(11).fillColor(INK);
        doc.list(block.items, {
          listType: block.ordered ? "numbered" : "bullet",
          indent: 18,
          textIndent: 10,
          bulletIndent: 2,
          lineGap: 3,
        });
        doc.moveDown(0.55);
      } else if (block.type === "quote") {
        const x = doc.x;
        doc.save().strokeColor(ACCENT).lineWidth(2).moveTo(x, doc.y).lineTo(x, doc.y + 34).stroke().restore();
        doc.x += 14;
        doc.font(fontFor(block.text, false, true)).fontSize(10.5).fillColor(MUTED);
        addLogicalText(doc, block.text, { lineGap: 3 });
        doc.x = x;
        doc.fillColor(INK).moveDown(0.65);
      } else if (block.type === "code") {
        const available = doc.page.contentWidth;
        doc.font(/[^-\u007f]/.test(block.text) ? "NotoSans" : "Courier").fontSize(9);
        const height = Math.min(doc.heightOfString(block.text, { width: available - 20 }) + 16, 500);
        if (doc.y + height > doc.page.height - doc.page.margins.bottom) doc.addPage();
        const top = doc.y;
        doc.save().fillColor(SURFACE).roundedRect(doc.x, top, available, height, 5).fill().restore();
        doc.fillColor(INK).text(block.text, doc.x + 10, top + 8, { width: available - 20, lineGap: 2 });
        doc.y = top + height + 8;
      } else if (block.type === "rule") {
        doc.moveDown(0.4);
        doc.save().strokeColor(LINE).lineWidth(1).moveTo(doc.x, doc.y).lineTo(doc.x + doc.page.contentWidth, doc.y).stroke().restore();
        doc.moveDown(0.7);
      } else if (block.type === "table") {
        renderTable(doc, block.rows);
      } else if (block.type === "image") {
        try {
          doc.image(block.src, { fit: [doc.page.contentWidth, 430], align: "center" });
          doc.moveDown(0.65);
        } catch {
          doc.font("NotoSansItalic").fontSize(10).fillColor(MUTED).text(block.alt || "Image");
          doc.moveDown(0.5);
        }
      }
    }
  }

  function columnGroups(sheet, availableWidth) {
    const columns = sheet.rows.reduce((max, row) => Math.max(max, row.length), 0);
    const widths = Array.from({ length: columns }, (_, column) => {
      if (sheet.widths?.[column]) return Math.max(42, Math.min(160, sheet.widths[column]));
      const longest = sheet.rows.slice(0, 250).reduce(
        (max, row) => Math.max(max, String(row[column] || "").length),
        0
      );
      return Math.max(42, Math.min(160, longest * 4.5 + 12));
    });
    const groups = [];
    let group = [];
    let used = 0;
    widths.forEach((width, index) => {
      if (group.length && used + width > availableWidth) {
        groups.push(group);
        group = [];
        used = 0;
      }
      group.push({ index, width: Math.min(width, availableWidth) });
      used += width;
    });
    if (group.length) groups.push(group);
    return groups;
  }

  function columnName(index) {
    let value = index + 1;
    let result = "";
    while (value) {
      value -= 1;
      result = String.fromCharCode(65 + (value % 26)) + result;
      value = Math.floor(value / 26);
    }
    return result;
  }

  function sheetRows(sheet, group) {
    const mergeAt = new Map();
    const covered = new Set();
    for (const merge of sheet.merges || []) {
      const groupStart = group[0].index;
      const groupEnd = group[group.length - 1].index;
      const mergeEnd = merge.startColumn + merge.colSpan - 1;
      if (merge.startColumn < groupStart || mergeEnd > groupEnd) continue;
      mergeAt.set(`${merge.startRow},${merge.startColumn}`, merge);
      for (let row = merge.startRow; row < merge.startRow + merge.rowSpan; row += 1) {
        for (let column = merge.startColumn; column <= mergeEnd; column += 1) {
          if (row !== merge.startRow || column !== merge.startColumn) covered.add(`${row},${column}`);
        }
      }
    }
    return sheet.rows.map((row, rowIndex) => {
      const output = [];
      for (const { index } of group) {
        if (covered.has(`${rowIndex},${index}`)) continue;
        const merge = mergeAt.get(`${rowIndex},${index}`);
        output.push({
          text: String(row[index] || ""),
          header: rowIndex === 0,
          colSpan: merge?.colSpan || 1,
          rowSpan: merge?.rowSpan || 1,
        });
      }
      return output;
    });
  }

  function sheetTitle(doc, title, continued) {
    doc.font(fontFor(title, true, false)).fontSize(16).fillColor(INK)
      .text(title + (continued ? " · continued" : ""));
    doc.moveDown(0.55).fontSize(8).font("NotoSans");
  }

  function estimatedRowHeight(doc, row, widths) {
    let height = 0;
    row.forEach((cell, index) => {
      const text = typeof cell === "object" ? cell.text : String(cell || "");
      doc.font(fontFor(text, Boolean(cell?.header), false)).fontSize(8);
      height = Math.max(height, doc.heightOfString(text || " ", {
        width: Math.max(8, (widths[index] || 42) - 8),
        lineGap: 1,
      }) + 8);
    });
    return Math.min(500, Math.max(17, height));
  }

  function renderPaginatedSheet(doc, title, rows, widths) {
    const header = rows[0] || [];
    let pageRows = [header];
    let continued = false;

    function newPage() {
      doc.addPage({ size: "A4", layout: "landscape", margin: 30 });
      sheetTitle(doc, title, continued);
      continued = true;
      pageRows = [header];
    }

    newPage();
    let used = estimatedRowHeight(doc, header, widths);
    for (const row of rows.slice(1)) {
      const height = estimatedRowHeight(doc, row, widths);
      const available = doc.page.height - doc.page.margins.bottom - doc.y;
      if (pageRows.length > 1 && used + height > available) {
        renderTable(doc, pageRows, widths);
        newPage();
        used = estimatedRowHeight(doc, header, widths);
      }
      pageRows.push(row);
      used += height;
    }
    renderTable(doc, pageRows, widths);
  }

  function renderSheets(doc, sheets) {
    for (const sheet of sheets) {
      const available = Pages.A4_PORTRAIT[1] - 60;
      const groups = columnGroups(sheet, available);
      if (!groups.length) {
        doc.addPage({ size: "A4", layout: "landscape", margin: 30 });
        doc.font("NotoSansBold").fontSize(17).fillColor(INK).text(sheet.name);
        doc.moveDown(0.6).font("NotoSans").fontSize(10).fillColor(MUTED).text("This sheet is empty.");
        continue;
      }
      groups.forEach((group, groupIndex) => {
        const suffix = groups.length > 1
          ? ` · columns ${columnName(group[0].index)}–${columnName(group[group.length - 1].index)}`
          : "";
        renderPaginatedSheet(
          doc,
          sheet.name + suffix,
          sheetRows(sheet, group),
          group.map((column) => column.width)
        );
        if (groupIndex < groups.length - 1) doc.moveDown(0.1);
      });
    }
  }

  async function buildConverted(items, onProgress) {
    if (typeof PDFDocument !== "function" || typeof blobStream !== "function") {
      throw new Error("The offline PDF engine did not load. Refresh and try again.");
    }
    const doc = new PDFDocument({
      autoFirstPage: false,
      bufferPages: true,
      compress: true,
      tagged: true,
      pdfVersion: "1.5",
      info: { Title: "MakeItPDF conversion", Creator: "MakeItPDF" },
    });
    const stream = doc.pipe(blobStream());
    await registerFonts(doc, items);
    const warnings = [];

    for (let index = 0; index < items.length; index += 1) {
      const item = items[index];
      onProgress?.(index, items.length, `Converting ${item.file.name}`);
      if (item.kind === "image") addImagePage(doc, await prepareImage(item.file));
      else if (item.kind === "xlsx" || item.kind === "csv") renderSheets(doc, item.sheets);
      else {
        addTextPage(doc);
        renderBlocks(doc, item.blocks);
      }
      warnings.push(...(item.warnings || []).map((warning) => `${item.file.name}: ${warning}`));
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
    const pageCount = doc.bufferedPageRange().count;
    onProgress?.(items.length, items.length, "Finishing PDF");
    doc.end();
    const blob = await new Promise((resolve, reject) => {
      stream.on("finish", () => resolve(stream.toBlob("application/pdf")));
      stream.on("error", reject);
    });
    return { blob, pageCount, warnings };
  }

  function pdfLoadError(name, error) {
    if (/encrypt|password/i.test(String(error?.message || error))) {
      return new Error(`${name} is password-protected. Remove its password and try again.`);
    }
    return new Error(`${name} isn't a valid PDF or uses PDF features this browser couldn't read.`);
  }

  async function appendPdf(merged, engine, bytes, name) {
    let source;
    try {
      source = await engine.PDFDocument.load(bytes, { updateMetadata: false });
      if (!source.getPageCount()) throw new Error("PDF has no pages");
      const pages = await merged.copyPages(source, source.getPageIndices());
      pages.forEach((page) => merged.addPage(page));
    } catch (error) {
      throw pdfLoadError(name, error);
    }
  }

  async function build(items, onProgress) {
    if (!items.some((item) => item.kind === "pdf")) return buildConverted(items, onProgress);

    const engine = globalThis.PDFLib;
    if (!engine?.PDFDocument) {
      throw new Error("The offline PDF combining engine did not load. Refresh and try again.");
    }

    const merged = await engine.PDFDocument.create();
    const warnings = [];
    let index = 0;

    while (index < items.length) {
      const item = items[index];
      if (item.kind === "pdf") {
        onProgress?.(index, items.length, `Adding ${item.file.name}`);
        let bytes;
        try {
          bytes = await item.file.arrayBuffer();
        } catch {
          throw new Error(`${item.file.name} could not be read.`);
        }
        await appendPdf(merged, engine, bytes, item.file.name);
        index += 1;
        await new Promise((resolve) => setTimeout(resolve, 0));
        continue;
      }

      const start = index;
      const run = [];
      while (index < items.length && items[index].kind !== "pdf") run.push(items[index++]);
      const converted = await buildConverted(run, (current, total, label) => {
        onProgress?.(start + Math.min(current, total), items.length, label);
      });
      warnings.push(...converted.warnings);
      await appendPdf(
        merged,
        engine,
        await converted.blob.arrayBuffer(),
        "The newly converted section"
      );
    }

    onProgress?.(items.length, items.length, "Finishing PDF");
    merged.setTitle("MakeItPDF combined document");
    merged.setCreator("MakeItPDF");
    merged.setProducer("MakeItPDF");
    const bytes = await merged.save();
    return {
      blob: new Blob([bytes], { type: "application/pdf" }),
      pageCount: merged.getPageCount(),
      warnings,
    };
  }

  return { build, scriptsIn, visualRtl, columnGroups };
});