master
noah Publish 1.0.0 a09b9ee 2mo ago
/*
 * render-pdf.js  —  draw list -> jsPDF document (professional PDF export).
 * Consumes exactly the ops produced by harness-layout.js, so the PDF matches
 * the on-screen SVG preview 1:1.
 */
(function (global) {
  'use strict';

  function getCtor() {
    if (global.jspdf && global.jspdf.jsPDF) return global.jspdf.jsPDF;
    if (typeof require !== 'undefined') { try { return require('jspdf').jsPDF; } catch (e) {} }
    throw new Error('jsPDF not loaded');
  }

  // parse #rgb / #rrggbb / rgb() / rgba() -> {r,g,b,a}
  function parseColor(str) {
    if (str == null) return null;
    str = String(str).trim();
    var m;
    if (str[0] === '#') {
      if (str.length === 4) return { r: parseInt(str[1] + str[1], 16), g: parseInt(str[2] + str[2], 16), b: parseInt(str[3] + str[3], 16), a: 1 };
      return { r: parseInt(str.slice(1, 3), 16), g: parseInt(str.slice(3, 5), 16), b: parseInt(str.slice(5, 7), 16), a: 1 };
    }
    if ((m = str.match(/rgba?\(([^)]+)\)/))) {
      var p = m[1].split(',').map(function (s) { return parseFloat(s); });
      return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
    }
    return { r: 0, g: 0, b: 0, a: 1 };
  }
  // blend an rgba over white -> opaque rgb (jsPDF fills are opaque)
  function overWhite(c) {
    if (c.a >= 1) return c;
    return { r: Math.round(c.r * c.a + 255 * (1 - c.a)), g: Math.round(c.g * c.a + 255 * (1 - c.a)), b: Math.round(c.b * c.a + 255 * (1 - c.a)), a: 1 };
  }

  function setDash(doc, dash) {
    try {
      if (dash && dash.length) {
        if (doc.setLineDashPattern) doc.setLineDashPattern(dash, 0);
        else if (doc.setLineDash) doc.setLineDash(dash, 0);
      } else {
        if (doc.setLineDashPattern) doc.setLineDashPattern([], 0);
        else if (doc.setLineDash) doc.setLineDash([], 0);
      }
    } catch (e) {}
  }
  function setCap(doc, cap) { try { doc.setLineCap(cap || 'butt'); } catch (e) {} }
  function setJoin(doc, join) { try { doc.setLineJoin(join || 'miter'); } catch (e) {} }

  // convert absolute path segments to jsPDF relative lines[]
  function toRelLines(d) {
    var lines = [], cur = null, start = null;
    for (var i = 0; i < d.length; i++) {
      var c = d[i];
      if (c[0] === 'M') { start = { x: c[1], y: c[2] }; cur = { x: c[1], y: c[2] }; }
      else if (c[0] === 'L') { lines.push([c[1] - cur.x, c[2] - cur.y]); cur = { x: c[1], y: c[2] }; }
      else if (c[0] === 'C') {
        lines.push([c[1] - cur.x, c[2] - cur.y, c[3] - cur.x, c[4] - cur.y, c[5] - cur.x, c[6] - cur.y]);
        cur = { x: c[5], y: c[6] };
      } else if (c[0] === 'Q') {
        var c1x = cur.x + 2 / 3 * (c[1] - cur.x), c1y = cur.y + 2 / 3 * (c[2] - cur.y);
        var c2x = c[3] + 2 / 3 * (c[1] - c[3]), c2y = c[4] + 2 / 3 * (c[2] - c[4]);
        lines.push([c1x - cur.x, c1y - cur.y, c2x - cur.x, c2y - cur.y, c[3] - cur.x, c[4] - cur.y]);
        cur = { x: c[3], y: c[4] };
      }
    }
    return { start: start, lines: lines };
  }

  function fontName(f) { return f === 'mono' ? 'courier' : 'helvetica'; }

  function drawOp(doc, o) {
    if (o.svgOnly) return; // interaction-only hit areas never go into the PDF
    if (o.op === 'rect') {
      var hasFill = !!o.fill, hasStroke = !!o.stroke;
      var style = hasFill && hasStroke ? 'FD' : hasFill ? 'F' : 'S';
      if (hasFill) { var f = overWhite(parseColor(o.fill)); doc.setFillColor(f.r, f.g, f.b); }
      if (hasStroke) { var s = parseColor(o.stroke); doc.setDrawColor(s.r, s.g, s.b); doc.setLineWidth(o.lw || 1); }
      setDash(doc, o.dash);
      if (o.rx) doc.roundedRect(o.x, o.y, o.w, o.h, o.rx, o.rx, style);
      else doc.rect(o.x, o.y, o.w, o.h, style);
      setDash(doc, null);
    } else if (o.op === 'line') {
      var sc = parseColor(o.stroke || '#000'); doc.setDrawColor(sc.r, sc.g, sc.b);
      doc.setLineWidth(o.lw || 1); setCap(doc, o.cap); setDash(doc, o.dash);
      doc.line(o.x1, o.y1, o.x2, o.y2);
      setDash(doc, null); setCap(doc, 'butt');
    } else if (o.op === 'path') {
      var rl = toRelLines(o.d);
      if (!rl.start) return;
      var pstyle = 'S';
      if (o.fill) { var pf = overWhite(parseColor(o.fill)); doc.setFillColor(pf.r, pf.g, pf.b); pstyle = o.stroke ? 'FD' : 'F'; }
      if (o.stroke) { var ps = parseColor(o.stroke); doc.setDrawColor(ps.r, ps.g, ps.b); doc.setLineWidth(o.lw || 1); }
      setCap(doc, o.cap); setJoin(doc, o.join); setDash(doc, o.dash);
      doc.lines(rl.lines, rl.start.x, rl.start.y, [1, 1], pstyle, false);
      setDash(doc, null); setCap(doc, 'butt'); setJoin(doc, 'miter');
    } else if (o.op === 'circle') {
      var cstyle = o.fill && o.stroke ? 'FD' : o.fill ? 'F' : 'S';
      if (o.fill) { var cf = overWhite(parseColor(o.fill)); doc.setFillColor(cf.r, cf.g, cf.b); }
      if (o.stroke) { var cs = parseColor(o.stroke); doc.setDrawColor(cs.r, cs.g, cs.b); doc.setLineWidth(o.lw || 1); }
      doc.circle(o.x, o.y, o.r, cstyle);
    } else if (o.op === 'text') {
      doc.setFont(fontName(o.font), o.weight === 'bold' ? 'bold' : 'normal');
      doc.setFontSize(o.size || 8);
      var tc = parseColor(o.color || '#000'); doc.setTextColor(tc.r, tc.g, tc.b);
      var align = o.align === 'middle' ? 'center' : o.align === 'end' ? 'right' : 'left';
      var optsT = { align: align, baseline: o.baseline || 'alphabetic' };
      if (o.angle) optsT.angle = -o.angle; // jsPDF rotates CCW-positive
      doc.text(String(o.text == null ? '' : o.text), o.x, o.y, optsT);
    }
  }

  function toDoc(pages) {
    var jsPDF = getCtor();
    var p0 = pages[0];
    var doc = new jsPDF({ unit: 'pt', format: [p0.w, p0.h], orientation: p0.w >= p0.h ? 'landscape' : 'portrait', compress: true });
    for (var pi = 0; pi < pages.length; pi++) {
      if (pi > 0) doc.addPage([pages[pi].w, pages[pi].h], pages[pi].w >= pages[pi].h ? 'landscape' : 'portrait');
      var ops = pages[pi].ops;
      for (var i = 0; i < ops.length; i++) drawOp(doc, ops[i]);
    }
    return doc;
  }

  global.RenderPDF = {
    toDoc: toDoc,
    buildFromModel: function (model) {
      var res = global.HarnessLayout.build(model);
      return { doc: toDoc(res.pages), warnings: res.warnings, pages: res.pages };
    },
  };
})(typeof window !== 'undefined' ? window : globalThis);