Add application codebase: PDF annotation for e-Court

Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-10 14:29:40 +07:00
parent 6fd0c3174f
commit eddcec28c7
60 changed files with 15055 additions and 0 deletions

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { AnnotationDTO, AnnotationData, AnnotationType } from "@/db/schema";
import {
DRAW_PENS,
STAMP_PRESETS,
annotationNote,
annotationPreviewText,
annotationTypeLabel,
effectiveDrawOpacity,
} from "@/utils/annotation-display";
const dto = (type: AnnotationType, data: AnnotationData): AnnotationDTO => ({
id: "00000000-0000-0000-0000-000000000001",
fileId: "00000000-0000-0000-0000-000000000002",
pageIndex: 0,
userId: "user-1",
userName: "ทดสอบ",
type,
data,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
resolved: false,
resolvedAt: null,
});
describe("annotationTypeLabel", () => {
it("HIGHLIGHT ตาม style + fallback เมื่อไม่ระบุ style", () => {
expect(
annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [], style: "underline" })),
).toBe("เส้นใต้");
expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์");
});
it("ครบทุกชนิดที่เหลือ", () => {
expect(annotationTypeLabel(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [] }))).toBe("เส้นวาด");
expect(annotationTypeLabel(dto("COMMENT", { position: { x: 0, y: 0 }, message: "m" }))).toBe("คอมเมนต์");
expect(
annotationTypeLabel(
dto("SHAPE", { shape: "ellipse", rect: { x: 0, y: 0, width: 1, height: 1 }, strokeColor: "#000", strokeWidth: 1 }),
),
).toBe("รูปทรง (วงรี)");
expect(annotationTypeLabel(dto("REDACTION", { rect: { x: 0, y: 0, width: 1, height: 1 } }))).toBe("ปิดพราง");
expect(
annotationTypeLabel(dto("TEXTBOX", { position: { x: 0, y: 0 }, text: "t", color: "#000", fontSize: 1 })),
).toBe("กล่องข้อความ");
expect(annotationTypeLabel(dto("STAMP", { label: "สำคัญ", position: { x: 0, y: 0 }, scale: 18 }))).toBe("ตรายาง");
});
});
describe("annotationPreviewText", () => {
it("HIGHLIGHT ใช้ข้อความที่คลุม / ว่าง → placeholder", () => {
expect(
annotationPreviewText(dto("HIGHLIGHT", { text: "ข้อความที่คลุม", color: "#000", rects: [] })),
).toBe("ข้อความที่คลุม");
expect(annotationPreviewText(dto("HIGHLIGHT", { text: "", color: "#000", rects: [] }))).toBe(
"(ไม่มีข้อความ)",
);
});
it("COMMENT → message, TEXTBOX → text, STAMP → label, DRAW → ข้อความตายตัว", () => {
expect(
annotationPreviewText(dto("COMMENT", { position: { x: 0, y: 0 }, message: "ดูจุดนี้" })),
).toBe("ดูจุดนี้");
expect(
annotationPreviewText(dto("TEXTBOX", { position: { x: 0, y: 0 }, text: "หมายเหตุ", color: "#000", fontSize: 1 })),
).toBe("หมายเหตุ");
expect(
annotationPreviewText(dto("STAMP", { label: "ตรวจสอบแล้ว", position: { x: 0, y: 0 }, scale: 18 })),
).toBe("ตรวจสอบแล้ว");
expect(
annotationPreviewText(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 0, y: 0 }] })),
).toBe("เส้นวาดอิสระ");
});
});
describe("annotationNote", () => {
it("มี note → คืนค่านั้น / ไม่มี → ช่องว่าง", () => {
expect(
annotationNote(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [], note: "เหตุผล" })),
).toBe("เหตุผล");
expect(annotationNote(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [] }))).toBe("");
});
it("COMMENT (ไม่มี field note) → ช่องว่าง", () => {
expect(annotationNote(dto("COMMENT", { position: { x: 0, y: 0 }, message: "m" }))).toBe("");
});
});
describe("effectiveDrawOpacity", () => {
it("opacity ที่เก็บชัดชนะเสมอ", () => {
expect(effectiveDrawOpacity({ pen: "highlighter", opacity: 0.8 })).toBe(0.8);
expect(effectiveDrawOpacity({ opacity: 0.3 })).toBe(0.3);
});
it("ไม่มี opacity → fallback ตาม preset ของ pen", () => {
expect(effectiveDrawOpacity({ pen: "highlighter" })).toBe(DRAW_PENS.highlighter.opacity);
expect(effectiveDrawOpacity({ pen: "pencil" })).toBe(DRAW_PENS.pencil.opacity);
});
it("ไม่มีทั้ง pen และ opacity → pencil", () => {
expect(effectiveDrawOpacity({})).toBe(DRAW_PENS.pencil.opacity);
});
});
describe("presets ที่ sidebar/export ใช้ร่วมกัน", () => {
it("STAMP_PRESETS มีค่าตาม D7 (preset ข้อความเท่านั้น)", () => {
expect(STAMP_PRESETS).toEqual(["ตรวจสอบแล้ว", "สำคัญ"]);
});
});

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
clamp,
getPointerPercent,
mergeRectsByLine,
pathToPercent,
pathToPx,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
toPercent,
toPx,
type Size,
} from "@/utils/coordinates";
const size: Size = { width: 800, height: 600 };
describe("toPercent / toPx", () => {
it("px → % ตามสัดส่วนขนาดที่ส่งเข้ามา", () => {
expect(toPercent(400, 800)).toBe(50);
expect(toPercent(300, 600)).toBe(50);
expect(toPercent(0, 800)).toBe(0);
});
it("roundtrip px → % → px ได้ค่าเดิม", () => {
expect(toPx(toPercent(123, 800), 800)).toBeCloseTo(123);
});
});
describe("clamp", () => {
it("ต่ำกว่า min → min, อยู่ในช่วง → ค่าเดิม, เกิน max → max", () => {
expect(clamp(-5, 0, 100)).toBe(0);
expect(clamp(42, 0, 100)).toBe(42);
expect(clamp(120, 0, 100)).toBe(100);
});
});
describe("point/rect/path converters", () => {
it("point: x อิง width, y อิง height (แกนแยกกัน)", () => {
expect(pointToPercent({ x: 400, y: 300 }, size)).toEqual({ x: 50, y: 50 });
expect(pointToPx({ x: 25, y: 75 }, size)).toEqual({ x: 200, y: 450 });
});
it("rect: แปลงครบทั้ง 4 ค่า", () => {
expect(rectToPercent({ x: 80, y: 60, width: 160, height: 120 }, size)).toEqual({
x: 10,
y: 10,
width: 20,
height: 20,
});
expect(rectToPx({ x: 10, y: 10, width: 20, height: 20 }, size)).toEqual({
x: 80,
y: 60,
width: 160,
height: 120,
});
});
it("path: แปลงทุกจุดในลิสต์", () => {
expect(
pathToPercent(
[
{ x: 0, y: 0 },
{ x: 800, y: 600 },
],
size,
),
).toEqual([
{ x: 0, y: 0 },
{ x: 100, y: 100 },
]);
expect(pathToPx([{ x: 50, y: 50 }], size)).toEqual([{ x: 400, y: 300 }]);
});
});
describe("getPointerPercent", () => {
const makeEl = (left: number, top: number, width: number, height: number) =>
({ getBoundingClientRect: () => ({ left, top, width, height }) }) as HTMLElement;
it("คิดจากขอบ element ไม่ใช่ขอบจอ", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 200, clientY: 100 }, el)).toEqual({ x: 50, y: 50 });
});
it("เกินขอบ element → clamp 100 / ต่ำกว่าขอบ → clamp 0", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 999, clientY: 999 }, el)).toEqual({ x: 100, y: 100 });
expect(getPointerPercent({ clientX: 0, clientY: 0 }, el)).toEqual({ x: 0, y: 0 });
});
});
describe("mergeRectsByLine", () => {
it("rects บรรทัดเดียวกัน (y overlap) → รวมเป็น 1 rect ครอบกว้าง/สูงสุด", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 45, y: 21, width: 20, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toEqual({ x: 10, y: 20, width: 55, height: 6 });
});
it("rects คนละบรรทัด (y ไม่ overlap) → คงแยกกัน", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 12, y: 30, width: 30, height: 5 },
]);
expect(out).toHaveLength(2);
});
it("ลำดับ input ไม่สำคัญ — rect ซ้ายมาทีหลังก็รวมถูกจุดเริ่ม", () => {
const out = mergeRectsByLine([
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ x: 10, width: 55 });
});
it("ช่องว่างระหว่างคำในบรรทัดเดียวกันถูกครอบด้วย (union แกน x)", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 10, height: 5 },
{ x: 80, y: 20, width: 10, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0].width).toBe(80); // 10 → 90
});
it("array ว่าง → ว่าง / rect เดี่ยว → ค่าเดิม", () => {
expect(mergeRectsByLine([])).toEqual([]);
expect(mergeRectsByLine([{ x: 1, y: 2, width: 3, height: 4 }])).toEqual([
{ x: 1, y: 2, width: 3, height: 4 },
]);
});
it("ไม่แก้ array และ rect ต้นฉบับ", () => {
const input = [
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
];
const snapshot = input.map((r) => ({ ...r }));
mergeRectsByLine(input);
expect(input).toEqual(snapshot);
});
});

36
tests/utils/csv.test.ts Normal file
View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { csvField } from "@/utils/csv";
describe("csvField", () => {
it("ครอบ double quote ตาม RFC 4180", () => {
expect(csvField("ทดสอบ")).toBe('"ทดสอบ"');
});
it("escape double quote ในค่าเป็น \"\"", () => {
expect(csvField('พูดว่า "สวัสดี"')).toBe('"พูดว่า ""สวัสดี"""');
});
it("ตัวเลขหน้าไม่ถูกนำหน้าอะไร", () => {
expect(csvField("1")).toBe('"1"');
expect(csvField("12")).toBe('"12"');
});
it("FIX-6: ขึ้นต้นด้วย = + - @ ต้องนำหน้าด้วย ' กันสูตร Excel", () => {
expect(csvField("=1+1")).toBe('"\'=1+1"');
expect(csvField("+SUM(A1:A2)")).toBe('"\'+SUM(A1:A2)"');
expect(csvField("-2+3")).toBe('"\'-2+3"');
expect(csvField("@SUM(A1)")).toBe('"\'@SUM(A1)"');
});
it("FIX-6: ขึ้นต้นด้วย TAB/CR ต้องนำหน้าด้วย ' เช่นกัน", () => {
expect(csvField("\t=cmd")).toBe('"\'\t=cmd"');
expect(csvField("\r=cmd")).toBe('"\'\r=cmd"');
});
it("อักขระพิเศษที่ไม่ใช่จุดเริ่มสูตรไม่ถูกนำหน้า", () => {
expect(csvField("1+1")).toBe('"1+1"');
expect(csvField("a=b")).toBe('"a=b"');
expect(csvField("ราคา -50 บาท")).toBe('"ราคา -50 บาท"');
});
});

View file

@ -0,0 +1,336 @@
import { describe, expect, it } from "vitest";
import type { AnnotationData, ShapeData } from "@/db/schema";
import {
STAMP_HEIGHT_RATIO,
annotationBBox,
hitTestPath,
hitTestShape,
scaleAnnotationData,
translateAnnotationData,
} from "@/utils/geometry";
// เคล็ดลับจาก docstring ของ annotationBBox: size 100×100 ทำให้ px = % โดยตรง
const S = { width: 100, height: 100 };
describe("annotationBBox", () => {
it("HIGHLIGHT: union ของ rects ทุกอัน", () => {
const bbox = annotationBBox(
{
type: "HIGHLIGHT",
data: {
text: "ทดสอบ",
color: "#fde047",
rects: [
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 45, y: 22, width: 20, height: 5 },
],
},
},
S,
);
expect(bbox).toEqual({ x: 10, y: 20, width: 55, height: 7 });
});
it("HIGHLIGHT: rects ว่าง → กล่องศูนย์", () => {
const bbox = annotationBBox(
{ type: "HIGHLIGHT", data: { text: "", color: "#000", rects: [] } },
S,
);
expect(bbox).toEqual({ x: 0, y: 0, width: 0, height: 0 });
});
it("DRAW: ขยายจุดสุดขอบด้วย strokeWidth/2", () => {
const bbox = annotationBBox(
{
type: "DRAW",
data: {
strokeColor: "#ef4444",
strokeWidth: 2,
paths: [
{ x: 10, y: 10 },
{ x: 30, y: 50 },
],
},
},
S,
);
expect(bbox).toEqual({ x: 9, y: 9, width: 22, height: 42 });
});
it("COMMENT: กล่องสี่เหลี่ยมรอบ position ขนาด COMMENT_PIN_BOX_PCT", () => {
const bbox = annotationBBox(
{ type: "COMMENT", data: { position: { x: 50, y: 50 }, message: "สวัสดี" } },
S,
);
expect(bbox).toEqual({ x: 48, y: 48, width: 4, height: 4 });
});
it("SHAPE rect: ขยายออกด้วยครึ่งความหนาเส้น", () => {
const bbox = annotationBBox(
{
type: "SHAPE",
data: {
shape: "rect",
rect: { x: 10, y: 20, width: 30, height: 10 },
strokeColor: "#000",
strokeWidth: 2,
},
},
S,
);
expect(bbox).toEqual({ x: 9, y: 19, width: 32, height: 12 });
});
it("SHAPE line: bbox ของ from/to ขยายครึ่งความหนา (ไม่ใช่ rect)", () => {
const bbox = annotationBBox(
{
type: "SHAPE",
data: {
shape: "line",
from: { x: 10, y: 10 },
to: { x: 40, y: 20 },
strokeColor: "#000",
strokeWidth: 1,
},
},
S,
);
expect(bbox).toEqual({ x: 9.5, y: 9.5, width: 31, height: 11 });
});
it("REDACTION: rect เดิมตรง ๆ ไม่ขยายความหนา", () => {
const bbox = annotationBBox(
{ type: "REDACTION", data: { rect: { x: 10, y: 20, width: 30, height: 10 } } },
S,
);
expect(bbox).toEqual({ x: 10, y: 20, width: 30, height: 10 });
});
it("TEXTBOX: ประมาณจากจำนวนตัวอักษร × 0.6 fontSize บรรทัดสูง 1.5 fontSize", () => {
const bbox = annotationBBox(
{
type: "TEXTBOX",
data: { position: { x: 10, y: 20 }, text: "อบจ", color: "#111827", fontSize: 2 },
},
S,
);
expect(bbox.x).toBe(10);
expect(bbox.y).toBe(20);
expect(bbox.width).toBeCloseTo(3.6); // 3 ตัว × (2 × 0.6)
expect(bbox.height).toBeCloseTo(3); // 1.5 × 2
});
it("TEXTBOX: ข้อความกว้างเกิน 50% ของหน้า → wrap เป็นหลายบรรทัด กว้างถูก cap", () => {
const bbox = annotationBBox(
{
type: "TEXTBOX",
data: { position: { x: 0, y: 0 }, text: "x".repeat(60), color: "#000", fontSize: 2 },
},
S,
);
expect(bbox.width).toBe(50); // 60 × 1.2 = 72 → cap ที่ 50
expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3
});
it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => {
const bbox = annotationBBox(
{
type: "TEXTBOX",
data: { position: { x: 10, y: 20 }, text: "", color: "#000", fontSize: 2 },
},
S,
);
expect(bbox.width).toBeCloseTo(1.2);
expect(bbox.height).toBeCloseTo(3);
});
it("STAMP: กว้าง = scale% สูง = กว้าง × STAMP_HEIGHT_RATIO", () => {
const bbox = annotationBBox(
{
type: "STAMP",
data: { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 },
},
S,
);
expect(bbox).toEqual({ x: 10, y: 20, width: 18, height: 18 * STAMP_HEIGHT_RATIO });
});
});
describe("translateAnnotationData", () => {
const cases: { name: string; data: AnnotationData; dx: number; dy: number }[] = [
{
name: "HIGHLIGHT เลื่อนทุก rect",
data: { text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 5, height: 5 }] },
dx: 5,
dy: 3,
},
{
name: "DRAW เลื่อนทุกจุด",
data: { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 1, y: 2 }] },
dx: 5,
dy: 3,
},
{
name: "SHAPE line เลื่อนทั้ง from/to",
data: { shape: "line", from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, strokeColor: "#000", strokeWidth: 1 },
dx: 5,
dy: 3,
},
{
name: "REDACTION เลื่อน rect",
data: { rect: { x: 10, y: 10, width: 5, height: 5 } },
dx: 5,
dy: 3,
},
{
name: "STAMP เลื่อน position",
data: { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 },
dx: 5,
dy: 3,
},
];
// ตรวจผ่าน annotationBBox (มันแยกชนิดจาก field ของ data ไม่ใช่ค่า type จึงส่ง type อะไรมาก็ได้)
// → ยืนยันว่า "ทั้งชิ้น" เลื่อนไป dx,dy พอดี ไม่ต้องเขียน assertion แยกทีละ variant
it.each(cases)("%s", ({ data, dx, dy }) => {
const out = translateAnnotationData(data, dx, dy);
const before = annotationBBox({ type: "DRAW", data }, S);
const after = annotationBBox({ type: "DRAW", data: out }, S);
expect(after.x).toBeCloseTo(before.x + dx);
expect(after.y).toBeCloseTo(before.y + dy);
});
it("COMMENT เลื่อน position", () => {
const out = translateAnnotationData({ position: { x: 10, y: 10 }, message: "m" }, 5, 3);
expect(out).toEqual({ position: { x: 15, y: 13 }, message: "m" });
});
it("DRAW: strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)", () => {
const out = translateAnnotationData(
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
10,
10,
);
expect(out).toMatchObject({ strokeWidth: 2 });
});
});
describe("scaleAnnotationData", () => {
it("HIGHLIGHT anchor {0,0} ×2: ทุกพิกัดและขนาด ×2", () => {
const out = scaleAnnotationData(
{ text: "a", color: "#000", rects: [{ x: 10, y: 20, width: 5, height: 6 }] },
{ x: 0, y: 0 },
2,
2,
);
if (!("rects" in out)) throw new Error("expected rects");
expect(out.rects[0]).toEqual({ x: 20, y: 40, width: 10, height: 12 });
});
it("HIGHLIGHT ย่อ–ขยายรอบ anchor มุมตรงข้าม: ขอบที่ anchor อยู่นิ่ง", () => {
const out = scaleAnnotationData(
{ text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 20, height: 10 }] },
{ x: 30, y: 20 },
2,
1,
);
if (!("rects" in out)) throw new Error("expected rects");
expect(out.rects[0]).toEqual({ x: -10, y: 10, width: 40, height: 10 });
// ขอบขวา: -10 + 40 = 30 = anchor.x ✓ · y และ height ไม่เปลี่ยนเพราะ sy=1
});
it("COMMENT position ขยายจาก anchor", () => {
const out = scaleAnnotationData({ position: { x: 30, y: 10 }, message: "m" }, { x: 10, y: 10 }, 2, 1);
expect(out).toEqual({ position: { x: 50, y: 10 }, message: "m" });
});
it("DRAW: strokeWidth คงเดิมตามสเปก F1", () => {
const out = scaleAnnotationData(
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
{ x: 0, y: 0 },
3,
3,
);
expect(out).toMatchObject({ strokeWidth: 2 });
});
});
describe("hitTestPath", () => {
const S2 = { width: 100, height: 100 }; // % = px โดยตรง
it("ไม่มีจุดเลย → false", () => {
expect(hitTestPath([], { x: 50, y: 50 }, 5, S2)).toBe(false);
});
it("จุดเดียว: อยู่ใน threshold → true / เกิน → false", () => {
expect(hitTestPath([{ x: 50, y: 50 }], { x: 52, y: 50 }, 5, S2)).toBe(true);
expect(hitTestPath([{ x: 50, y: 50 }], { x: 60, y: 50 }, 5, S2)).toBe(false);
});
it("วัดระยะถึง segment กลางเส้น ไม่ใช่แค่ endpoint", () => {
const path = [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
];
expect(hitTestPath(path, { x: 50, y: 12 }, 5, S2)).toBe(true);
expect(hitTestPath(path, { x: 50, y: 30 }, 5, S2)).toBe(false);
});
it("จุดอยู่ต่อเส้นแต่นอกช่วง segment → วัดถึง endpoint ที่ใกล้สุด", () => {
const path = [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
];
expect(hitTestPath(path, { x: 0, y: 10 }, 5, S2)).toBe(false); // ห่าง endpoint 10px
expect(hitTestPath(path, { x: 7, y: 10 }, 5, S2)).toBe(true); // ห่าง 3px
});
});
describe("hitTestShape", () => {
const S2 = { width: 100, height: 100 };
it("rect: กลางพื้นที่ไม่โดน (hit เฉพาะเส้นขอบ)", () => {
const data: ShapeData = {
shape: "rect",
rect: { x: 20, y: 20, width: 60, height: 40 },
strokeColor: "#000",
strokeWidth: 1,
};
expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false);
});
it("rect: จุดบนขอบ → true / นอกกรอบห่างเกิน threshold → false", () => {
const data: ShapeData = {
shape: "rect",
rect: { x: 20, y: 20, width: 60, height: 40 },
strokeColor: "#000",
strokeWidth: 1,
};
expect(hitTestShape(data, { x: 50, y: 20 }, 3, S2)).toBe(true);
expect(hitTestShape(data, { x: 50, y: 15 }, 3, S2)).toBe(false); // ห่างขอบ 5px
});
it("ellipse: จุดบนเส้นรอบวงรี → true / กลางวง → false", () => {
const data: ShapeData = {
shape: "ellipse",
rect: { x: 20, y: 20, width: 60, height: 40 },
strokeColor: "#000",
strokeWidth: 1,
};
// center (50,40) rx=30 ry=20 → จุด (80,40) อยู่บนวงรีพอดี
expect(hitTestShape(data, { x: 80, y: 40 }, 3, S2)).toBe(true);
expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false);
});
it("line/arrow: วัดระยะถึงเส้นตรง from→to", () => {
const data: ShapeData = {
shape: "arrow",
from: { x: 10, y: 10 },
to: { x: 50, y: 50 },
strokeColor: "#000",
strokeWidth: 1,
};
expect(hitTestShape(data, { x: 31, y: 31 }, 3, S2)).toBe(true); // ใกล้เส้น ~1.4px
expect(hitTestShape(data, { x: 80, y: 80 }, 3, S2)).toBe(false);
});
});

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
displayRectToPdf,
displaySize,
displayToPdfPoint,
encodeRFC5987,
hexToRgb01,
wrapStyledText,
wrapTextByWidth,
} from "@/utils/pdf-export";
// หน้า A4 portrait จริง (pt) — display ของ 90/270 จะสลับเป็น 792×612
const PAGE = { width: 612, height: 792 };
describe("displaySize", () => {
it("/Rotate 0 และ 180 → ขนาดหน้าเดิม", () => {
expect(displaySize(PAGE, 0)).toEqual(PAGE);
expect(displaySize(PAGE, 180)).toEqual(PAGE);
});
it("/Rotate 90 และ 270 → สลับกว้าง-สูง", () => {
expect(displaySize(PAGE, 90)).toEqual({ width: 792, height: 612 });
expect(displaySize(PAGE, 270)).toEqual({ width: 792, height: 612 });
});
});
describe("displayToPdfPoint", () => {
it("rot 0: พลิกแกน y (จอ y ลง → PDF y ขึ้น)", () => {
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 0)).toEqual({ x: 10, y: 772 });
});
it("rot 90: จอมุมบนซ้าย (0,0) → PDF (0,0)", () => {
expect(displayToPdfPoint({ x: 0, y: 0 }, PAGE, 90)).toEqual({ x: 0, y: 0 });
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 90)).toEqual({ x: 20, y: 10 });
});
it("rot 180: มุมบนซ้ายจอ → มุมขวาล่างของ PDF", () => {
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 180)).toEqual({ x: 602, y: 20 });
});
it("rot 270: มุมบนซ้ายจอ → มุมขวาบนของ PDF", () => {
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 270)).toEqual({ x: 592, y: 782 });
});
});
describe("displayRectToPdf", () => {
it("rot 0: y พลิก (y เดิม = ขอบบนจอ → กลายเป็น y บนของ rect PDF)", () => {
expect(displayRectToPdf({ x: 10, y: 20, width: 30, height: 40 }, PAGE, 0)).toEqual({
x: 10,
y: 732,
width: 30,
height: 40,
});
});
it("rot 90: กว้าง-สูงสลับกัน และ normalized เป็นบวกเสมอ", () => {
expect(displayRectToPdf({ x: 10, y: 20, width: 30, height: 40 }, PAGE, 90)).toEqual({
x: 20,
y: 10,
width: 40,
height: 30,
});
});
});
describe("hexToRgb01", () => {
it("แปลง #rrggbb เป็นสัดส่วน 0–1", () => {
expect(hexToRgb01("#ff0000")).toEqual({ r: 1, g: 0, b: 0 });
expect(hexToRgb01("#00ff00")).toEqual({ g: 1, r: 0, b: 0 });
expect(hexToRgb01("#0000ff")).toEqual({ r: 0, g: 0, b: 1 });
});
it("ไม่มี # นำหน้า / ตัวพิมพ์ใหญ่ก็รับ", () => {
expect(hexToRgb01("ff8000")).toEqual({ r: 1, g: 128 / 255, b: 0 });
expect(hexToRgb01("#FF8000")).toEqual({ r: 1, g: 128 / 255, b: 0 });
});
it("รูปแบบผิด → ดำ (fallback กัน parse พัง)", () => {
expect(hexToRgb01("abc")).toEqual({ r: 0, g: 0, b: 0 });
expect(hexToRgb01("#zzzzzz")).toEqual({ r: 0, g: 0, b: 0 });
expect(hexToRgb01("")).toEqual({ r: 0, g: 0, b: 0 });
});
});
describe("wrapTextByWidth", () => {
// จำลองฟอนต์ที่ตัวอักษรกว้าง 1 หน่วยทุกตัว
const w = (s: string) => s.length;
it("ตัดทีละตัวอักษร (ภาษาไทยไม่มีช่องว่างคำ)", () => {
expect(wrapTextByWidth("อบจทดสอบ", 5, w)).toEqual(["อบจทด", "สอบ"]);
});
it("ขึ้นบรรทัดใหม่ด้วย \\n แยกจากการ wrap", () => {
expect(wrapTextByWidth("aaaa\nbb", 5, w)).toEqual(["aaaa", "bb"]);
});
it("คำยาวเกิน maxWidth → แตกต่อทันทีไม่มีการกลืน", () => {
expect(wrapTextByWidth("x".repeat(12), 5, w)).toEqual(["xxxxx", "xxxxx", "xx"]);
});
it("ข้อความว่าง → บรรทัดว่างเดียว", () => {
expect(wrapTextByWidth("", 5, w)).toEqual([""]);
});
});
describe("wrapStyledText", () => {
// ตัวหนากว้าง 2 เท่าของปกติ
const w = (s: string, bold: boolean) => s.length * (bold ? 2 : 1);
it("run ผสมหนา/ปกติ wrap ตามความกว้างจริงของแต่ละตัว แล้วม้วนกลับเป็น run ต่อบรรทัด", () => {
const out = wrapStyledText(
[
{ text: "ab", bold: true },
{ text: "cdef", bold: false },
],
6,
w,
);
// a(2)+b(2)+c(1)+d(1) = 6 พอดีบรรทัดแรก · e,f ไปบรรทัดสอง
expect(out).toEqual([
[
{ text: "ab", bold: true },
{ text: "cd", bold: false },
],
[{ text: "ef", bold: false }],
]);
});
it("\\n ใน run ตัดบรรทัดแบบชัด ๆ", () => {
const out = wrapStyledText([{ text: "a\nb", bold: false }], 10, w);
expect(out).toEqual([[{ text: "a", bold: false }], [{ text: "b", bold: false }]]);
});
it("ตัวอักษรที่กว้างเกิน maxWidth ทั้งบรรทัด ยังวางลงบรรทัดใหม่ได้ (ไม่ทิ้ง)", () => {
const out = wrapStyledText([{ text: "ab", bold: true }], 1, w);
expect(out).toEqual([[{ text: "a", bold: true }], [{ text: "b", bold: true }]]);
});
});
describe("encodeRFC5987", () => {
it("ชื่อไฟล์ภาษาไทย encode เป็น percent-encoding UTF-8", () => {
expect(encodeRFC5987("คำพิพากษา.pdf")).toBe(
"%E0%B8%84%E0%B8%B3%E0%B8%9E%E0%B8%B4%E0%B8%9E%E0%B8%B2%E0%B8%81%E0%B8%A9%E0%B8%B2.pdf",
);
});
it("อักขระ '()* ที่ encodeURIComponent ปล่อยไว้ ต้องถูก encode เพิ่มตาม RFC 5987", () => {
expect(encodeRFC5987("a'b(c)*d")).toBe("a%27b%28c%29%2Ad");
});
});