/* global React */
// ============================================================
// escp.jsx — พิมพ์ผ่าน "ฟอนต์ในตัวเครื่อง" ด้วยคำสั่ง ESC/P
//
// ต่างจาก dotmatrix.jsx อย่างไร:
//   dotmatrix.jsx  ทำ HTML → Chromium เรนเดอร์เป็นรูป → ส่งจุดให้หัวเข็มยิง
//                  จัดหน้าสวย มีเส้นกรอบ ตัวอักษรหลายขนาด — แต่ตัวเล็กๆ เบลอ
//   escp.jsx       ส่ง "ไบต์ตัวอักษร" ให้เครื่องโดยตรง เครื่องใช้ฟอนต์ ROM ของมันเอง
//                  คมกริบและเร็วกว่ามาก — แต่ได้แค่ตัวอักษรความกว้างคงที่
//
// ข้อควรรู้เรื่องภาษาไทย:
//   - ส่งเป็น TIS-620 (ไทยเป็นไบต์เดียว) ไม่ใช่ UTF-8
//   - สระบน-ล่างและวรรณยุกต์ "ไม่กินที่" บนเครื่องพิมพ์ไทย
//     การนับความกว้างจึงต้องข้ามอักขระพวกนี้ ไม่งั้นคอลัมน์เหลื่อมกันหมด
//   - ตารางอักขระไทยอยู่ที่ ESC t เท่าไร ขึ้นกับรุ่นเครื่อง — ให้ผู้ใช้เลือกเอง
//     แล้วดูจากหน้าทดสอบว่าอันไหนออกมาถูก
// ============================================================

// ---------- คำสั่ง ESC/P ----------
const ESC = 0x1b, FF = 0x0c, LF = 0x0a, CR = 0x0d, SO = 0x0e, SI = 0x0f, DC2 = 0x12, DC4 = 0x14;

// เครื่องหมายภายในสตริง (ถูกแปลงเป็นคำสั่งตอน serialize ไม่ได้พิมพ์ออกมา)
const MK_BOLD_ON  = "\u0001";
const MK_BOLD_OFF = "\u0002";
const MK_DW       = "\u0003";   // ต้นบรรทัด = บรรทัดนี้ตัวกว้าง 2 เท่า
const MK_FF       = "\u000C";   // ขึ้นแผ่นใหม่

// ---------- ความกว้างสูงสุดของหัวพิมพ์ (แคร่แคบมาตรฐาน) ----------
// เครื่องแคร่แคบพิมพ์ได้ 80 ตัวที่ 10 CPI ต่อให้ใส่กระดาษกว้างกว่านั้น
const ESCP_CPI = {
  10: { label: "10 ตัว/นิ้ว — Pica (ใหญ่สุด อ่านง่ายสุด)", max: 80, cmd: [ESC, 0x50] },
  12: { label: "12 ตัว/นิ้ว — Elite", max: 96, cmd: [ESC, 0x4d] },
  15: { label: "15 ตัว/นิ้ว — เล็ก ใส่ได้เยอะ", max: 120, cmd: [ESC, 0x67] },
  17: { label: "17 ตัว/นิ้ว — Pica ย่อ (เล็กสุด)", max: 137, cmd: [ESC, 0x50, SI] },
};
const ESCP_CPI_LIST = Object.keys(ESCP_CPI).map((k) => ({ v: Number(k), ...ESCP_CPI[k] }));

// เครื่องเลื่อนหัวพิมพ์ให้สระ/วรรณยุกต์หรือไม่ — ต่างรุ่นต่างโหมดไม่เหมือนกัน
// เลือกผิดแล้วคอลัมน์ในตารางจะเหลื่อม ใช้หน้าทดสอบแนวคอลัมน์ตัดสิน
const ESCP_MARK_MODES = [
  { v: "none", label: "เครื่องพิมพ์ทับให้เอง — ไม่กินที่ (โหมด 3 พาสในตัวเครื่อง)" },
  { v: "overprint", label: "ให้ระบบพิมพ์ทับเอง — ได้เฉพาะเครื่องที่วาดสระไว้บน-ล่างของช่อง" },
  { v: "stacked", label: "เฉพาะวรรณยุกต์ที่ซ้อนบนสระ กินที่ 1 ช่อง" },
  { v: "all", label: "กินที่ทุกตัว — คอลัมน์ตรง แต่สระยังลอยอยู่ข้างตัว" },
];

// ตารางอักขระไทยอยู่ที่ไหน ขึ้นกับรุ่น — หน้าทดสอบจะพิมพ์ให้ดูทุกค่า
const ESCP_TABLES = [
  { v: "", label: "ไม่ส่งคำสั่ง — ใช้ค่าที่ตั้งไว้ที่ตัวเครื่อง (แนะนำให้ลองก่อน)" },
  { v: "0", label: "ESC t 0" },
  { v: "1", label: "ESC t 1" },
  { v: "2", label: "ESC t 2" },
  { v: "3", label: "ESC t 3" },
];

// ============================================================
// เข้ารหัส TIS-620
// ============================================================
// อักษรไทย U+0E01..U+0E5B เรียงตรงกับ 0xA1..0xFB พอดี (บวก 0xA0)
function tisByte(cp) {
  if (cp === 0x0a || cp === 0x0d) return cp;
  if (cp >= 0x20 && cp < 0x7f) return cp;
  if (cp >= 0x0e01 && cp <= 0x0e5b) return cp - 0x0e00 + 0xa0;
  return 0x3f;   // "?" — อักขระที่เครื่องไม่มี
}

// อักขระที่ TIS-620 ไม่มี แต่คนไทยพิมพ์กันบ่อย → แทนด้วยตัวที่ใกล้เคียง
const ESCP_SUBST = {
  "·": "-", "•": "-", "—": "-", "–": "-", "―": "-",
  "…": "...", "“": '"', "”": '"', "„": '"', "‘": "'", "’": "'",
  " ": " ", "​": "", "﻿": "",
  "×": "x", "÷": "/", "≈": "~", "≤": "<=", "≥": ">=",
  "→": "->", "←": "<-", "↑": "^", "↓": "v", "™": "TM", "©": "(c)", "®": "(R)",
};
function escpClean(s) {
  let out = "";
  for (const ch of String(s == null ? "" : s)) {
    if (Object.prototype.hasOwnProperty.call(ESCP_SUBST, ch)) { out += ESCP_SUBST[ch]; continue; }
    const cp = ch.codePointAt(0);
    // ตัดอักขระควบคุมทิ้ง ยกเว้นเครื่องหมายภายในของเราเอง
    if (cp < 0x20 && ch !== MK_BOLD_ON && ch !== MK_BOLD_OFF && ch !== MK_DW && ch !== MK_FF) continue;
    out += ch;
  }
  return out;
}

// ============================================================
// ความกว้างที่ "กินที่จริง" ของข้อความไทย
// ============================================================
// สระบน-ล่างและวรรณยุกต์พิมพ์ทับตัวก่อนหน้า หัวพิมพ์ไม่เลื่อน
const ESCP_NONSPACING = new Set([
  0x0e31,                                                   // ั
  0x0e34, 0x0e35, 0x0e36, 0x0e37, 0x0e38, 0x0e39, 0x0e3a,   // ิ ี ึ ื ุ ู ฺ
  0x0e47, 0x0e48, 0x0e49, 0x0e4a, 0x0e4b, 0x0e4c, 0x0e4d, 0x0e4e, // ็ ่ ้ ๊ ๋ ์ ํ ๎
]);
// สระบน (ที่วรรณยุกต์ไปซ้อนทับได้) กับวรรณยุกต์ — ใช้แยกกรณี "ซ้อน"
const ESCP_UPPER_VOWEL = new Set([0x0e31, 0x0e34, 0x0e35, 0x0e36, 0x0e37]); // ั ิ ี ึ ื
const ESCP_TONE = new Set([0x0e47, 0x0e48, 0x0e49, 0x0e4a, 0x0e4b, 0x0e4c, 0x0e4d, 0x0e4e]); // ็ ่ ้ ๊ ๋ ์ ํ ๎

// เครื่องพิมพ์ไทยแต่ละรุ่น/แต่ละโหมดจัดการสระ-วรรณยุกต์ไม่เหมือนกัน
//   none    = พิมพ์ทับตัวเดิม หัวพิมพ์ไม่เลื่อน (โหมด 3 พาส — ถูกต้องที่สุด)
//   stacked = เฉพาะวรรณยุกต์ที่ไปซ้อนบนสระบน ต้องใช้ช่องของตัวเอง
//   all     = สระบน-ล่างและวรรณยุกต์กินที่ทุกตัว
// เลือกผิด = คอลัมน์ในตารางเหลื่อมกันหมด · หน้าทดสอบแนวคอลัมน์มีไว้ชี้ขาดข้อนี้
let ESCP_MARK_MODE = "none";
function escpSetMarkMode(m) {
  ESCP_MARK_MODE = (m === "all" || m === "stacked" || m === "overprint") ? m : "none";
}
// overprint = เราแยกพิมพ์เองทีละชั้น สระจึงไม่กินที่เหมือนโหมด none
function escpMarksAdvance() {
  return ESCP_MARK_MODE === "all" || ESCP_MARK_MODE === "stacked";
}
function escpWidth(s) {
  let n = 0, prev = 0;
  for (const ch of String(s == null ? "" : s)) {
    const cp = ch.codePointAt(0);
    if (cp < 0x20) continue;                       // เครื่องหมายภายใน ไม่กินที่
    if (ESCP_NONSPACING.has(cp)) {
      if (ESCP_MARK_MODE === "all") n++;
      else if (ESCP_MARK_MODE === "stacked" && ESCP_TONE.has(cp) && ESCP_UPPER_VOWEL.has(prev)) n++;
      prev = cp;
      continue;
    }
    n++; prev = cp;
  }
  return n;
}
function escpClip(s, n) {
  s = String(s == null ? "" : s);
  if (escpWidth(s) <= n) return s;
  let out = "", w = 0, prev = 0;
  for (const ch of s) {
    const cp = ch.codePointAt(0);
    let adds = 1;
    if (cp < 0x20) adds = 0;
    else if (ESCP_NONSPACING.has(cp)) {
      adds = (ESCP_MARK_MODE === "all"
              || (ESCP_MARK_MODE === "stacked" && ESCP_TONE.has(cp) && ESCP_UPPER_VOWEL.has(prev))) ? 1 : 0;
    }
    if (w + adds > n) break;
    out += ch; w += adds; prev = cp;
  }
  return out;
}
function padR(s, n) { s = escpClip(s, n); return s + " ".repeat(Math.max(0, n - escpWidth(s))); }
function padL(s, n) { s = escpClip(s, n); return " ".repeat(Math.max(0, n - escpWidth(s))) + s; }
function padC(s, n) {
  s = escpClip(s, n);
  const left = Math.max(0, Math.floor((n - escpWidth(s)) / 2));
  return " ".repeat(left) + s + " ".repeat(Math.max(0, n - escpWidth(s) - left));
}
const B = (s) => MK_BOLD_ON + s + MK_BOLD_OFF;

// นับทุกอักขระเป็น 1 ช่อง (ไม่สนโหมด) — ใช้ตอนยังไม่รู้ว่าเครื่องจัดการสระยังไง
function escpWidthRaw(s) {
  let n = 0;
  for (const ch of String(s == null ? "" : s)) if (ch.codePointAt(0) >= 0x20) n++;
  return n;
}
function escpWrapRaw(text, W) {
  const words = String(text).split(" ");
  const out = [];
  let cur = "";
  const flush = () => { if (cur !== "") { out.push(cur); cur = ""; } };
  for (let w of words) {
    while (escpWidthRaw(w) > W) {
      flush();
      let cut = "", n = 0;
      for (const ch of w) { if (ch.codePointAt(0) >= 0x20) { if (n >= W) break; n++; } cut += ch; }
      out.push(cut);
      w = w.slice(cut.length);
    }
    if (cur === "") cur = w;
    else if (escpWidthRaw(cur) + 1 + escpWidthRaw(w) <= W) cur += " " + w;
    else { flush(); cur = w; }
  }
  flush();
  return out.length ? out : [""];
}

// ตัดข้อความยาวเป็นหลายบรรทัดตามช่องว่าง — คำที่ยาวเกินหนึ่งบรรทัดจะถูกหั่น
function escpWrap(text, W) {
  const words = String(text).split(" ");
  const out = [];
  let cur = "";
  const flush = () => { if (cur !== "") { out.push(cur); cur = ""; } };
  for (let w of words) {
    while (escpWidth(w) > W) {          // คำเดียวยาวเกินบรรทัด
      flush();
      out.push(escpClip(w, W));
      w = w.slice(escpClip(w, W).length);
    }
    if (cur === "") cur = w;
    else if (escpWidth(cur) + 1 + escpWidth(w) <= W) cur += " " + w;
    else { flush(); cur = w; }
  }
  flush();
  return out.length ? out : [""];
}

// ---------- ตัวเลข/วันที่ ----------
function eFmt(n) {
  return (Number(n) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function eDate(d) { return d ? String(d) : "-"; }
function eNow() {
  const d = new Date(), p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}

// ============================================================
// ตัวจัดคอลัมน์
// ============================================================
// def = [{ w: 10, a: "l"|"r"|"c", h: "หัวคอลัมน์", flex: true, drop: 2 }]
//   flex = กินที่ที่เหลือ · drop = ลำดับการตัดทิ้งเมื่อกระดาษแคบ (มากถูกตัดก่อน)
const ESCP_MIN_FLEX = 6;   // คอลัมน์ยืดได้ที่แคบกว่านี้ไม่มีประโยชน์ ตัดทิ้งดีกว่า
function escpCols(W, defs) {
  let cols = defs.map((c) => ({ ...c }));   // อย่าแก้ของเดิม ผู้เรียกใช้ซ้ำได้
  const need = (list) =>
    list.reduce((s, c) => s + (c.flex ? ESCP_MIN_FLEX : c.w), 0) + Math.max(0, list.length - 1);

  // แคบเกินไป → ตัดคอลัมน์ที่ยอมทิ้งได้ออกทีละตัว
  while (need(cols) > W && cols.some((c) => c.drop)) {
    const victim = cols.reduce((a, c) => (c.drop && (!a || c.drop > a.drop) ? c : a), null);
    cols = cols.filter((c) => c !== victim);
  }

  const flex = cols.find((c) => c.flex);
  if (flex) {
    const fixed = cols.reduce((s, c) => s + (c.flex ? 0 : c.w), 0) + Math.max(0, cols.length - 1);
    const avail = W - fixed;
    // เดิมบังคับกว้างขั้นต่ำ 4 ทำให้แถวยาวเกินขอบกระดาษเมื่อที่ไม่พอจริงๆ
    if (avail >= ESCP_MIN_FLEX) flex.w = avail;
    else cols = cols.filter((c) => c !== flex);
  }

  // กันเหนียว: ถ้ายังยาวเกิน ให้หดจากคอลัมน์ขวาสุดเข้ามา (เหลืออย่างน้อย 3 ตัว)
  let over = cols.reduce((s, c) => s + c.w, 0) + Math.max(0, cols.length - 1) - W;
  for (let i = cols.length - 1; i >= 0 && over > 0; i--) {
    const cut = Math.min(over, Math.max(0, cols[i].w - 3));
    cols[i].w -= cut; over -= cut;
  }
  // ยังเกินอีก แปลว่าคอลัมน์เยอะเกินกว่ากระดาษจะรับไหว — ตัดจากขวาทิ้ง
  while (over > 0 && cols.length > 1) {
    const last = cols.pop();
    over -= last.w + 1;
  }

  return {
    cols,
    row(vals) {
      return cols.map((c) => {
        const v = vals[c.key] == null ? "" : String(vals[c.key]);
        return c.a === "r" ? padL(v, c.w) : c.a === "c" ? padC(v, c.w) : padR(v, c.w);
      }).join(" ");
    },
    head() {
      return cols.map((c) => {
        const v = c.h || "";
        return c.a === "r" ? padL(v, c.w) : c.a === "c" ? padC(v, c.w) : padR(v, c.w);
      }).join(" ");
    },
  };
}

// ============================================================
// เอกสาร: เก็บบรรทัด + ขึ้นแผ่นใหม่อัตโนมัติ + พิมพ์หัวซ้ำทุกแผ่น
// ============================================================
function escpDoc(paper, s) {
  escpSetMarkMode(s.escpMarks);
  const cpi = ESCP_CPI[s.escpCpi] ? s.escpCpi : 10;
  const indent = Math.max(0, Math.min(20, Number(s.escpIndent) || 0));
  const W = Math.max(24, Math.min(ESCP_CPI[cpi].max, Math.floor(paper.w * cpi)) - indent);
  const LPP = Math.max(8, Math.round(paper.h * 6));            // บรรทัดต่อแผ่นที่ 6 บรรทัด/นิ้ว
  const topPad = Math.max(0, Math.round(paper.padV * 6));
  const usable = Math.max(4, LPP - topPad * 2);

  const lines = [];
  let onPage = 0;
  let header = null;                                            // ฟังก์ชันพิมพ์หัวซ้ำ

  const api = {
    W, LPP, topPad, usable, cpi, indent,
    setHeader(fn) { header = fn; },
    // จำนวนบรรทัดที่ยังเหลือบนแผ่นนี้
    left() { return usable - onPage; },
    // ดันบรรทัดลงจริง (ใช้ภายใน)
    _push(t) {
      if (onPage >= usable) api.newPage();
      lines.push(t);
      onPage++;
    },
    // กันล้นที่ต้นทาง — ผู้เรียกจะได้ไม่ต้องคอยนับความกว้างเองทุกจุด
    add(text) {
      const t = text == null ? "" : String(text);
      // บรรทัดตัวกว้าง 2 เท่า กินที่เป็นสองเท่า และไม่ควรตัดขึ้นบรรทัดใหม่
      if (t.startsWith(MK_DW)) {
        api._push(MK_DW + escpClip(t.slice(1), Math.floor(W / 2)));
        return api;
      }
      if (escpWidth(t) <= W) { api._push(t); return api; }
      for (const part of escpWrap(t, W)) api._push(part);
      return api;
    },
    blank(n) { for (let i = 0; i < (n || 1); i++) api.add(""); return api; },
    rule(ch) { return api.add((ch || "-").repeat(W)); },
    // จองที่ n บรรทัดติดกัน — ถ้าเหลือไม่พอให้ขึ้นแผ่นใหม่ก่อน (กันหัวข้อขาดจากเนื้อ)
    keep(n) { if (api.left() < n) api.newPage(); return api; },
    newPage() {
      lines.push(MK_FF);
      onPage = 0;
      for (let i = 0; i < topPad; i++) { lines.push(""); }
      if (header) header();
      return api;
    },
    start() { for (let i = 0; i < topPad; i++) lines.push(""); return api; },
    lines,
  };
  return api;
}

// ============================================================
// แตกบรรทัดเป็นคอลัมน์ — ใช้ตอนพิมพ์ทับเอง
// ============================================================
// เครื่องบางรุ่น (เช่น Epson PLQ-20 ที่ตารางไทยเป็นแบบเลื่อนหัวพิมพ์)
// พิมพ์สระ/วรรณยุกต์ลงช่องถัดไปแทนที่จะทับตัวเดิม ตัวหนังสือเลยลอยออกมา
// วิธีแก้: พิมพ์ทีละชั้น แล้วดึงหัวพิมพ์กลับด้วย CR โดยไม่เลื่อนบรรทัด
//   พาส 1 = พยัญชนะกับสระที่กินที่จริง
//   พาส 2 = สระบน-ล่าง วางตรงคอลัมน์ของตัวที่มันเกาะ
//   พาส 3 = วรรณยุกต์ที่ซ้อนอยู่อีกชั้น
function escpColumns(line) {
  const cols = [];
  let bold = false;
  for (const ch of line) {
    if (ch === MK_BOLD_ON) { bold = true; continue; }
    if (ch === MK_BOLD_OFF) { bold = false; continue; }
    const cp = ch.codePointAt(0);
    if (cp < 0x20) continue;
    if (ESCP_NONSPACING.has(cp) && cols.length) {
      cols[cols.length - 1].marks.push(ch);
      continue;
    }
    cols.push({ ch, marks: [], bold });
  }
  return cols;
}

// ============================================================
// แปลงบรรทัดทั้งหมดเป็นไบต์
// ============================================================
function escpSerialize(doc, s) {
  const out = [];
  const put = (...b) => { for (const x of b) out.push(x); };

  put(ESC, 0x40);                                    // ESC @ — คืนค่าเริ่มต้นทั้งหมด
  // ถ้ารู้รหัสตารางไทยของรุ่นนี้แล้ว สั่งยัดลงช่อง 1 แล้วเลือกช่องนั้น
  // (หาได้จากปุ่ม "หาโหมดไทยของเครื่อง" ในหน้าตั้งค่า)
  const thaiCode = Number(s.escpThaiCode);
  if (s.escpThaiCode !== "" && s.escpThaiCode != null && Number.isFinite(thaiCode)) {
    put(ESC, 0x28, 0x74, 0x03, 0x00, 1, thaiCode & 0xff, 0);   // ESC ( t
    put(ESC, 0x74, 1);
  } else if (s.escpTable !== "" && s.escpTable != null) {
    put(ESC, 0x74, Number(s.escpTable) || 0);        // ESC t n — เลือกตารางอักขระ
  }
  put(ESC, 0x78, s.escpQuality === "draft" ? 0 : 1); // ESC x n — 1 = LQ (คมกว่า) / 0 = Draft (เร็วกว่า)
  put(...(ESCP_CPI[doc.cpi] || ESCP_CPI[10]).cmd);   // ความถี่ตัวอักษร
  put(ESC, 0x32);                                    // ESC 2 — ระยะบรรทัด 1/6 นิ้ว
  put(ESC, 0x43, Math.max(1, Math.min(127, doc.LPP)));  // ESC C n — ความยาวแผ่นเป็นบรรทัด

  const crlf = s.escpLineEnd !== "lf";
  const indent = " ".repeat(doc.indent);

  for (const raw of doc.lines) {
    if (raw === MK_FF) { put(FF); continue; }
    let line = escpClean(raw);
    let dw = false;
    if (line.startsWith(MK_DW)) { dw = true; line = line.slice(1); }
    if (s.escpMarks === "overprint") {
      escpPutOverprint(put, line, dw, indent);
    } else {
      if (dw) put(SO);                               // SO — ตัวกว้าง 2 เท่า (เครื่องยกเลิกเองเมื่อจบบรรทัด)
      if (indent) for (const ch of indent) put(ch.codePointAt(0));
      for (const ch of line) {
        if (ch === MK_BOLD_ON) { put(ESC, 0x45); continue; }   // ESC E
        if (ch === MK_BOLD_OFF) { put(ESC, 0x46); continue; }  // ESC F
        put(tisByte(ch.codePointAt(0)));
      }
      put(ESC, 0x46);                                // กัน bold ค้างข้ามบรรทัด
    }
    // ไม่ต้องส่ง DC4 — SO ถูกยกเลิกด้วย LF อยู่แล้ว
    // ถ้าส่งก่อนจบบรรทัด เครื่องบางรุ่นจะยกเลิกทั้งบรรทัดตั้งแต่ยังไม่ทันพิมพ์
    if (crlf) put(CR);
    put(LF);
  }

  put(FF);                                           // ดันกระดาษไปหัวแผ่นถัดไป
  put(ESC, 0x40);                                    // คืนค่าเครื่องให้ซอฟต์แวร์อื่นใช้ต่อ
  return out;
}

// พิมพ์บรรทัดเดียวหลายพาส คั่นด้วย CR (ดึงหัวพิมพ์กลับโดยไม่เลื่อนกระดาษ)
// ต้องปิด Auto Line Feed ที่ตัวเครื่อง ไม่งั้น CR จะกลายเป็นขึ้นบรรทัดใหม่
function escpPutOverprint(put, line, dw, indent) {
  const cols = escpColumns(line);
  let passes = 1;
  for (const c of cols) passes = Math.max(passes, 1 + c.marks.length);

  for (let p = 0; p < passes; p++) {
    if (p > 0) put(CR);
    if (dw) put(SO);                       // CR ยกเลิก SO ต้องสั่งใหม่ทุกพาส
    if (indent) for (const ch of indent) put(ch.codePointAt(0));

    // หาคอลัมน์สุดท้ายที่มีอะไรจะพิมพ์ในพาสนี้ — ที่เหลือเป็นช่องว่าง ไม่ต้องส่ง
    let last = -1;
    for (let i = 0; i < cols.length; i++) {
      if (p === 0 ? cols[i].ch : cols[i].marks[p - 1]) last = i;
    }
    let bold = false;
    for (let i = 0; i <= last; i++) {
      const c = cols[i];
      const g = p === 0 ? c.ch : c.marks[p - 1];
      if (c.bold !== bold) { put(ESC, c.bold ? 0x45 : 0x46); bold = c.bold; }
      put(tisByte((g || " ").codePointAt(0)));
    }
    if (bold) put(ESC, 0x46);
  }
  put(ESC, 0x46);
}

function escpToBase64(bytes) {
  let bin = "";
  for (let i = 0; i < bytes.length; i += 4096) {
    bin += String.fromCharCode.apply(null, bytes.slice(i, i + 4096).map((b) => b & 0xff));
  }
  return btoa(bin);
}

// ============================================================
// ส่วนหัว/ท้ายที่ใช้ร่วมกันทุกรายงาน
// ============================================================
function escpHead(doc, title, refLines) {
  const W = doc.W;
  const refs = (refLines || []).slice();
  doc.add(padR(B("JS CONTRACT SYSTEM"), W - escpWidth(refs[0] || "")) + (refs[0] || ""));
  // ชื่อรายงานตัวกว้าง 2 เท่า — กว้างเป็นสองเท่าจึงเหลือที่ครึ่งเดียว
  doc.add(MK_DW + B(escpClip(title, Math.floor(W / 2))));
  if (refs[1]) doc.add(padL(refs[1], W));
  doc.rule("=");
}
function escpSign(doc, roles) {
  const W = doc.W;
  const each = Math.floor(W / roles.length);
  doc.keep(4).blank(1);
  doc.add(roles.map(() => padC(".".repeat(Math.min(26, each - 4)), each)).join(""));
  doc.add(roles.map((r) => padC("( " + r + " )", each)).join(""));
  doc.add(roles.map(() => padC("วันที่ ...../...../.....", each)).join(""));
}
function escpFoot(doc, left) {
  const W = doc.W;
  doc.rule("-");
  const right = "พิมพ์ " + eNow();
  doc.add(padR(escpClip(left, W - escpWidth(right) - 1), W - escpWidth(right)) + right);
}

// ============================================================
// รายงานที่ 1 — ใบสรุปการเบิกจ่ายรายหลัง
// ============================================================
function escpHouseSlip(doc, { project, contract, house, unitLbl }) {
  const W = doc.W;
  const price = house.price || 0, paid = house.paid_total || 0, remain = price - paid;
  const st = window.dmStatus ? window.dmStatus(price, paid) : { text: "" };
  const pays = (house.payments || []).slice()
    .sort((a, b) => (a.date > b.date ? 1 : a.date < b.date ? -1 : 0));
  const sum = pays.reduce((s, p) => s + (p.amount || 0), 0);

  const T = escpCols(W, [
    { key: "no", w: 4, a: "r", h: "งวด" },
    { key: "date", w: 10, a: "l", h: "วันที่" },
    { key: "inv", w: 16, a: "l", h: "เลขที่ใบกำกับ" },
    { key: "amt", w: 14, a: "r", h: "จำนวนเงิน" },
    { key: "note", w: 0, a: "l", h: "หมายเหตุ", flex: true, drop: 1 },
  ]);

  const head = () => {
    escpHead(doc, `ใบสรุปการเบิกจ่าย${unitLbl.singular}`, [
      `${unitLbl.code} ${house.house_code || "-"}`,
      `สัญญา ${contract.contract_number || "-"}`,
    ]);
  };
  doc.setHeader(() => { head(); doc.add(B(T.head())); doc.rule("-"); });

  head();
  doc.add(padR("โครงการ", 12) + escpClip(project.name || "-", W - 12));
  doc.add(padR("สัญญาเลขที่", 12) + padR(contract.contract_number || "-", 22) +
          padR("วันที่สัญญา", 12) + eDate(contract.contract_date));
  doc.add(padR(unitLbl.code, 12) + padR(house.house_code || "-", 22) +
          (house.notes ? padR("หมายเหตุ", 12) + escpClip(house.notes, Math.max(0, W - 46)) : ""));
  doc.rule("-");
  // แบ่งช่องตามความกว้างจริง ไม่ใช่ต่อสตริงกันดื้อๆ — สถานะยาวๆ จะได้ไม่ดันจนล้นขอบ
  const moneyW = Math.floor((W - 12) / 3);
  doc.add(
    padR(B("ราคา") + padL(eFmt(price), moneyW - 5), moneyW) + "  " +
    padR(B("เบิกแล้ว") + padL(eFmt(paid), moneyW - 9), moneyW) + "  " +
    padR(B("คงเหลือ") + padL(eFmt(Math.abs(remain)), moneyW - 8), moneyW) + "  " +
    B(escpClip(st.text, Math.max(0, W - moneyW * 3 - 6)))
  );
  doc.rule("=");
  doc.add(B(T.head()));
  doc.rule("-");

  if (!pays.length) {
    doc.add(padC("— ยังไม่มีรายการเบิกจ่าย —", W));
  } else {
    pays.forEach((p, i) => {
      doc.add(T.row({
        no: String(i + 1), date: eDate(p.date), inv: p.inv || "-",
        amt: eFmt(p.amount), note: p.notes || "",
      }));
    });
  }
  doc.rule("-");
  const baht = window.bahtText ? "(" + window.bahtText(sum) + ")" : "";
  const totalRight = padL(eFmt(sum), 14);
  doc.add(padR(B(`รวม ${pays.length} งวด `) + baht, W - 14) + B(totalRight));
  doc.rule("=");
  escpSign(doc, ["ผู้จัดทำ", "ผู้อนุมัติ"]);
  escpFoot(doc, `${project.name || ""} / ${contract.contract_number || ""}`);
}

// ============================================================
// รายงานที่ 2 — สรุปรายสัญญา (ทุกหลังในสัญญา)
// ============================================================
function escpContractSheet(doc, { project, contract, unitLbl }) {
  const W = doc.W;
  const houses = (contract.houses || []).slice()
    .sort((a, b) => (a.house_code || "").localeCompare(b.house_code || "", undefined, { numeric: true, sensitivity: "base" }));

  const T = escpCols(W, [
    { key: "code", w: 12, a: "l", h: unitLbl.code },
    { key: "price", w: 14, a: "r", h: "ราคา" },
    { key: "paid", w: 14, a: "r", h: "เบิกแล้ว" },
    { key: "remain", w: 14, a: "r", h: "คงเหลือ" },
    { key: "st", w: 0, a: "l", h: "สถานะ", flex: true, drop: 1 },
  ]);

  const head = () => {
    escpHead(doc, `รายงานสัญญา ${contract.contract_number || "-"}`, [
      `โครงการ ${escpClip(project.name || "-", 30)}`,
      `วันที่สัญญา ${eDate(contract.contract_date)}`,
    ]);
  };
  doc.setHeader(() => { head(); doc.add(B(T.head())); doc.rule("-"); });

  head();
  doc.add(B(T.head()));
  doc.rule("-");

  let totPrice = 0, totPaid = 0, totPays = 0;
  if (!houses.length) {
    doc.add(padC(`— ยังไม่มี${unitLbl.singular}ในสัญญานี้ —`, W));
  }
  for (const h of houses) {
    const price = h.price || 0, paid = h.paid_total || 0;
    const pays = (h.payments || []).slice()
      .sort((a, b) => (a.date > b.date ? 1 : a.date < b.date ? -1 : 0));
    totPrice += price; totPaid += paid; totPays += pays.length;
    const st = window.dmStatus ? window.dmStatus(price, paid) : { text: "" };

    doc.keep(2 + Math.min(pays.length, 3));   // อย่าให้ชื่อหลังขาดจากงวดของมัน
    doc.add(T.row({
      code: h.house_code || "-", price: eFmt(price), paid: eFmt(paid),
      remain: eFmt(Math.abs(price - paid)), st: st.text,
    }));
    for (let i = 0; i < pays.length; i++) {
      const p = pays[i];
      doc.add("   " + escpClip(
        `งวด ${i + 1}  ${eDate(p.date)}  ${p.inv || "-"}  ${eFmt(p.amount)}${p.notes ? "  " + p.notes : ""}`,
        W - 3));
    }
  }
  doc.rule("-");
  doc.add(T.row({
    code: B("รวม"), price: B(eFmt(totPrice)), paid: B(eFmt(totPaid)),
    remain: B(eFmt(Math.abs(totPrice - totPaid))), st: `${houses.length} ${unitLbl.counter} / ${totPays} งวด`,
  }));
  doc.rule("=");
  if (window.bahtText) doc.add(padL("ยอดเบิกแล้วเป็นตัวอักษร (" + window.bahtText(totPaid) + ")", W));
  escpSign(doc, ["ผู้จัดทำ", "ผู้ตรวจสอบ", "ผู้อนุมัติ"]);
  escpFoot(doc, `${project.name || ""} / ${contract.contract_number || ""}`);
}

// ============================================================
// รายงานที่ 3 — ประวัติการเบิกจ่าย
// ============================================================
function escpLedger(doc, { rows, scopeLabel }) {
  const W = doc.W;
  const T = escpCols(W, [
    { key: "date", w: 10, a: "l", h: "วันที่" },
    { key: "unit", w: 14, a: "l", h: "หน่วย" },
    { key: "no", w: 4, a: "r", h: "งวด" },
    { key: "inv", w: 15, a: "l", h: "เลขที่ใบกำกับ" },
    { key: "amt", w: 14, a: "r", h: "จำนวนเงิน" },
    { key: "proj", w: 0, a: "l", h: "โครงการ / สัญญา", flex: true, drop: 1 },
  ]);

  const head = () => escpHead(doc, "ประวัติการเบิกจ่าย", [escpClip(scopeLabel || "ทั้งระบบ", 34), ""]);
  doc.setHeader(() => { head(); doc.add(B(T.head())); doc.rule("-"); });

  head();
  doc.add(B(T.head()));
  doc.rule("-");

  let total = 0;
  if (!rows.length) doc.add(padC("— ยังไม่มีรายการเบิกจ่าย —", W));
  for (const r of rows) {
    total += r.amount || 0;
    doc.add(T.row({
      date: eDate(r.date), unit: r.unit || "-", no: String(r.no || ""),
      inv: r.inv || "-", amt: eFmt(r.amount),
      proj: `${r.project || ""} / ${r.contract || ""}`,
    }));
  }
  doc.rule("-");
  doc.add(padR(B(`รวม ${rows.length} รายการ`), W - 14) + B(padL(eFmt(total), 14)));
  doc.rule("=");
  if (window.bahtText) doc.add(padL("(" + window.bahtText(total) + ")", W));
  escpSign(doc, ["ผู้จัดทำ", "ผู้ตรวจสอบ"]);
  escpFoot(doc, scopeLabel || "ทั้งระบบ");
}

// ============================================================
// หน้าทดสอบ — ใช้หาว่าตารางอักขระไทยของเครื่องนี้อยู่ที่ ESC t เท่าไร
// ============================================================
const ESCP_SAMPLE = "ผู้รับเหมาเบิกงวดที่ ๙ ปั๊มน้ำ ญี่ปุ่น ฿1,234.50";

function escpTestPage(doc, s) {
  const W = doc.W;
  escpHead(doc, "ทดสอบพิมพ์ ESC/P", [`${W} ตัวอักษร/บรรทัด`, `${doc.cpi} CPI`]);

  doc.add(`กระดาษ ${doc.LPP} บรรทัด/แผ่น · เว้นบน-ล่างข้างละ ${doc.topPad} บรรทัด · เยื้องซ้าย ${doc.indent} ตัว`);
  doc.add(`คุณภาพ ${s.escpQuality === "draft" ? "Draft (เร็ว)" : "LQ (คม)"} · ` +
          `ตารางอักขระ ${s.escpTable === "" ? "ไม่ส่งคำสั่ง" : "ESC t " + s.escpTable} · ` +
          `ท้ายบรรทัด ${s.escpLineEnd === "lf" ? "LF" : "CR+LF"}`);
  doc.rule("-");

  // ไม้บรรทัดนับคอลัมน์ — ตัวเลขต้องตรงกับความกว้างจริงของกระดาษ
  doc.add("ไม้บรรทัด: ตัวสุดท้ายต้องอยู่ริมขวาพอดี ไม่ตกขอบและไม่ขาด");
  let tens = "", ones = "";
  for (let i = 1; i <= W; i++) {
    tens += (i % 10 === 0) ? String(Math.floor(i / 10) % 10) : " ";
    ones += String(i % 10);
  }
  doc.add(tens);
  doc.add(ones);
  doc.rule("-");

  doc.add(B("ภาษาไทย — บรรทัดไหนอ่านออก ให้ไปตั้งค่าตารางอักขระเป็นค่านั้น"));
  doc.add("ไม่ส่งคำสั่ง : " + ESCP_SAMPLE);
  doc.rule("-");
  doc.add(B("อังกฤษ/ตัวเลข") + "  ABCDEFG abcdefg 0123456789 -+*/=().,%");
  doc.add(B("ตัวหนา") + " ปกติ " + B("หนา") + " ปกติ " + B("หนา"));
  doc.add("ตัวกว้าง 2 เท่า:");
  doc.add(MK_DW + "กขคง ABC 123");
  doc.rule("-");
  doc.add("ตรวจ: สระบน-ล่างและวรรณยุกต์ครบ · คอลัมน์ตรงแนว · ไม่มีบรรทัดว่างแทรก");
  doc.add("ถ้ามีบรรทัดว่างแทรกทุกบรรทัด = ปิด Auto Line Feed ที่เครื่อง หรือเปลี่ยนท้ายบรรทัดเป็น LF");
  escpFoot(doc, "หน้าทดสอบ ESC/P — ไม่ใช่เอกสารจริง");
}

// เอกสารแยกอีกชุด: พิมพ์ตัวอย่างไทยซ้ำโดยสลับ ESC t ทีละค่า
// ต้องประกอบไบต์เอง เพราะคำสั่งเปลี่ยนตารางต้องแทรกกลางเอกสาร
function escpTableProbeBytes(paper, s) {
  const doc = escpDoc(paper, s);
  doc.start();
  escpHead(doc, "หาตารางอักขระไทย", ["ESC t 0 ถึง 3", ""]);
  doc.add("บรรทัดไหนอ่านออกเป็นภาษาไทย ให้ไปเลือกค่านั้นในหน้าตั้งค่า");
  doc.rule("-");
  const head = doc.lines.slice();

  const out = [];
  const put = (...b) => { for (const x of b) out.push(x); };
  put(ESC, 0x40);
  put(ESC, 0x78, s.escpQuality === "draft" ? 0 : 1);
  put(...(ESCP_CPI[doc.cpi] || ESCP_CPI[10]).cmd);
  put(ESC, 0x32);
  put(ESC, 0x43, Math.max(1, Math.min(127, doc.LPP)));

  const crlf = s.escpLineEnd !== "lf";
  const indent = " ".repeat(doc.indent);
  const emit = (line, table) => {
    if (table !== null) put(ESC, 0x74, table);
    let t = escpClean(line);
    let dw = false;
    if (t.startsWith(MK_DW)) { dw = true; t = t.slice(1); }
    if (s.escpMarks === "overprint") {
      escpPutOverprint(put, t, dw, indent);
    } else {
      if (dw) put(SO);                                 // SO ยกเลิกเองตอน LF
      for (const ch of indent) put(ch.codePointAt(0));
      for (const ch of t) {
        if (ch === MK_BOLD_ON) { put(ESC, 0x45); continue; }
        if (ch === MK_BOLD_OFF) { put(ESC, 0x46); continue; }
        put(tisByte(ch.codePointAt(0)));
      }
      put(ESC, 0x46);
    }
    if (crlf) put(CR);
    put(LF);
  };

  for (const l of head) emit(l, null);
  emit(padR("ไม่ส่งคำสั่ง", 16) + ESCP_SAMPLE, null);
  for (let t = 0; t <= 3; t++) emit(padR("ESC t " + t, 16) + ESCP_SAMPLE, t);

  // คืนตารางก่อนพิมพ์ท้ายหน้า — เดิมใช้ Number("") || 0 ทำให้ค่า "ไม่ส่งคำสั่ง"
  // กลายเป็นตาราง 0 (ตัวเอียง) ท้ายหน้าเลยออกมาเป็นขยะ
  // ถ้าผู้ใช้ตั้งเป็น "ไม่ส่งคำสั่ง" ให้คงตารางสุดท้ายที่อ่านออกไว้ (3) ไม่ต้องสั่งอะไร
  put(ESC, 0x74, s.escpTable === "" || s.escpTable == null ? 3 : (Number(s.escpTable) || 0));
  emit("-".repeat(doc.W), null);
  emit("เลือกค่าที่อ่านออกไปตั้งใน ตั้งค่า > Dot Matrix > ตารางอักขระไทย", null);

  // ---- ทดสอบแนวคอลัมน์ ----
  // ทุกบรรทัดมีตัวอักษรที่ "กินที่" เท่ากันหมด ขีดปิดท้ายจึงต้องตรงเป็นแนวเดียว
  // ถ้าบรรทัดไหนเลื่อนขวา แปลว่าเครื่องเลื่อนหัวพิมพ์ให้สระ/วรรณยุกต์ด้วย
  emit("-".repeat(doc.W), null);
  emit(B("ทดสอบแนวคอลัมน์") + " - ขีด | ของทุกบรรทัดต้องตรงกันเป็นแนวเดียว", null);
  // ทุกตัวอย่างมีพยัญชนะฐาน 10 ตัวเท่ากัน ต่างกันแค่ชนิดของสระ/วรรณยุกต์ที่เกาะอยู่
  // ป้าย A-F เป็นอังกฤษล้วน กว้างแน่นอน ตัวแปรจึงเหลืออย่างเดียวคือสระ/วรรณยุกต์
  const rep = (u, n) => new Array(n + 1).join(u);
  const COLTEST = [
    ["A", rep("ก", 10), "ไม่มีสระเลย (เส้นฐาน)"],
    ["B", rep("มิ", 10), "สระบน"],
    ["C", rep("ผุ", 10), "สระล่าง"],
    ["D", rep("ม่", 10), "วรรณยุกต์บนพยัญชนะ"],
    ["E", rep("มี่", 10), "วรรณยุกต์ซ้อนบนสระ"],
    ["F", "ABCDEFGHIJ", "อังกฤษ"],
  ];
  for (const c of COLTEST) emit(padR(c[0], 3) + padR(c[1], 26) + "| " + c[2], null);
  emit("ทุกบรรทัดตั้งใจให้กว้างเท่ากัน - ดูว่าขีดของบรรทัดไหนเลื่อนออกไปทางขวา", null);
  emit("  ตรงกันหมด        = ใช้ได้เลย ไม่ต้องแก้อะไร", null);
  emit("  B C D E เลื่อน   = เลือก 'ให้ระบบพิมพ์ทับเอง' (แก้ทั้งคอลัมน์และสระลอย)", null);
  emit("  เลื่อนเฉพาะ E    = เลือก 'เฉพาะวรรณยุกต์ซ้อนสระ'", null);
  emit("พิมพ์ " + eNow(), null);
  put(FF);
  put(ESC, 0x40);
  return out;
}

// ============================================================
// หน้าไล่หา "โหมดไทย" ในตัวเครื่อง ด้วยคำสั่ง ESC ( t
// ============================================================
// ESC t เลือกได้แค่ช่องตาราง 0-3 ที่เครื่องตั้งไว้แล้ว
// ส่วน ESC ( t สั่ง "ยัดรหัสตารางที่ต้องการลงในช่อง" ได้โดยตรง
//   ESC ( t  nL nH  d1 d2 d3   ->  1B 28 74 03 00 d1 d2 00
//   d1 = ช่องตาราง (ใช้ 1) · d2 = หมายเลขตารางอักขระ
// เครื่องไทยมักมีรหัสไทยหลายแบบ ต่างกันตรงวิธีวางสระ/วรรณยุกต์
// บางแบบพิมพ์ทับให้เอง (ที่เราต้องการ) บางแบบเลื่อนไปช่องถัดไป
// ไม่มีทางรู้ว่ารุ่นไหนอยู่เลขอะไรนอกจากลองพิมพ์ดู
// ไล่ทุกรหัสตารางที่เครื่องอาจมี — ไม่มีทางรู้ล่วงหน้าว่ารุ่นไหนวางไทยไว้เลขอะไร
const ESCP_THAI_FROM = 0, ESCP_THAI_TO = 127;

// ตัวอย่างสั้นๆ ที่ดูออกทันทีว่าเครื่อง "ประกอบ" สระให้หรือไม่
//   ประกอบให้   -> ผู้ที่  กว้าง 2 ช่อง
//   ไม่ประกอบ   -> ผ ู ้ ท ี ่  กว้าง 6 ช่อง (สังเกตง่ายมาก)
const ESCP_THAI_MINI = "ผู้ที่";

function escpThaiModeProbeBytes(paper, s) {
  const doc = escpDoc(paper, s);
  const W = doc.W;
  const out = [];
  const put = (...b) => { for (const x of b) out.push(x); };
  const crlf = s.escpLineEnd !== "lf";
  const indent = " ".repeat(doc.indent);

  put(ESC, 0x40);
  put(ESC, 0x78, s.escpQuality === "draft" ? 0 : 1);
  put(...(ESCP_CPI[doc.cpi] || ESCP_CPI[10]).cmd);
  put(ESC, 0x32);
  put(ESC, 0x43, Math.max(1, Math.min(127, doc.LPP)));

  // นับบรรทัดเอง เพราะบรรทัดพวกนี้ประกอบไบต์ตรงๆ ไม่ได้ผ่านตัวจัดหน้าของ doc
  let onPage = 0;
  const emitOne = (text) => {
    if (onPage >= doc.usable) {                 // เต็มแผ่น -> ขึ้นแผ่นใหม่
      put(FF);
      onPage = 0;
      for (let i = 0; i < doc.topPad; i++) { put(CR); put(LF); onPage++; }
    }
    for (const ch of indent) put(ch.codePointAt(0));
    for (const ch of text) {
      if (ch === MK_BOLD_ON) { put(ESC, 0x45); continue; }
      if (ch === MK_BOLD_OFF) { put(ESC, 0x46); continue; }
      put(tisByte(ch.codePointAt(0)));
    }
    put(ESC, 0x46);
    if (crlf) put(CR);
    put(LF);
    onPage++;
  };
  // หน้านี้พิมพ์ตอนยังไม่รู้ว่าเครื่องนับสระเป็นช่องไหม จึงตัดบรรทัดแบบเผื่อไว้ก่อน
  const emitRaw = (text) => {
    for (const part of escpWrapRaw(escpClean(text), W)) emitOne(part);
  };

  for (let i = 0; i < doc.topPad; i++) { put(CR); put(LF); onPage++; }
  emitRaw(B("JS CONTRACT SYSTEM") + "   หาโหมดไทยของเครื่อง (ESC ( t)");
  emitRaw("=".repeat(W));
  emitRaw("ไล่รหัสตาราง " + ESCP_THAI_FROM + " ถึง " + ESCP_THAI_TO + " แล้วพิมพ์คำว่า " + ESCP_THAI_MINI + " ด้วยรหัสนั้น");
  emitRaw(B("มองหารหัสที่ได้ตัวสั้นและอ่านออกเป็น ") + ESCP_THAI_MINI + B(" (สระอยู่บน-ล่างตัวอักษร)"));
  emitRaw("รหัสที่ผิดจะได้ตัวยาวกว่า สระแยกออกมาเป็นตัวๆ หรือเป็นภาษาอื่นไปเลย");
  emitRaw("-".repeat(W));

  // จัดเป็นตาราง: ช่องละ 12 ตัว เรียงเท่าที่กระดาษรับได้
  const CELL = 12;
  const perLine = Math.max(2, Math.floor((W + 1) / (CELL + 1)));
  for (let code = ESCP_THAI_FROM; code <= ESCP_THAI_TO; code += perLine) {
    if (onPage >= doc.usable) {
      put(FF);
      onPage = 0;
      for (let i = 0; i < doc.topPad; i++) { put(CR); put(LF); onPage++; }
      for (const ch of indent) put(ch.codePointAt(0));
      for (const ch of escpClean("(ต่อ) " + ESCP_THAI_MINI + " ในแต่ละรหัสตาราง")) put(tisByte(ch.codePointAt(0)));
      if (crlf) put(CR);
      put(LF); onPage++;
    }
    for (const ch of indent) put(ch.codePointAt(0));
    for (let k = 0; k < perLine && code + k <= ESCP_THAI_TO; k++) {
      const c = code + k;
      if (k > 0) put(0x20);
      put(ESC, 0x28, 0x74, 0x03, 0x00, 1, c, 0);      // ยัดรหัส c ลงช่องตาราง 1
      put(ESC, 0x74, 1);                              // เลือกช่อง 1
      // เลขรหัสต้องอ่านออกเสมอ เลยพิมพ์ด้วยตารางที่ใช้อยู่เดิมก่อนแล้วค่อยสลับ
      const tag = String(c).padStart(3, " ") + " ";
      for (const ch of tag) put(ch.codePointAt(0));
      for (const ch of ESCP_THAI_MINI) put(tisByte(ch.codePointAt(0)));
      // เว้นให้ครบความกว้างช่อง เผื่อรหัสที่ไม่ประกอบซึ่งกินที่มากกว่า
      for (let sp = 0; sp < CELL - 4 - ESCP_THAI_MINI.length; sp++) put(0x20);
    }
    if (crlf) put(CR);
    put(LF);
    onPage++;
  }

  // คืนค่าเครื่องก่อนพิมพ์ท้ายหน้า ไม่งั้นอ่านไม่ออก
  put(ESC, 0x40);
  put(ESC, 0x78, s.escpQuality === "draft" ? 0 : 1);
  put(...(ESCP_CPI[doc.cpi] || ESCP_CPI[10]).cmd);
  put(ESC, 0x32);
  if (s.escpTable !== "" && s.escpTable != null) put(ESC, 0x74, Number(s.escpTable) || 0);

  emitRaw("-".repeat(W));
  emitRaw(B("เจอรหัสที่ถูก") + " = กรอกเลขนั้นในช่อง 'รหัสตารางไทยของเครื่อง' แล้วพิมพ์ได้เลย");
  emitRaw("ไม่มีรหัสไหนถูกเลย = รุ่นนี้ทำได้แค่นี้ ต้องกลับไปใช้โหมดกราฟิก");
  emitRaw("พิมพ์ " + eNow());
  put(FF);
  put(ESC, 0x40);
  return out;
}

// ============================================================
// ส่งเข้าเครื่อง
// ============================================================
function escpAvailable() {
  return !!(window.Desktop && typeof window.Desktop.printRaw === "function"
            && (window.Desktop.isWindows === true
                || (window.Desktop.isWindows == null && /Windows/i.test(navigator.userAgent || ""))));
}
// อยู่ในแอปแล้วแต่ยังไม่มี printRaw = แอปเวอร์ชันเก่า ต้องอัปเดตก่อน
function escpNeedsAppUpdate() {
  return !!(window.Desktop && typeof window.Desktop.printRaw !== "function");
}

async function escpSend(bytes, title) {
  if (!escpAvailable()) {
    return { ok: false, error: escpNeedsAppUpdate()
      ? "แอปเวอร์ชันนี้ยังพิมพ์ ESC/P ไม่ได้ กรุณาอัปเดตแอปก่อน"
      : "การพิมพ์ ESC/P ใช้ได้เฉพาะในแอปบน Windows" };
  }
  const s = window.getDmSettings();
  const copies = Math.max(1, Number(s.copies) || 1);
  let last = { ok: true };
  for (let i = 0; i < copies; i++) {
    last = await window.Desktop.printRaw({
      base64: escpToBase64(bytes),
      deviceName: s.printerName || undefined,
      docName: title || "JS Contract Dot Matrix",
    });
    if (!last || !last.ok) return last || { ok: false, error: "ไม่ทราบสาเหตุ" };
  }
  return last;
}

// สร้างเอกสาร → ไบต์ → ส่งเข้าเครื่อง
async function escpPrint({ reportKey, paperKey, title, build }) {
  const s = window.getDmSettings();
  const paper = paperKey ? window.DM_PAPERS[paperKey] : window.dmPaperFor(reportKey);
  const doc = escpDoc(paper, s);
  doc.start();
  build(doc, s);
  return escpSend(escpSerialize(doc, s), title);
}

Object.assign(window, {
  ESCP_CPI, ESCP_CPI_LIST, ESCP_TABLES, ESCP_SAMPLE, ESCP_MARK_MODES,
  escpAvailable, escpNeedsAppUpdate, escpPrint, escpSend, escpSetMarkMode, escpColumns,
  escpDoc, escpSerialize, escpToBase64, escpTableProbeBytes,
  escpThaiModeProbeBytes, ESCP_THAI_FROM, ESCP_THAI_TO, ESCP_THAI_MINI,
  escpHouseSlip, escpContractSheet, escpLedger, escpTestPage,
  escpWidth, escpClip, escpClean, tisByte, escpWidthRaw, escpWrapRaw,
});
