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:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
109
tests/utils/annotation-display.test.ts
Normal file
109
tests/utils/annotation-display.test.ts
Normal 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(["ตรวจสอบแล้ว", "สำคัญ"]);
|
||||
});
|
||||
});
|
||||
145
tests/utils/coordinates.test.ts
Normal file
145
tests/utils/coordinates.test.ts
Normal 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
36
tests/utils/csv.test.ts
Normal 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 บาท"');
|
||||
});
|
||||
});
|
||||
336
tests/utils/geometry.test.ts
Normal file
336
tests/utils/geometry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
tests/utils/pdf-export.test.ts
Normal file
150
tests/utils/pdf-export.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue