patx/makeitpdf

/* Safe, format-neutral document parsing helpers. */

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

  const SAFE_LINK = /^(https?:|mailto:)/i;
  const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp|gif|bmp);base64,/i;

  function safeUrl(value) {
    const url = String(value || "").trim();
    return SAFE_LINK.test(url) ? url : null;
  }

  function decodeUtf8(buffer) {
    try {
      return new TextDecoder("utf-8", { fatal: true }).decode(buffer).replace(/^\uFEFF/, "");
    } catch {
      throw new Error("isn't valid UTF-8. Save it as UTF-8 and try again.");
    }
  }

  function parseDelimited(text, delimiter) {
    const rows = [];
    let row = [];
    let field = "";
    let quoted = false;

    for (let index = 0; index < text.length; index += 1) {
      const char = text[index];
      if (quoted) {
        if (char === '"' && text[index + 1] === '"') {
          field += '"';
          index += 1;
        } else if (char === '"') {
          quoted = false;
        } else {
          field += char;
        }
      } else if (char === '"' && field.length === 0) {
        quoted = true;
      } else if (char === delimiter) {
        row.push(field);
        field = "";
      } else if (char === "\n" || char === "\r") {
        if (char === "\r" && text[index + 1] === "\n") index += 1;
        row.push(field);
        rows.push(row);
        row = [];
        field = "";
      } else {
        field += char;
      }
    }
    if (quoted) throw new Error("has an unclosed quoted field.");
    if (field.length || row.length || !rows.length) {
      row.push(field);
      rows.push(row);
    }
    return rows;
  }

  function detectDelimiter(text) {
    const candidates = [",", "\t", ";", "|"];
    let best = { delimiter: ",", score: -Infinity };
    for (const delimiter of candidates) {
      let rows;
      try {
        rows = parseDelimited(text.slice(0, 65536), delimiter).slice(0, 12);
      } catch {
        continue;
      }
      const counts = rows.filter((row) => row.some((cell) => cell !== "")).map((row) => row.length);
      const common = counts.reduce((map, count) => map.set(count, (map.get(count) || 0) + 1), new Map());
      const consistency = Math.max(0, ...common.values());
      const width = Math.max(1, ...counts);
      const score = width > 1 ? consistency * 100 + width : 0;
      if (score > best.score) best = { delimiter, score };
    }
    return best.delimiter;
  }

  function parseCsv(text) {
    let inQuotes = false;
    for (let index = 0; index < text.length; index += 1) {
      if (text[index] !== '"') continue;
      if (inQuotes && text[index + 1] === '"') index += 1;
      else inQuotes = !inQuotes;
    }
    if (inQuotes) throw new Error("has an unclosed quoted field.");
    const delimiter = detectDelimiter(text);
    const rows = parseDelimited(text, delimiter);
    const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
    rows.forEach((row) => {
      while (row.length < width) row.push("");
    });
    return { delimiter, rows, cells: rows.length * width };
  }

  function cleanText(value) {
    return String(value || "")
      .replace(/\u00a0/g, " ")
      .replace(/[ \t]+\n/g, "\n")
      .replace(/\n[ \t]+/g, "\n")
      .trim();
  }

  function htmlToBlocks(html) {
    if (typeof DOMParser === "undefined") throw new Error("This browser cannot read document content.");
    const parsed = new DOMParser().parseFromString(String(html || ""), "text/html");
    const blocks = [];
    const warnings = [];

    function inline(node, style, into) {
      const output = into || [];
      const current = style || {};
      if (node.nodeType === 3) {
        if (node.nodeValue) output.push({ text: node.nodeValue, ...current });
        return output;
      }
      if (node.nodeType !== 1) return output;
      const tag = node.tagName.toLowerCase();
      if (tag === "br") {
        output.push({ text: "\n", ...current });
        return output;
      }
      if (tag === "img") return output;

      const next = { ...current };
      if (tag === "strong" || tag === "b") next.bold = true;
      if (tag === "em" || tag === "i") next.italic = true;
      if (tag === "code") next.code = true;
      if (tag === "s" || tag === "del" || tag === "strike") next.strike = true;
      if (tag === "a") {
        const href = safeUrl(node.getAttribute("href"));
        if (href) next.link = href;
        else if (node.getAttribute("href")) warnings.push(`Skipped an unsafe link: ${node.getAttribute("href")}`);
      }
      Array.from(node.childNodes).forEach((child) => inline(child, next, output));
      return output;
    }

    function imagesFrom(element) {
      return Array.from(element.querySelectorAll("img")).map((img) => {
        const src = String(img.getAttribute("src") || "");
        const alt = cleanText(img.getAttribute("alt") || "Image");
        if (SAFE_DATA_IMAGE.test(src)) return { type: "image", src, alt };
        const label = src ? `${alt} — ${src}` : alt;
        warnings.push(`Did not load linked image: ${src || alt}`);
        return { type: "paragraph", spans: [{ text: label, italic: true }] };
      });
    }

    function addTextBlock(type, element, extra) {
      const spans = inline(element).filter((span) => span.text);
      const text = cleanText(spans.map((span) => span.text).join(""));
      if (text) blocks.push({ type, spans, ...(extra || {}) });
      blocks.push(...imagesFrom(element));
    }

    function walk(element) {
      if (element.nodeType !== 1) return;
      const tag = element.tagName.toLowerCase();
      if (/^h[1-6]$/.test(tag)) return addTextBlock("heading", element, { level: Number(tag[1]) });
      if (tag === "p") return addTextBlock("paragraph", element);
      if (tag === "pre") {
        const text = element.textContent || "";
        if (text) blocks.push({ type: "code", text: text.replace(/\n$/, "") });
        return;
      }
      if (tag === "blockquote") {
        const text = cleanText(element.textContent);
        if (text) blocks.push({ type: "quote", text });
        return;
      }
      if (tag === "hr") {
        blocks.push({ type: "rule" });
        return;
      }
      if (tag === "img") {
        blocks.push(...imagesFrom(element.parentElement || element));
        return;
      }
      if (tag === "ul" || tag === "ol") {
        const items = Array.from(element.children)
          .filter((child) => child.tagName.toLowerCase() === "li")
          .map((item) => {
            const clone = item.cloneNode(true);
            clone.querySelectorAll("ul,ol").forEach((nested) => nested.remove());
            return cleanText(clone.textContent);
          })
          .filter(Boolean);
        if (items.length) blocks.push({ type: "list", ordered: tag === "ol", items });
        return;
      }
      if (tag === "table") {
        const rows = Array.from(element.querySelectorAll("tr")).map((row) =>
          Array.from(row.children)
            .filter((cell) => /^(td|th)$/i.test(cell.tagName))
            .map((cell) => ({
              text: cleanText(cell.textContent),
              header: cell.tagName.toLowerCase() === "th",
              colSpan: Math.max(1, Number(cell.getAttribute("colspan")) || 1),
              rowSpan: Math.max(1, Number(cell.getAttribute("rowspan")) || 1),
            }))
        );
        if (rows.length) blocks.push({ type: "table", rows });
        return;
      }
      if (["body", "main", "article", "section", "div", "figure", "figcaption"].includes(tag)) {
        Array.from(element.children).forEach(walk);
      } else if (cleanText(element.textContent)) {
        addTextBlock("paragraph", element);
      }
    }

    Array.from(parsed.body.children).forEach(walk);
    return { blocks, warnings };
  }

  return {
    SAFE_DATA_IMAGE,
    safeUrl,
    decodeUtf8,
    detectDelimiter,
    parseCsv,
    htmlToBlocks,
  };
});