import type { Rect } from "@/db/schema"; import type { Point, Size } from "@/utils/coordinates"; // ตัวช่วยแปลงพิกัดสำหรับ export PDF (F8/C1 — PLAN-FEAT.md §3, §5) // พิกัดของเราเป็น % ของ "หน้าที่แสดงบนจอ" (pdfjs คิด /Rotate แล้ว, จุด 0,0 มุมบนซ้าย แกน y ลง) // ฝั่ง pdf-lib วาดในพิกัด PDF ของหน้า **ที่ยังไม่ถูกหมุน** (จุด 0,0 มุมล่างซ้าย แกน y ขึ้น) // → ทุกจุดต้องแปลงตามมุม /Rotate ก่อนวาดเสมอ export type Rot = 0 | 90 | 180 | 270; /** ขนาดหน้า "บนจอ" หลังคิด rotation — 90/270 สลับกว้าง-สูง */ export function displaySize(pageSize: Size, rotation: Rot): Size { return rotation % 180 === 0 ? pageSize : { width: pageSize.height, height: pageSize.width }; } /** * จุดบนจอ (px, มุมบนซ้าย y ลง, ขอบเขต displaySize) → พิกัด PDF (pt, มุมล่างซ้าย y ขึ้น) * /Rotate ของ PDF หมายถึง "หมุน content ตามเข็มนาฬิกาตอนแสดง" (ตามพฤติกรรม pdfjs) * อนุพันธ์ inverse mapping ของการหมุน CW แต่ละมุม */ export function displayToPdfPoint( p: Point, pageSize: Size, rotation: Rot, ): Point { switch (rotation) { case 90: return { x: p.y, y: p.x }; case 180: return { x: pageSize.width - p.x, y: p.y }; case 270: return { x: pageSize.width - p.y, y: pageSize.height - p.x }; default: return { x: p.x, y: pageSize.height - p.y }; } } /** * rect บนจอ (แกนขนานกับจอ) → rect ในพิกัด PDF — หมุน 90/270 แล้วยังแกนขนาน * (เพราะเป็นมุมฉากพอดี) แปลงสองมุมแล้ว normalize */ export function displayRectToPdf( rect: Rect, pageSize: Size, rotation: Rot, ): Rect { const a = displayToPdfPoint({ x: rect.x, y: rect.y }, pageSize, rotation); const b = displayToPdfPoint( { x: rect.x + rect.width, y: rect.y + rect.height }, pageSize, rotation, ); return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y), }; } /** แปลงสี hex (#rrggbb) → สัดส่วน 0–1 สำหรับ rgb() ของ pdf-lib — ผิดรูปแบบใช้ดำ */ export function hexToRgb01(hex: string): { r: number; g: number; b: number } { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); const v = m ? m[1] : "000000"; return { r: parseInt(v.slice(0, 2), 16) / 255, g: parseInt(v.slice(2, 4), 16) / 255, b: parseInt(v.slice(4, 6), 16) / 255, }; } /** ตัดบรรทัดแบบตามความกว้างจริงของฟอนต์ (ภาษาไทยไม่มีช่องว่าง → เดินทีละตัวอักษร) */ export function wrapTextByWidth( text: string, maxWidth: number, widthOf: (s: string) => number, ): string[] { const lines: string[] = []; for (const raw of text.split(/\r?\n/)) { let cur = ""; for (const ch of raw) { if (cur && widthOf(cur + ch) > maxWidth) { lines.push(cur); cur = ch; } else { cur += ch; } } lines.push(cur); } return lines; } /** ช่วงข้อความที่มีสไตล์เดียวกัน (ตัวหนา/ปกติ) — ใช้ตอน export TEXTBOX แบบ rich text */ export type StyledRun = { text: string; bold: boolean }; /** * ตัดบรรทัดข้อความที่มีสไตล์ผสม (ตัวหนาเฉพาะส่วน) ตามความกว้าง — แตกเป็นตัวอักษร * (แต่ละตัวใช้ฟอนต์ตามสไตล์ของมันเองตอนวัดความกว้าง) แล้วม้วนกลับเป็น run ต่อบรรทัด * พฤติกรรมการตัดคำเดียวกับ wrapTextByWidth (ตัดทีละตัวอักษร — ภาษาไทยไม่มีช่องว่างคำ) */ export function wrapStyledText( runs: StyledRun[], maxWidth: number, widthOf: (s: string, bold: boolean) => number, ): StyledRun[][] { const chars: { ch: string; bold: boolean }[] = []; for (const r of runs) { for (const ch of Array.from(r.text)) chars.push({ ch, bold: r.bold }); } const lines: { ch: string; bold: boolean }[][] = [[]]; let w = 0; for (const c of chars) { if (c.ch === "\n") { lines.push([]); w = 0; continue; } const cw = widthOf(c.ch, c.bold); if (w > 0 && w + cw > maxWidth) { lines.push([]); w = 0; } lines[lines.length - 1].push(c); w += cw; } return lines.map((line) => { const out: StyledRun[] = []; for (const c of line) { const last = out[out.length - 1]; if (last && last.bold === c.bold) last.text += c.ch; else out.push({ text: c.ch, bold: c.bold }); } return out; }); } /** ตัดชื่อไฟล์แบบ RFC 5987 สำหรับ Content-Disposition (ชื่อไฟล์ภาษาไทย) */ export function encodeRFC5987(s: string): string { return encodeURIComponent(s).replace( /['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), ); }