147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
|
|
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(),
|
|||
|
|
);
|
|||
|
|
}
|