- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
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("บรรทัดติดกันที่กล่องซ้อนกันเล็กน้อย (leading แน่น/ฟอนต์ไทย) → ยังแยกบรรทัดกัน", () => {
|
|
// ซ้อน 1 หน่วยจากความสูง 5 (20%) — ไม่ใช่บรรทัดเดียวกัน
|
|
const out = mergeRectsByLine([
|
|
{ x: 10, y: 20, width: 30, height: 5 },
|
|
{ x: 12, y: 24, 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);
|
|
});
|
|
});
|