ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว
- โหลด 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>
This commit is contained in:
parent
289d735bf3
commit
6fd3a1515f
19 changed files with 890 additions and 53 deletions
|
|
@ -10,7 +10,7 @@ paths:
|
|||
- Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: `SHAPE` + `ShapeData` (rect/ellipse เก็บ `rect` bbox, line/arrow เก็บ `from`/`to` ตาม D3), `REDACTION` + `RedactionData` (`{ rect }` — ดำทึบ ไม่มีสีให้เปลี่ยน), `TEXTBOX` + `TextboxData` (F17 — `{ position, text, color, fontSize, bold?, opacity?, align?, segments? }` position เป็นมุมซ้ายบน, fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right') เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย — bold ตอน export ต้องมี `public/fonts/Sarabun-Bold.ttf` ไม่งั้น fallback เป็น Regular; `segments?` = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field `text` เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ `wrapStyledText` วาดแบบ run ผสม), `STAMP` + `StampData` (F18 — `{ label, position, scale, color? }` scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = `STAMP_COLOR`), `DrawData.widths?` (F7 pressure, optional ยาวเท่า paths; `Point` define ที่ `db/schema.ts` แล้ว re-export จาก `utils/coordinates.ts`), `DrawData.pen?` ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ `DRAW_PENS` ใน `utils/annotation-display.ts`), `DrawData.opacity?` (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน `effectiveDrawOpacity`), `HighlightData.style?` ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), `HighlightData.opacity?` (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน `validateAnnotationData` ครบถ้วน (invariant #8)
|
||||
- column `data` ใช้ `$type<AnnotationData>()` (discriminated union ตาม enum `type`) — ห้าม `any` หรือ object type กว้าง — `note?: string` (โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิด**ยกเว้น COMMENT** (มี `message` ทำหน้าที่นี้อยู่แล้ว) — validator `vNote` trim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่าน `annotationNote()` ใน `utils/annotation-display.ts` เท่านั้น (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
- DTO (`AnnotationDTO`, `BookmarkDTO`): timestamp เป็น **ISO string** เสมอ ผ่าน mapper `toDTO` — ห้ามส่ง `Date` object ข้าม server→client
|
||||
- Server Actions: validate input ก่อนทุกครั้ง (type ถูกต้อง, ฟิลด์ครบ, `pageIndex ≥ 0`) ก่อนแตะ DB; เขียนด้วย `DATABASE_URL` ตรง ๆ + **publish event ผ่าน `lib/annotate-events.ts` หลังเขียนสำเร็จ**
|
||||
- Server Actions: validate input ก่อนทุกครั้ง (type ถูกต้อง, ฟิลด์ครบ, `pageIndex ≥ 0`) ก่อนแตะ DB; เขียนด้วย `DATABASE_URL` ตรง ๆ + **publish event ผ่าน `lib/annotate-events.ts` หลังเขียนสำเร็จ** + **`revalidatePath` หน้า viewer+summary ของ fileId นั้น** (helper `revalidateFilePages` ในไฟล์เดียวกัน — invalidate Router Cache ฝั่ง client เพราะ `force-dynamic` ปิดแค่ฝั่ง server: ไม่งั้นกด back แล้วเข้าไฟล์ใหม่ได้ initial data เก่าจนต้อง hard refresh; action ใหม่ที่เขียน DB ต้องเรียกทุกครั้งเช่นเดียวกับ publish event)
|
||||
- `db/index.ts`: postgres.js + Drizzle, `prepare: false` เสมอ, `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (พอร์ต 54322 — ไม่ใช่ Supabase stack) และ **pool ต้องเก็บบน `globalThis`** — Next dev hot-reload re-evaluate module ได้บ่อย ถ้าสร้าง pool ใน module scope ตรง ๆ pool เก่าไม่ถูกปิดและสะสมจนชน `max_connections` ("too many clients already" — pattern เดียวกับ `lib/annotate-events.ts`)
|
||||
- FK `file_id` ของ annotations/bookmarks: `ON DELETE CASCADE`
|
||||
- Bookmarks ลำดับชั้น (N2): `parent_id uuid NULL` **ไม่ทำ FK** — ลบหัวข้อแม่ต้อง re-parent ลูกขึ้น root (`update ... set parent_id = null`) + delete ใน **transaction เดียวกัน** แล้ว publish update event ต่อลูก; จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH=3`): create เช็ค `depth(parent) < 3`, ย้ายเช็ค `depth(parent)+subtreeHeight ≤ 3` + ห้ามย้ายเข้า subtree ตัวเอง — **helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant` ต้อง `.for("update")` ทุกแถวที่เดินผ่าน** ใน tx เดียวกับ write (กัน race เกินความลึก/orphan) + `note text NULL` (ว่าง/whitespace = null) — validator `vBookmarkNote`
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ paths:
|
|||
- Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px
|
||||
- Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak)
|
||||
- Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง
|
||||
- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer)
|
||||
- **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด
|
||||
- คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ
|
||||
- `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ)
|
||||
- Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state
|
||||
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)`
|
||||
- Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ paths:
|
|||
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=<uuid>` และ `es.close()` เมื่อ unmount
|
||||
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
|
||||
- Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case
|
||||
- Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น
|
||||
- Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
|
||||
- Event `update` (annotations): client **replace ตาม id** — idempotent (มาก่อน/หลัง optimistic ก็ได้ผลเดียวกัน) ห้ามเพิ่ม logic กัน duplicate เกินกว่า replace ด้วย id (PLAN-FEAT.md §5) — update row ของ F10 (`setAnnotationResolved`) ใช้ channel เดียวกันนี้ (row เต็มมี `resolved`/`resolvedAt`)
|
||||
- Event ของ `replies` (F9): create → append + dedupe ด้วย id, delete → filter ตาม id — และเมื่อ annotation แม่ถูกลบ **ต้องเคลียร์ replies ของแม่ใน state ฝั่ง client ด้วย** (CASCADE ใน DB ไม่ส่ง event ราย reply)
|
||||
- `restoreVersion` (F19) ก็ publish **create** event ต่อรายการที่ insert สำเร็จเหมือน action เขียนอื่น ๆ — แท็บของผู้กดเองก็ได้ event ผ่าน SSE ของตัวเอง จึงไม่ต้อง sync state ซ้ำใน client
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
1. **Non-Destructive:** ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ)
|
||||
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
||||
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts`** / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ
|
||||
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
|
||||
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)`
|
||||
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
|
||||
|
||||
import { publishAnnotateEvent } from "@/lib/annotate-events";
|
||||
|
|
@ -376,6 +377,17 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
|
||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invalidate Router Cache ฝั่ง client ของหน้า viewer + หน้าสรุปของไฟล์นี้
|
||||
* หลัง action เขียน DB สำเร็จ — ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ Next.js
|
||||
* ยังใช้ RSC payload เก่า (initialAnnotations ไม่รวมรายการใหม่) จนต้อง
|
||||
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
|
||||
*/
|
||||
function revalidateFilePages(fileId: string) {
|
||||
revalidatePath(`/annotate/${fileId}`);
|
||||
revalidatePath(`/annotate/${fileId}/summary`);
|
||||
}
|
||||
|
||||
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
|
||||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||||
const fileId = vUuid(raw?.fileId, "fileId");
|
||||
|
|
@ -394,6 +406,7 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
|
|||
.returning();
|
||||
const dto = toAnnotationDTO(row);
|
||||
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
|
||||
revalidateFilePages(fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +450,7 @@ export async function deleteAnnotation(id: string, userId: string): Promise<void
|
|||
action: "delete",
|
||||
id: annId,
|
||||
});
|
||||
revalidateFilePages(removed.fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -490,6 +504,7 @@ export async function updateAnnotation(
|
|||
}
|
||||
const dto = toAnnotationDTO(row);
|
||||
publishAnnotateEvent(row.fileId, { table: "annotations", action: "update", row: dto });
|
||||
revalidateFilePages(row.fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -522,6 +537,7 @@ export async function setAnnotationResolved(
|
|||
}
|
||||
const dto = toAnnotationDTO(row);
|
||||
publishAnnotateEvent(existing.fileId, { table: "annotations", action: "update", row: dto });
|
||||
revalidateFilePages(existing.fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -555,6 +571,7 @@ export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
|
|||
.returning();
|
||||
const dto = toReplyDTO(row);
|
||||
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
|
||||
revalidateFilePages(parent.fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -584,6 +601,7 @@ export async function deleteReply(id: string, userId: string): Promise<void> {
|
|||
}
|
||||
if (parent) {
|
||||
publishAnnotateEvent(parent.fileId, { table: "replies", action: "delete", id: replyId });
|
||||
revalidateFilePages(parent.fileId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -708,6 +726,7 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
|
|||
});
|
||||
const dto = toBookmarkDTO(row);
|
||||
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
|
||||
revalidateFilePages(fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -778,6 +797,7 @@ export async function updateBookmark(input: UpdateBookmarkInput): Promise<Bookma
|
|||
}
|
||||
const dto = toBookmarkDTO(row);
|
||||
publishAnnotateEvent(row.fileId, { table: "bookmarks", action: "update", row: dto });
|
||||
revalidateFilePages(row.fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
@ -819,6 +839,7 @@ export async function deleteBookmark(id: string, userId: string): Promise<void>
|
|||
action: "delete",
|
||||
id: bmId,
|
||||
});
|
||||
revalidateFilePages(result.fileId);
|
||||
// ลูกที่ถูกยกขึ้น root → update event ต่อตัว ให้ client อื่นย้ายตำแหน่งตาม
|
||||
for (const child of result.children) {
|
||||
publishAnnotateEvent(result.fileId, {
|
||||
|
|
@ -919,6 +940,7 @@ export async function restoreVersion(
|
|||
|
||||
const dto = toAnnotationDTO(result.row);
|
||||
publishAnnotateEvent(v.fileId, { table: "annotations", action: result.action, row: dto });
|
||||
revalidateFilePages(v.fileId);
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
import type { Rect } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, STAMP_FONT_RATIO, effectiveDrawOpacity } from "@/utils/annotation-display";
|
||||
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
|
||||
|
||||
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
|
||||
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
|
||||
|
|
@ -415,23 +415,22 @@ export async function GET(req: Request) {
|
|||
});
|
||||
if (thaiFont) {
|
||||
const font = thaiFont;
|
||||
const fontSize = w * STAMP_FONT_RATIO;
|
||||
const lines = wrapTextByWidth(
|
||||
// ย่อฟอนต์ให้ label อยู่บรรทัดเดียวพอดีกรอบ — เหมือนที่ render บนจอ
|
||||
// (ไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดแล้วล้นกรอบ)
|
||||
const fontSize = stampFitFontSize(
|
||||
d.label,
|
||||
w * 0.9,
|
||||
(s) => font.widthOfTextAtSize(s, fontSize),
|
||||
w,
|
||||
(s, size) => font.widthOfTextAtSize(s, size),
|
||||
);
|
||||
const lineH = fontSize * 1.2;
|
||||
lines.forEach((line, i) => {
|
||||
const textW = font.widthOfTextAtSize(line, fontSize);
|
||||
page.drawText(line, {
|
||||
const textW = font.widthOfTextAtSize(d.label, fontSize);
|
||||
const lineH = fontSize * 1.2; // กึ่งกลางแนวตั้งตาม lineHeight บนจอ
|
||||
page.drawText(d.label, {
|
||||
x: rect.x + (rect.width - textW) / 2,
|
||||
y: rect.y + rect.height - (rect.height - lines.length * lineH) / 2 - lineH * i - fontSize,
|
||||
y: rect.y + (rect.height - lineH) / 2 + lineH - fontSize,
|
||||
size: fontSize,
|
||||
font,
|
||||
color: stampRgb,
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
43
app/api/pdf/route.ts
Normal file
43
app/api/pdf/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
|
||||
// ให้ viewer โหลด PDF ผ่านทางนี้แทนไฟล์ดิบใน public/ — ซ่อม ToUnicode จาก ActualText
|
||||
// ของ PDF ไทยที่ cmap ของฟอนต์เพี้ยน (สกัดข้อความได้ control char ขยะแทนสระ/วรรณยุกต์)
|
||||
// **แก้บนสำเนาในหน่วยความจำเท่านั้น** ไฟล์ต้นฉบับไม่ถูกเขียน (invariant #1)
|
||||
// ไฟล์ปกติไม่มี ActualText = ได้ bytes เดิมกลับมาไม่มี cost การแปลง
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const fileId = url.searchParams.get("fileId") ?? "";
|
||||
if (!UUID_RE.test(fileId)) {
|
||||
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
|
||||
}
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, fileId));
|
||||
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
|
||||
|
||||
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
|
||||
let srcBytes: Buffer;
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) return new Response(err.message, { status: 400 });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fixed = await fixPdfActualText(srcBytes);
|
||||
return new Response(new Uint8Array(fixed), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
// ขนาดไฟล์ตัวอย่างเล็ก ซ่อมใหม่ต่อ request (สักหลักสิบ ms) — ไม่ต้องแคช
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -155,6 +155,28 @@ export default function AnnotationSelectionLayer({
|
|||
100 - st.origBoxPct.y - st.origBoxPct.height,
|
||||
);
|
||||
next = translateAnnotationData(st.origData, dx, dy);
|
||||
} else if ("scale" in st.origData) {
|
||||
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
||||
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
||||
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
|
||||
const rawK =
|
||||
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
|
||||
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
|
||||
2;
|
||||
const k = clampAxisScale(
|
||||
clampAxisScale(
|
||||
rawK,
|
||||
st.anchorPct.x,
|
||||
st.origBoxPct.x,
|
||||
st.origBoxPct.x + st.origBoxPct.width,
|
||||
st.origBoxPct.width,
|
||||
),
|
||||
st.anchorPct.y,
|
||||
st.origBoxPct.y,
|
||||
st.origBoxPct.y + st.origBoxPct.height,
|
||||
st.origBoxPct.height,
|
||||
);
|
||||
next = scaleAnnotationData(st.origData, st.anchorPct, k, k);
|
||||
} else {
|
||||
const sx = clampAxisScale(
|
||||
(cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
ArrowRight,
|
||||
Bold,
|
||||
BookmarkPlus,
|
||||
Brush,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
|
|
@ -86,6 +87,7 @@ import {
|
|||
annotationTypeLabel,
|
||||
DRAW_PENS,
|
||||
effectiveDrawOpacity,
|
||||
sanitizePdfText,
|
||||
STAMP_COLOR,
|
||||
STAMP_DEFAULT_SCALE_PCT,
|
||||
STAMP_PRESETS,
|
||||
|
|
@ -164,9 +166,11 @@ type BookmarkAction = { kind: "remove"; id: string };
|
|||
|
||||
const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"];
|
||||
|
||||
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ยุบ whitespace และตัดยาวไม่เกิน 120 ตัวอักษร */
|
||||
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
|
||||
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
|
||||
* และตัดยาวไม่เกิน 120 ตัวอักษร */
|
||||
function normalizeBookmarkTitle(s: string): string {
|
||||
const t = s.replace(/\s+/g, " ").trim();
|
||||
const t = sanitizePdfText(s).replace(/\s+/g, " ").trim();
|
||||
return t.length > 120 ? `${t.slice(0, 120)}…` : t;
|
||||
}
|
||||
|
||||
|
|
@ -197,6 +201,142 @@ function squigglePath(w: number): string {
|
|||
return d;
|
||||
}
|
||||
|
||||
/** กรอง clientRects ของ selection ให้เหลือเฉพาะแนวเส้นทางที่ผู้ใช้ลากเมาส์จริง —
|
||||
* span ของ textLayer ในบาง PDF (เอกสารไทย) เรียงใน DOM ไม่ตรงลำดับบรรทัด และ
|
||||
* anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node)
|
||||
* จึงใช้จุด mousedown + ตำแหน่งเมาส์ล่าสุด (client coords) เป็น ground truth แทน
|
||||
* กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ,
|
||||
* บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/
|
||||
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
|
||||
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง */
|
||||
type ClientPt = { x: number; y: number };
|
||||
function rectsInSweptPath(rects: DOMRect[], start: ClientPt, end: ClientPt): DOMRect[] {
|
||||
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
|
||||
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
|
||||
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
|
||||
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
|
||||
let top: ClientPt;
|
||||
let bottom: ClientPt;
|
||||
if (Math.abs(end.y - start.y) < lineTol) {
|
||||
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
|
||||
} else {
|
||||
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
|
||||
}
|
||||
return rects.filter((r) => {
|
||||
const cy = r.y + r.height / 2;
|
||||
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
|
||||
if (!moved) return true;
|
||||
const inTop = Math.abs(cy - top.y) <= lineTol;
|
||||
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
|
||||
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
|
||||
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
|
||||
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
|
||||
if (inBottom) return crosses(-Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ
|
||||
return true; // บรรทัดกลาง: ครอบทั้งบรรทัด
|
||||
});
|
||||
}
|
||||
|
||||
/** วรรณยุกต์/สระบน-ล่างไทย (combining mark) — ตามหลังพยัญชนะฐานเสมอ แต่เดี่ยว ๆ
|
||||
* rect อาจกว้าง 0 หรือลอยเหนือบรรทัด จึงต้องให้ตามสถานะของตัวฐานก่อนหน้า */
|
||||
function isThaiCombiningMark(c: string): boolean {
|
||||
const v = c.charCodeAt(0);
|
||||
return v === 0x0e31 || (v >= 0x0e34 && v <= 0x0e3a) || (v >= 0x0e47 && v <= 0x0e4e);
|
||||
}
|
||||
|
||||
/** ข้อความเฉพาะส่วนที่อยู่ใน rects ที่กรองแล้ว — DOM range ของ textLayer ไทยครอบ
|
||||
* ข้อความเกินบริเวณที่ลากจริง selection.toString() ดิบเลยได้คำเกินมา (rects ถูกกรอง
|
||||
* ด้วย rectsInSweptPath แต่ข้อความยังไม่) จึงเดินทีละตัวอักษรใน text nodes ของ range
|
||||
* เช็คว่า rect ของตัวอักษรนั้นตกใน rect ที่ render บนจอไหม — ข้อความตรงกับที่เห็น
|
||||
* จากนั้นเรียงตามตำแหน่ง rect (บน→ล่าง, ซ้าย→ขวา) เพราะ DOM order ของ span ใน PDF
|
||||
* ไทยบางไฟล์ไม่ตรงลำดับบนจอ แล้วปิดท้ายด้วย sanitizePdfText ตัด control char ขยะ
|
||||
* แพงกว่า toString() มาก ใช้เฉพาะตอน mouseup/copy เท่านั้น (live preview เรียกถี่เกิน) */
|
||||
const TEXT_PROBE_MAX_CHARS = 3000;
|
||||
function selectionTextWithin(kept: DOMRect[], range: Range): string {
|
||||
const raw = range.toString();
|
||||
// เลือกยาวเกินขีดนี้ = คุ้มกว่าใช้ข้อความดิบ (กันหน่วงตอนปล่อยเมาส์)
|
||||
if (raw.length > TEXT_PROBE_MAX_CHARS) return sanitizePdfText(raw.slice(0, 5000));
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
const root = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode : ancestor;
|
||||
if (!root) return sanitizePdfText(raw.slice(0, 5000));
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const probe = document.createRange();
|
||||
const eps = 1; // ทนพลาดจากการปัดทศนิยมของ client rects
|
||||
// ตัวอักษรที่เลือก: rect ที่ hit (ใช้เป็นกลุ่มบรรทัด) + ตำแหน่ง x + ลำดับ DOM
|
||||
// (สำรองตอนจัดเรียง — combining mark ที่ไม่มี rect เอา x ของตัวฐานก่อนหน้า)
|
||||
type Entry = { ch: string; rect: number; left: number; dom: number };
|
||||
const entries: Entry[] = [];
|
||||
let dom = 0;
|
||||
let prevRect = -1;
|
||||
let prevLeft = 0;
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
const node = n as Text;
|
||||
if (!range.intersectsNode(node)) continue;
|
||||
const s = node === range.startContainer ? range.startOffset : 0;
|
||||
const e = node === range.endContainer ? range.endOffset : node.data.length;
|
||||
for (let i = s; i < e && entries.length < 6000; i++) {
|
||||
const ch = node.data[i];
|
||||
probe.setStart(node, i);
|
||||
probe.setEnd(node, i + 1);
|
||||
const r = probe.getClientRects()[0];
|
||||
let hit = -1;
|
||||
if (r !== undefined) {
|
||||
hit = kept.findIndex(
|
||||
(k) =>
|
||||
r.left < k.right + eps &&
|
||||
r.right > k.left - eps &&
|
||||
r.top < k.bottom + eps &&
|
||||
r.bottom > k.top - eps,
|
||||
);
|
||||
}
|
||||
// combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น
|
||||
const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0);
|
||||
if (hit < 0 && sticky) hit = prevRect;
|
||||
if (hit < 0) {
|
||||
prevRect = -1;
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
ch,
|
||||
rect: hit,
|
||||
left: hit === prevRect && sticky ? prevLeft : r ? r.left : 0,
|
||||
dom: dom++,
|
||||
});
|
||||
prevRect = hit;
|
||||
prevLeft = entries[entries.length - 1].left;
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) return sanitizePdfText(raw.slice(0, 5000));
|
||||
// เรียงกลุ่ม rect ตามตำแหน่งบนจอ: คนละบรรทัด (กลางแกน y ห่างเกินความสูงบรรทัด)
|
||||
// เรียงบน→ล่าง, บรรทัดเดียวกันเรียงซ้าย→ขวา — DOM order ใช้เป็น tie-break เท่านั้น
|
||||
const midY = (r: DOMRect) => r.top + r.height / 2;
|
||||
const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => {
|
||||
const ra = kept[a];
|
||||
const rb = kept[b];
|
||||
if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) {
|
||||
return midY(ra) - midY(rb);
|
||||
}
|
||||
return ra.left - rb.left;
|
||||
});
|
||||
let out = "";
|
||||
let prev = -1;
|
||||
for (const ri of rectOrder) {
|
||||
if (prev >= 0) {
|
||||
const a = kept[prev];
|
||||
const b = kept[ri];
|
||||
const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height);
|
||||
if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString()
|
||||
}
|
||||
out += entries
|
||||
.filter((en) => en.rect === ri)
|
||||
.sort((a, b) => a.left - b.left || a.dom - b.dom)
|
||||
.map((en) => en.ch)
|
||||
.join("");
|
||||
prev = ri;
|
||||
}
|
||||
// กรองแล้วว่างเปล่า (เรขาคณิตผิดปกติ) = ใช้ข้อความดิบดีกว่าไม่มีเลย
|
||||
return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000));
|
||||
}
|
||||
|
||||
const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
|
||||
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
|
||||
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
|
||||
|
|
@ -216,10 +356,11 @@ const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
|
|||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#334155" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>',
|
||||
)}") 2 17, auto`;
|
||||
|
||||
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS */
|
||||
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS
|
||||
* (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */
|
||||
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
|
||||
pencil: Pencil,
|
||||
highlighter: Highlighter,
|
||||
highlighter: Brush,
|
||||
};
|
||||
|
||||
/** ชนิดรูปทรงย่อยของ tool shape (F5/B1) — ปุ่มแสดงเมื่อ tool ใช้งาน */
|
||||
|
|
@ -389,6 +530,7 @@ function PdfPage({
|
|||
pageBaseWidth,
|
||||
liveMark,
|
||||
liveMarkStyle,
|
||||
selectPreview,
|
||||
annotations,
|
||||
replies,
|
||||
openPinId,
|
||||
|
|
@ -435,6 +577,9 @@ function PdfPage({
|
|||
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
|
||||
liveMark: { pageIndex: number; rects: Rect[] } | null;
|
||||
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
|
||||
/** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark
|
||||
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
|
||||
selectPreview: { pageIndex: number; rects: Rect[] } | null;
|
||||
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
|
||||
pageBaseWidth: number;
|
||||
annotations: AnnotationDTO[];
|
||||
|
|
@ -662,6 +807,19 @@ function PdfPage({
|
|||
liveMarkStyle.opacity,
|
||||
),
|
||||
)}
|
||||
{/* selection สีฟ้าของ tool เลือกข้อความ — กรองแนวเส้นทางลากแล้ว วาดเองแทน
|
||||
สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */}
|
||||
{selectPreview &&
|
||||
selectPreview.pageIndex === pageNumber - 1 &&
|
||||
selectPreview.rects.map((r, i) =>
|
||||
renderMark(
|
||||
`selprev-${i}`,
|
||||
rectToPx(r, { width, height }),
|
||||
"#3b82f6",
|
||||
"highlight",
|
||||
0.3,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
|
||||
|
|
@ -933,6 +1091,8 @@ export default function PDFViewer({
|
|||
const [currentPage, setCurrentPage] = useState(0);
|
||||
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
|
||||
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
|
||||
// tool เลือกข้อความ — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์)
|
||||
const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
|
||||
const [composer, setComposer] = useState<Composer | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [openPinId, setOpenPinId] = useState<string | null>(null);
|
||||
|
|
@ -1068,7 +1228,17 @@ export default function PDFViewer({
|
|||
(async () => {
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||
const doc = await pdfjs.getDocument({ url: file.pdfUrl }).promise;
|
||||
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||||
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const res = await fetch(`/api/pdf?fileId=${file.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.arrayBuffer();
|
||||
} catch {
|
||||
data = await (await fetch(file.pdfUrl)).arrayBuffer();
|
||||
}
|
||||
const doc = await pdfjs.getDocument({ data }).promise;
|
||||
loadedDoc = doc;
|
||||
if (cancelled) {
|
||||
void doc.destroy();
|
||||
|
|
@ -1096,7 +1266,7 @@ export default function PDFViewer({
|
|||
cancelled = true;
|
||||
if (loadedDoc) void loadedDoc.destroy();
|
||||
};
|
||||
}, [file.pdfUrl]);
|
||||
}, [file.id, file.pdfUrl]);
|
||||
|
||||
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
|
|
@ -1230,10 +1400,28 @@ export default function PDFViewer({
|
|||
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
|
||||
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
|
||||
// contenteditable (PLAN-FEAT.md §3 F3, §5)
|
||||
const shortcutHandlersRef = useRef({ switchTool, openBookmarkComposer, undoHistory, redoHistory });
|
||||
const shortcutHandlersRef = useRef({
|
||||
switchTool,
|
||||
openBookmarkComposer,
|
||||
undoHistory,
|
||||
redoHistory,
|
||||
deleteSelected: () => {},
|
||||
});
|
||||
useEffect(() => {
|
||||
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
|
||||
shortcutHandlersRef.current = { switchTool, openBookmarkComposer, undoHistory, redoHistory };
|
||||
shortcutHandlersRef.current = {
|
||||
switchTool,
|
||||
openBookmarkComposer,
|
||||
undoHistory,
|
||||
redoHistory,
|
||||
// DEL/Backspace = ลบรายการที่เลือก (ของตัวเองเท่านั้น — เงื่อนไขเดียวกับปุ่มลบในแผง selection)
|
||||
deleteSelected: () => {
|
||||
if (selectedId === null || !user) return;
|
||||
const target = annotations.find((a) => a.id === selectedId);
|
||||
if (!target || target.userId !== user.userId) return;
|
||||
void handleDeleteAnnotation(selectedId);
|
||||
},
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -1269,7 +1457,17 @@ export default function PDFViewer({
|
|||
}
|
||||
|
||||
if (e.altKey || inForm) return;
|
||||
switch (e.key.toLowerCase()) {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "delete" || k === "backspace") {
|
||||
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
|
||||
h.deleteSelected();
|
||||
return;
|
||||
}
|
||||
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กด B เปิด popup bookmark แล้ว
|
||||
// React focus input ทันทีใน keydown เดียวกัน browser จึงพิมพ์ "b" ตามลงไป
|
||||
if (k !== "h" && k !== "d" && k !== "c" && k !== "b") return;
|
||||
e.preventDefault();
|
||||
switch (k) {
|
||||
case "h": // H = ไฮไลต์ — สลับไป tool highlight (คลุมข้อความ = สร้างทันที)
|
||||
h.switchTool("highlight");
|
||||
break;
|
||||
|
|
@ -1511,6 +1709,25 @@ export default function PDFViewer({
|
|||
/** ข้อความที่เพิ่งคลุมบนหน้าล่าสุด — ชื่อ default ของ popup bookmark (จาก mouseup บนหน้า) */
|
||||
const lastSelectionTextRef = useRef("");
|
||||
|
||||
/** เส้นทางลากเมาส์ของ tool highlight/select (client coords) — จุดกดเมาส์ + ตำแหน่ง
|
||||
* ล่าสุด ใช้เป็น ground truth กรอง selection rects (DOM order ของ textLayer บาง PDF
|
||||
* มั่วจน range ครอบบรรทัดที่ไม่ได้ลากผ่าน) — เคลียร์ตอนปล่อยเมาส์ (document mouseup
|
||||
* ยิงหลัง React onMouseUp ของ page เพราะ root อยู่ใต้ document) */
|
||||
const selDragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const selDragEndRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
/** เส้นทางลากล่าสุด (px อิงหน้า) + ข้อความดิบ/ความกว้างหน้า ณ mouseup — ให้ event
|
||||
* copy นำไปกรองแนวที่ลากจริงแบบเดียวกับตอนสร้างมาร์ก (DOM range ของ textLayer
|
||||
* ไทยครอบ span เกินบริเวณที่เห็น Ctrl+C เลยได้ข้อความเกินมา) — เทียบข้อความ
|
||||
* ดิบ + ความกว้างหน้าก่อนใช้ กันข้อมูลเก่าหลัง selection/zoom เปลี่ยน */
|
||||
const lastSweepRef = useRef<{
|
||||
pageIndex: number;
|
||||
start: ClientPt;
|
||||
end: ClientPt;
|
||||
pageWidth: number;
|
||||
raw: string;
|
||||
} | null>(null);
|
||||
|
||||
/** ความลึกของ bookmark (root = 1) บน state ปัจจุบัน — popup ใช้กรองเป้าหมายระดับ 1–2
|
||||
* (cap 100 กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) */
|
||||
function bookmarkDepthOf(b: BookmarkDTO): number {
|
||||
|
|
@ -1647,8 +1864,10 @@ export default function PDFViewer({
|
|||
// ─── Text selection → highlight (PLAN.md §6.1) ─────────────────────────────
|
||||
|
||||
/** อ่าน text selection ปัจจุบันบนหน้าที่ระบุ → rects % (รวบบรรทัดแล้ว) + ข้อความ + rect แรก
|
||||
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก */
|
||||
function readTextSelection(pageIndex: number) {
|
||||
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก — ข้อความ (กรองให้เหลือ
|
||||
* เฉพาะตัวอักษรใน rects ที่เห็นบนจอ) คำนวณเฉพาะเมื่อ withText เพราะแพง ส่วน live
|
||||
* preview ที่เรียกถี่ระหว่างลากใช้แค่ rects */
|
||||
function readTextSelection(pageIndex: number, opts?: { withText?: boolean }) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return null;
|
||||
|
|
@ -1665,8 +1884,32 @@ export default function PDFViewer({
|
|||
(r) => r.width > 0,
|
||||
);
|
||||
if (clientRects.length === 0) return null;
|
||||
// จำกัดเฉพาะแนวเส้นทางที่ลากเมาส์จริง — DOM range ของ textLayer ที่ span เรียง
|
||||
// มั่วครอบ rect ของบรรทัดอื่นที่ไม่ได้ลากผ่าน (ไม่มีเส้นทางเมาส์ เช่น เลือกด้วย
|
||||
// คีย์บอร์ด = ปล่อยผ่านตามเดิม ไม่ให้พังยิ่งกว่าเดิม)
|
||||
const startPt = selDragStartRef.current;
|
||||
const endPt = selDragEndRef.current;
|
||||
const sweptRects =
|
||||
startPt && endPt
|
||||
? rectsInSweptPath(clientRects, startPt, endPt)
|
||||
: clientRects;
|
||||
if (sweptRects.length === 0) return null;
|
||||
const pageRect = pageEl.getBoundingClientRect();
|
||||
const rectsPct = clientRects.map((r) =>
|
||||
// จำเส้นทางลาก (px อิงหน้า) ไว้ให้ event copy ใช้กรองแบบเดียวกันนี้ — ครอบ
|
||||
// ความกว้างหน้าด้วยเพื่อการ์ดตอน zoom เปลี่ยนหลังจากนี้ (ค่า px อิงหน้าจะสเกลตาม)
|
||||
if (opts?.withText) {
|
||||
lastSweepRef.current =
|
||||
startPt && endPt
|
||||
? {
|
||||
pageIndex,
|
||||
start: { x: startPt.x - pageRect.left, y: startPt.y - pageRect.top },
|
||||
end: { x: endPt.x - pageRect.left, y: endPt.y - pageRect.top },
|
||||
pageWidth: pageRect.width,
|
||||
raw: selection.toString(),
|
||||
}
|
||||
: null; // เลือกด้วยคีย์บอร์ด (ไม่มีเส้นทาง) — copy ใช้ rect ของ range ตรง ๆ
|
||||
}
|
||||
const rectsPct = sweptRects.map((r) =>
|
||||
rectToPercent(
|
||||
{
|
||||
x: r.left - pageRect.left,
|
||||
|
|
@ -1679,24 +1922,28 @@ export default function PDFViewer({
|
|||
);
|
||||
return {
|
||||
rects: mergeRectsByLine(rectsPct), // รวบเหลือ 1 rect ต่อบรรทัดก่อนบันทึก
|
||||
// ตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4) — คลุมทั้งหน้า
|
||||
// ของเอกสารหนาแน่นแล้ว action ปฏิเสธ ทำให้ไฮไลต์หายเงียบ (text ใช้แค่ค้นหา/preview)
|
||||
text: selection.toString().slice(0, 5000),
|
||||
first: clientRects[0],
|
||||
// ข้อความกรองให้เหลือเฉพาะตัวอักษรใน rects ข้างบน (DOM range ไทยครอบคำเกิน
|
||||
// บริเวณที่ลาก) และตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4)
|
||||
// — text ใช้แค่ค้นหา/preview/ชื่อ bookmark
|
||||
text: opts?.withText ? selectionTextWithin(sweptRects, range) : "",
|
||||
first: sweptRects[0],
|
||||
};
|
||||
}
|
||||
|
||||
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
|
||||
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
||||
// tool เลือกข้อความ — ใช้กลไกเดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์)
|
||||
useEffect(() => {
|
||||
if (tool !== "highlight") {
|
||||
if (tool !== "highlight" && tool !== "select") {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
return;
|
||||
}
|
||||
const onSelectionChange = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
return;
|
||||
}
|
||||
const node = selection.getRangeAt(0).commonAncestorContainer;
|
||||
|
|
@ -1704,11 +1951,13 @@ export default function PDFViewer({
|
|||
const pageEl = el?.closest<HTMLElement>("[data-page]");
|
||||
if (!pageEl) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
return;
|
||||
}
|
||||
const pi = Number(pageEl.dataset.page);
|
||||
const sel = readTextSelection(pi);
|
||||
setLiveMark((prev) => {
|
||||
const setPreview = tool === "highlight" ? setLiveMark : setSelectPreview;
|
||||
setPreview((prev) => {
|
||||
const next = sel ? { pageIndex: pi, rects: sel.rects } : null;
|
||||
// ค่าเดิมทุกอย่าง = คืน object เดิมให้ React ข้าม re-render (ลากทีละนิดไม่หนัก)
|
||||
const same =
|
||||
|
|
@ -1727,19 +1976,70 @@ export default function PDFViewer({
|
|||
});
|
||||
};
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
return () => document.removeEventListener("selectionchange", onSelectionChange);
|
||||
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — listener นี้ที่ document ยิงหลัง React
|
||||
// onMouseUp ของหน้า (root ของ React อยู่ใต้ document) ค่าจึงยังใช้ได้ตอนสร้างมาร์ก
|
||||
const onMouseUpDoc = () => {
|
||||
selDragStartRef.current = null;
|
||||
selDragEndRef.current = null;
|
||||
};
|
||||
document.addEventListener("mouseup", onMouseUpDoc);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
document.removeEventListener("mouseup", onMouseUpDoc);
|
||||
};
|
||||
}, [tool]);
|
||||
|
||||
// Copy (Ctrl+C) ข้อความจาก textLayer — ข้อความดิบของ PDF ไทยบางไฟล์เพี้ยน (glyph
|
||||
// ต่อ glyph + control char ขยะ + span เรียงไม่ตรงลำดับบนจอ) และ DOM range ครอบ
|
||||
// span เกินบริเวณที่ลาก จึงแทนที่คลิปบอร์ดด้วยข้อความที่กรองตามเส้นทางลากล่าสุด
|
||||
// (เทียบข้อความดิบ + ความกว้างหน้า — ต่างจากตอน mouseup = selection/zoom เปลี่ยน
|
||||
// ไปแล้ว กลับไปใช้ rect ของ range เองทั้งหมด) เรียงตามตำแหน่งบนจอแล้วล้างขยะ
|
||||
useEffect(() => {
|
||||
if (tool !== "highlight" && tool !== "select") return;
|
||||
const onCopy = (e: ClipboardEvent) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const node = range.commonAncestorContainer;
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
const pageEl = el?.closest<HTMLElement>("[data-page]");
|
||||
if (!pageEl) return; // เฉพาะ selection ในหน้า PDF ของเรา
|
||||
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
|
||||
const sweep = lastSweepRef.current;
|
||||
const pageRect = pageEl.getBoundingClientRect();
|
||||
const fresh =
|
||||
sweep !== null &&
|
||||
sweep.pageIndex === Number(pageEl.dataset.page) &&
|
||||
Math.abs(sweep.pageWidth - pageRect.width) < 1 &&
|
||||
sweep.raw === selection.toString();
|
||||
const kept =
|
||||
sweep && fresh && rects.length > 0
|
||||
? rectsInSweptPath(
|
||||
rects,
|
||||
{ x: sweep.start.x + pageRect.left, y: sweep.start.y + pageRect.top },
|
||||
{ x: sweep.end.x + pageRect.left, y: sweep.end.y + pageRect.top },
|
||||
)
|
||||
: rects;
|
||||
const text =
|
||||
kept.length > 0
|
||||
? selectionTextWithin(kept, range)
|
||||
: sanitizePdfText(selection.toString());
|
||||
e.preventDefault();
|
||||
e.clipboardData?.setData("text/plain", text);
|
||||
};
|
||||
document.addEventListener("copy", onCopy);
|
||||
return () => document.removeEventListener("copy", onCopy);
|
||||
}, [tool]);
|
||||
|
||||
function handleMouseUpOnPage(pageIndex: number) {
|
||||
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
|
||||
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
|
||||
lastSelectionTextRef.current = normalizeBookmarkTitle(
|
||||
readTextSelection(pageIndex)?.text ?? "",
|
||||
);
|
||||
// อ่านครั้งเดียวพร้อมข้อความ (กรองตาม rects แล้ว) — bookmark กับมาร์กใช้ชุดเดียวกัน
|
||||
const sel = readTextSelection(pageIndex, { withText: true });
|
||||
lastSelectionTextRef.current = normalizeBookmarkTitle(sel?.text ?? "");
|
||||
// มาร์กข้อความสร้างจาก tool highlight อย่างเดียว (มี live preview ระหว่างลาก)
|
||||
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
|
||||
if (tool !== "highlight") return;
|
||||
const sel = readTextSelection(pageIndex);
|
||||
if (!sel) return;
|
||||
setLiveMark(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
|
@ -2006,8 +2306,9 @@ export default function PDFViewer({
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* tool highlight — ซ่อนสี selection น้ำเงินของเบราว์เซอร์ ให้เห็นแต่ preview มาร์กของเรา */}
|
||||
{tool === "highlight" && (
|
||||
{/* tool highlight/select — ซ่อนสี selection ของเบราว์เซอร์ ให้เห็นแต่ preview
|
||||
มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว สะอาดกว่า native) */}
|
||||
{(tool === "highlight" || tool === "select") && (
|
||||
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
|
||||
)}
|
||||
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
|
||||
|
|
@ -2748,7 +3049,19 @@ export default function PDFViewer({
|
|||
style={{ width: w, height: h }}
|
||||
onMouseUp={() => handleMouseUpOnPage(i)}
|
||||
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
|
||||
onMouseMove={(e) => handlePageHoverMove(e, i)}
|
||||
onMouseDown={(e) => {
|
||||
// จุดเริ่มเส้นทางลากของ tool highlight/select (กรอง selection rects)
|
||||
if (tool !== "highlight" && tool !== "select") return;
|
||||
selDragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1)
|
||||
if ((tool === "highlight" || tool === "select") && e.buttons === 1) {
|
||||
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
handlePageHoverMove(e, i);
|
||||
}}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{mounted && pdfjs && doc ? (
|
||||
|
|
@ -2772,6 +3085,7 @@ export default function PDFViewer({
|
|||
? { opacity: highlightOpacityPct / 100 }
|
||||
: {}),
|
||||
}}
|
||||
selectPreview={selectPreview}
|
||||
annotations={visibleAnnotations
|
||||
.filter((a) => a.pageIndex === i)
|
||||
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, STAMP_FONT_RATIO } from "@/utils/annotation-display";
|
||||
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
|
||||
|
||||
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
|
||||
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
|
||||
|
|
@ -20,6 +20,20 @@ export type InkTextbox = { id: string; userId: string; data: TextboxData };
|
|||
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
|
||||
export type InkStamp = { id: string; userId: string; data: StampData };
|
||||
|
||||
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
|
||||
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
|
||||
const STAMP_FONT_STACK = "system-ui, sans-serif";
|
||||
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
|
||||
function measureStampText(text: string, fontSize: number): number {
|
||||
if (typeof document === "undefined") return 0;
|
||||
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
|
||||
if (!stampMeasureCtx) return 0;
|
||||
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
|
||||
return stampMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
|
||||
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
|
||||
const esc = (s: string) =>
|
||||
|
|
@ -294,7 +308,8 @@ export default function TextboxStampLayer({
|
|||
onCancel={onCancelComposer}
|
||||
/>
|
||||
)}
|
||||
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5) */}
|
||||
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
|
||||
ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
|
||||
{stamps.map((s) => {
|
||||
const w = toPx(s.data.scale, size.width);
|
||||
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
|
||||
|
|
@ -310,8 +325,10 @@ export default function TextboxStampLayer({
|
|||
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
|
||||
borderRadius: Math.max(w * 0.04, 2),
|
||||
color: c,
|
||||
fontSize: w * STAMP_FONT_RATIO,
|
||||
fontFamily: STAMP_FONT_STACK,
|
||||
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ Index: `(file_id)`
|
|||
|
||||
### ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §4)
|
||||
|
||||
- **F3 E2 คีย์ลัด:** keydown listener ที่ `window` ใน PDFViewer — `H/D/C` สลับ tool, `B` bookmark, `Ctrl+Z`/`Ctrl+Shift+Z` undo/redo, `Esc` ปิด popup/deselect; **ข้ามเมื่อ `e.target` เป็น input/textarea/contenteditable**
|
||||
- **F3 E2 คีย์ลัด:** keydown listener ที่ `window` ใน PDFViewer — `H/D/C` สลับ tool, `B` bookmark, `Ctrl+Z`/`Ctrl+Shift+Z` undo/redo, `Esc` ปิด popup/deselect, `DEL`/`Backspace` ลบรายการที่เลือก (ของตัวเองเท่านั้น); **ข้ามเมื่อ `e.target` เป็น input/textarea/contenteditable**
|
||||
- **F4 A3 ซ่อน–แสดงรายคน:** state `userFilter: "all" | "mine" | <userId>` + dropdown รายชื่อ (จาก unique userId ใน annotations) — กรองทั้ง viewer และ sidebar ฝั่ง client ล้วน
|
||||
- **F7 E1 สไตลัส/สัมผัส:** InkLayer ใช้ Pointer Events + `touch-none` อยู่แล้ว — เพิ่ม: pressure (`e.pressure` → `widths` per-point, render เป็น segment เมื่อมี widths), palm rejection (เมื่อเคยเห็น `pointerType === "pen"` → ละเว้น `touch` ในหน้านั้น)
|
||||
- **F13 A6 แก้ไขข้อความ comment:** ปุ่ม "แก้ไข" ใน popover (เจ้าของเท่านั้น) → textarea → `updateAnnotation` (reuse F1)
|
||||
|
|
|
|||
202
lib/pdf-actual-text.ts
Normal file
202
lib/pdf-actual-text.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import {
|
||||
decodePDFRawStream,
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFRawStream,
|
||||
PDFRef,
|
||||
PDFStream,
|
||||
} from "pdf-lib";
|
||||
|
||||
// ซ่อมข้อความ PDF ไทยที่ cmap ของฟอนต์เพี้ยน (เช่น คำสั่งศาลปกครอง) โดยเรียนรู้
|
||||
// glyph-code → ข้อความจาก /ActualText ใน marked content ของ content stream
|
||||
// (ตัวสร้างไฟล์ฝังข้อความถูกไว้ตรงนั้น แต่ ToUnicode CMap เพี้ยนจน pdf.js สกัดได้
|
||||
// control char ขยะแทนสระ/วรรณยุกต์) แล้ว**เขียน ToUnicode ใหม่บนสำเนาในหน่วยความจำ**
|
||||
// — ไฟล์ต้นฉบับไม่ถูกแตะ (invariant #1) เฉพาะ buffer ที่จะส่งให้ pdf.js เท่านั้น
|
||||
//
|
||||
// กลไก: ActualText ครอบรันของ glyph-code แล้วให้ข้อความของรันนั้น — ไทยมีสระ/วรรณยุกต์
|
||||
// เป็น glyph แยกตัวกันพอดี จึงจัดข้อความกับ code ทีละตัว 1:1 ตาม code point ได้
|
||||
// (จำนวนไม่เท่า = ยกข้อความทั้งก้อนให้ code แรก) แล้วโหวตเออกันส่วนมากระหว่างรัน
|
||||
// ก่อน override ลง ToUnicode เดิม (รักษา mapping ที่ถูกอยู่แล้ว เช่น พยัญชนะทั่วไป)
|
||||
|
||||
/** ถอดรหัส stream ของ pdf-lib (FlateDecode ฯลฯ) เป็นข้อความ latin1 สำหรับสแกน regex */
|
||||
function streamToText(obj: unknown): string {
|
||||
if (obj instanceof PDFRawStream) {
|
||||
return Buffer.from(decodePDFRawStream(obj).decode()).toString("latin1");
|
||||
}
|
||||
if (obj instanceof PDFStream) {
|
||||
try {
|
||||
return Buffer.from(obj.getContents()).toString("latin1");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** hex UTF-16BE (มี BOM นำหน้าหรือไม่ก็ได้) → string */
|
||||
function utf16beHexToStr(hex: string): string {
|
||||
let s = "";
|
||||
for (let i = 0; i + 1 < hex.length; i += 4) {
|
||||
s += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16));
|
||||
}
|
||||
// ตัด BOM (U+FEFF) นำหน้า — ActualText ของ Word/exporter ใส่มาเสมอ
|
||||
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
||||
}
|
||||
|
||||
/** รัน glyph ใต้ ActualText หนึ่งก้อน: hex รวมของ code + ข้อความที่ถูก */
|
||||
type ActualRun = { hex: string; str: string };
|
||||
|
||||
/** สแกน content stream ทุกหน้า → รายการรันต่อ font ("12 0 R") */
|
||||
export function collectActualTextRuns(pdfDoc: PDFDocument): Map<string, ActualRun[]> {
|
||||
const runsByFont = new Map<string, ActualRun[]>();
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const contents = page.node.Contents();
|
||||
let text = "";
|
||||
if (contents instanceof PDFArray) {
|
||||
for (let i = 0; i < contents.size(); i++) {
|
||||
text += `${streamToText(pdfDoc.context.lookup(contents.get(i)))}\n`;
|
||||
}
|
||||
} else if (contents) {
|
||||
text = streamToText(pdfDoc.context.lookup(contents));
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
// font ปัจจุบันตาม /Fx <size> Tf (ชื่อ → ref ใน Resources ของหน้า)
|
||||
const fontByName = new Map<string, unknown>();
|
||||
try {
|
||||
const fontRes = page.node.Resources()?.lookup(PDFName.of("Font"));
|
||||
if (fontRes instanceof PDFDict) {
|
||||
for (const [k, v] of fontRes.entries()) {
|
||||
fontByName.set(k.asString().replace(/^\//, ""), v);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// หน้าไหนไม่มี Resources ที่ใช้ได้ — ข้าม (รันของหน้านั้นไม่ถูกเรียน)
|
||||
}
|
||||
|
||||
let curFont: unknown = null;
|
||||
let pendingActual: string | null = null;
|
||||
const re =
|
||||
/(\/[A-Za-z0-9+.]+)\s+[\d.]+\s+Tf|\/ActualText\s*(?:<([0-9A-Fa-f]*)>|\(((?:[^()\\]|\\.)*)\))|(BDC)|(EMC)|<([0-9A-Fa-f]+)>\s*Tj|\[((?:[^\]]*))\]\s*TJ/g;
|
||||
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
||||
if (m[1] !== undefined) {
|
||||
curFont = fontByName.get(m[1].slice(1)) ?? null;
|
||||
} else if (m[2] !== undefined || m[3] !== undefined) {
|
||||
pendingActual = m[2] !== undefined ? utf16beHexToStr(m[2]) : (m[3] ?? "");
|
||||
} else if (m[5] !== undefined) {
|
||||
pendingActual = null; // EMC ปิด span
|
||||
} else if (pendingActual !== null && curFont !== null && curFont !== undefined) {
|
||||
// <glyphs>Tj หรือ [glyphs…]TJ ใต้ ActualText — รวม hex เป็นรันเดียว
|
||||
const hex =
|
||||
m[6] ?? Array.from((m[7] ?? "").matchAll(/<([0-9A-Fa-f]+)>/g)).map((h) => h[1]).join("");
|
||||
if (hex) {
|
||||
const key = String(curFont);
|
||||
const list = runsByFont.get(key) ?? [];
|
||||
list.push({ hex: hex.toUpperCase(), str: pendingActual });
|
||||
runsByFont.set(key, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return runsByFont;
|
||||
}
|
||||
|
||||
/** แตกคู่ code→ข้อความจาก bfchar/bfrange ของ CMap เดิม (เก็บ mapping ถูกไว้) */
|
||||
function parseCmapEntries(cmapText: string): Map<string, string> {
|
||||
const entries = new Map<string, string>();
|
||||
for (const m of Array.from(cmapText.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g))) {
|
||||
entries.set(m[1].toUpperCase(), utf16beHexToStr(m[2]));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** ความยาว hex ต่อ 1 code ของ font — จาก codespacerange ของ ToUnicode เดิม
|
||||
* (fallback: Type0 = 2 byte, อื่น ๆ 1 byte) */
|
||||
function codeHexLen(fontObj: PDFDict, oldCmapText: string): number {
|
||||
const cs = /begincodespacerange\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/.exec(oldCmapText);
|
||||
if (cs) return cs[1].length;
|
||||
const subtype = fontObj.get(PDFName.of("Subtype"));
|
||||
return String(subtype) === "/Type0" ? 4 : 2;
|
||||
}
|
||||
|
||||
const toUniHex = (s: string): string => {
|
||||
let hex = "";
|
||||
for (const ch of s) hex += ch.charCodeAt(0).toString(16).padStart(4, "0");
|
||||
return hex.toUpperCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* ซ่อม ToUnicode ของทุกฟอนต์ที่เรียนจาก ActualText ได้ — คืน bytes ใหม่
|
||||
* (เสมือนไฟล์เดิมแต่ข้อความถูก) หรือคืน bytes เดิมทั้งก้อนเมื่อไม่มีอะไรต้องแก้ /
|
||||
* ผิดพลาด (fail-safe: หน้า viewer ต้องไม่พังเพราะ PDF แปลก ๆ)
|
||||
*/
|
||||
export async function fixPdfActualText(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
try {
|
||||
const pdfDoc = await PDFDocument.load(bytes, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
});
|
||||
const runsByFont = collectActualTextRuns(pdfDoc);
|
||||
let anyChanged = false;
|
||||
|
||||
for (const [fontKey, runs] of Array.from(runsByFont)) {
|
||||
const refMatch = /(\d+)\s+(\d+)\s+R/.exec(fontKey);
|
||||
if (!refMatch) continue;
|
||||
const fontObj = pdfDoc.context.lookup(
|
||||
PDFRef.of(Number(refMatch[1]), Number(refMatch[2])),
|
||||
);
|
||||
if (!(fontObj instanceof PDFDict)) continue;
|
||||
|
||||
const oldToUni = fontObj.get(PDFName.of("ToUnicode"));
|
||||
const oldText = oldToUni ? streamToText(pdfDoc.context.lookup(oldToUni)) : "";
|
||||
const codeLen = codeHexLen(fontObj, oldText);
|
||||
const merged = new Map(parseCmapEntries(oldText));
|
||||
|
||||
// โหวต code → ข้อความ (รันหลายก้อนอาจให้ความหมายเดียวกัน — เอาเสียงข้างมาก)
|
||||
const votes = new Map<string, Map<string, number>>();
|
||||
for (const { hex, str } of runs) {
|
||||
if (hex.length % codeLen !== 0) continue; // รันยาวไม่ลงตัว = สแกนพลาด ข้าม
|
||||
const codes = hex.match(new RegExp(`.{${codeLen}}`, "g")) ?? [];
|
||||
const chars = Array.from(str);
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const s = chars.length === codes.length ? chars[i] : i === 0 ? str : "";
|
||||
const v = votes.get(codes[i]) ?? new Map<string, number>();
|
||||
v.set(s, (v.get(s) ?? 0) + 1);
|
||||
votes.set(codes[i], v);
|
||||
}
|
||||
}
|
||||
let changed = 0;
|
||||
for (const [code, v] of Array.from(votes)) {
|
||||
const best = Array.from(v.entries()).sort((a, b) => b[1] - a[1])[0][0];
|
||||
if (merged.get(code) !== best) {
|
||||
merged.set(code, best);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (changed === 0) continue;
|
||||
anyChanged = true;
|
||||
|
||||
// เขียน CMap ใหม่ (bfchar ชุดละ ≤100 ตามสเปก) — ไม่ใส่ BOM ในแต่ละ entry
|
||||
const codes = Array.from(merged.keys()).sort();
|
||||
let cmap =
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 1 def\n/CMapName /ActualTextFix def\n" +
|
||||
`1 begincodespacerange\n<${codes[0]}><${codes[codes.length - 1]}>\nendcodespacerange\n`;
|
||||
for (let i = 0; i < codes.length; i += 100) {
|
||||
const chunk = codes.slice(i, i + 100);
|
||||
cmap += `${chunk.length} beginbfchar\n`;
|
||||
for (const c of chunk) cmap += `<${c}> <${toUniHex(merged.get(c) ?? "")}>\n`;
|
||||
cmap += "endbfchar\n";
|
||||
}
|
||||
cmap += "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
|
||||
fontObj.set(PDFName.of("ToUnicode"), pdfDoc.context.register(pdfDoc.context.flateStream(cmap)));
|
||||
}
|
||||
|
||||
if (!anyChanged) return bytes;
|
||||
return await pdfDoc.save();
|
||||
} catch {
|
||||
return bytes; // fail-safe — PDF อะไรก็ตามที่พัง ใช้ไฟล์เดิมเลย
|
||||
}
|
||||
}
|
||||
60
tests/lib/pdf-actual-text.test.ts
Normal file
60
tests/lib/pdf-actual-text.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { PDFDocument, PDFDict, PDFName, PDFRawStream, StandardFonts, decodePDFRawStream } from "pdf-lib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { collectActualTextRuns, fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
|
||||
// ไฟล์คำสั่งศาลปกครองจริง — cmap ของฟอนต์เพี้ยน (สระ/วรรณยุกต์กลายเป็น control char)
|
||||
// แต่ฝัง /ActualText ถูกไว้ใน marked content
|
||||
const SAMPLE = readFileSync(
|
||||
join(process.cwd(), "public", "samples", "court_command_11_123 1.pdf"),
|
||||
);
|
||||
|
||||
describe("collectActualTextRuns — เรียนรู้ glyph จาก ActualText ของไฟล์จริง", () => {
|
||||
it("เจอรันหลักพัน + มีอย่างน้อย 1 ฟอนต์", async () => {
|
||||
const pdfDoc = await PDFDocument.load(SAMPLE, { ignoreEncryption: true });
|
||||
const runs = collectActualTextRuns(pdfDoc);
|
||||
const total = Array.from(runs.values()).reduce((n, list) => n + list.length, 0);
|
||||
expect(runs.size).toBeGreaterThanOrEqual(1);
|
||||
expect(total).toBeGreaterThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fixPdfActualText — ซ่อม ToUnicode บนสำเนาในหน่วยความจำ", () => {
|
||||
it("ไฟล์ cmap เพี้ยน: ได้ bytes ใหม่ ที่ CMap มีสระ/วรรณยุกต์ถูกต้อง", async () => {
|
||||
const out = await fixPdfActualText(new Uint8Array(SAMPLE));
|
||||
expect(Buffer.from(out).equals(SAMPLE)).toBe(false); // มีการแก้จริง
|
||||
|
||||
// โหลดซ้ำ → หา ToUnicode ของทุกฟอนต์ → ต้องมี ู (U+0E39) และ ี (U+0E35) โผล่
|
||||
const doc = await PDFDocument.load(out, { ignoreEncryption: true });
|
||||
let cmapAll = "";
|
||||
for (const page of doc.getPages()) {
|
||||
const fontRes = page.node.Resources()?.lookup(PDFName.of("Font"));
|
||||
if (!(fontRes instanceof PDFDict)) continue;
|
||||
for (const [, ref] of Array.from(fontRes.entries())) {
|
||||
const font = doc.context.lookup(ref);
|
||||
if (!(font instanceof PDFDict)) continue;
|
||||
const toUni = font.get(PDFName.of("ToUnicode"));
|
||||
if (!toUni) continue;
|
||||
const stream = doc.context.lookup(toUni);
|
||||
if (stream instanceof PDFRawStream) {
|
||||
cmapAll += Buffer.from(decodePDFRawStream(stream).decode()).toString("latin1");
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(cmapAll).toMatch(/<0E39>/); // ู
|
||||
expect(cmapAll).toMatch(/<0E35>/); // ี
|
||||
expect(cmapAll).not.toMatch(/<FEFF>/); // ไม่ใส่ BOM ในแต่ละ entry
|
||||
});
|
||||
|
||||
it("PDF ปกติไม่มี ActualText: คืน bytes เดิมทั้งก้อน (ไม่แตะ)", async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
doc.addPage().drawText("hello", { font, size: 12 });
|
||||
const bytes = await doc.save();
|
||||
const out = await fixPdfActualText(bytes);
|
||||
expect(Buffer.from(out).equals(Buffer.from(bytes))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest";
|
|||
import type { AnnotationDTO, AnnotationData, AnnotationType } from "@/db/schema";
|
||||
import {
|
||||
DRAW_PENS,
|
||||
STAMP_FONT_RATIO,
|
||||
STAMP_PRESETS,
|
||||
STAMP_TEXT_WIDTH_RATIO,
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
effectiveDrawOpacity,
|
||||
sanitizePdfText,
|
||||
stampFitFontSize,
|
||||
} from "@/utils/annotation-display";
|
||||
|
||||
const dto = (type: AnnotationType, data: AnnotationData): AnnotationDTO => ({
|
||||
|
|
@ -107,3 +111,65 @@ describe("presets ที่ sidebar/export ใช้ร่วมกัน", () =
|
|||
expect(STAMP_PRESETS).toEqual(["ตรวจสอบแล้ว", "สำคัญ"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampFitFontSize — ย่อฟอนต์ตรายางให้ label บรรทัดเดียว", () => {
|
||||
// measure จำลอง: อักษรฐาน ๆ กว้าง 0.55em (ไม่มี document บนจอ = คืน 0 ก็ผ่านเงื่อนไขพอดี)
|
||||
const measure = (text: string, fontSize: number) =>
|
||||
Array.from(text).filter((ch) => !/[ัิ-ฺ็-๎]/.test(ch)).length *
|
||||
0.55 *
|
||||
fontSize;
|
||||
|
||||
it("ข้อความสั้น (สำคัญ) พอดี → คงขนาด base = STAMP_FONT_RATIO × กรอบ", () => {
|
||||
const w = 144;
|
||||
expect(stampFitFontSize("สำคัญ", w, measure)).toBe(w * STAMP_FONT_RATIO);
|
||||
});
|
||||
|
||||
it("ข้อความยาว (ตรวจสอบแล้ว) ล้นกรอบ → ย่อตามสัดส่วนที่ล้น จนกว้าง = STAMP_TEXT_WIDTH_RATIO × กรอบ", () => {
|
||||
const w = 144;
|
||||
const size = stampFitFontSize("ตรวจสอบแล้ว", w, measure);
|
||||
expect(size).toBeLessThan(w * STAMP_FONT_RATIO);
|
||||
expect(measure("ตรวจสอบแล้ว", size)).toBeCloseTo(w * STAMP_TEXT_WIDTH_RATIO);
|
||||
});
|
||||
|
||||
it("ข้อความว่าง/วัดไม่ได้ (คืน 0) → ใช้ขนาด base", () => {
|
||||
expect(stampFitFontSize("", 100, measure)).toBe(100 * STAMP_FONT_RATIO);
|
||||
expect(stampFitFontSize("ตรวจสอบแล้ว", 100, () => 0)).toBe(100 * STAMP_FONT_RATIO);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("sanitizePdfText — ล้างข้อความเพี้ยนจาก textLayer ของ PDF ไทย (glyph ต่อ glyph)", () => {
|
||||
// control char ขยะที่พบจริงใน textLayer ของคำสั่งศาลปกครอง เช่น 04/05/0e/0f/03/12/1b
|
||||
const ctrl = (n: number) => String.fromCharCode(n);
|
||||
|
||||
it("กลุ่ม control ขยะที่มีช่องว่างล้อมสองข้าง = ตัดทั้งกลุ่ม (เกิดในคำเดียวกัน)", () => {
|
||||
// "คำ"+"สั่"+" "+04+" "+05+" "+"งศาลปกครอง" จาก textLayer จริง
|
||||
expect(sanitizePdfText(`คำสั่ ${ctrl(4)} ${ctrl(5)} งศาลปกครอง`)).toBe("คำสั่งศาลปกครอง");
|
||||
expect(sanitizePdfText(`สั่ ${ctrl(14)} งสั่ ${ctrl(15)} ด`)).toBe("สั่งสั่ด");
|
||||
});
|
||||
|
||||
it("control ขยะที่ช่องว่างติดข้างเดียว = เก็บช่องว่างไว้ (อาจเป็นช่องว่างจริง)", () => {
|
||||
// "ที่"+12+05+" "+"๑๑" — ช่องว่างก่อน ๑๑ เป็นช่องว่างจริงของเอกสาร
|
||||
expect(sanitizePdfText(`ที่${ctrl(18)}${ctrl(5)} ๑๑`)).toBe("ที่ ๑๑");
|
||||
// control เปล่า ๆ ไม่มีช่องว่างแนบ = ตัดทิ้งอย่างเดียว
|
||||
expect(sanitizePdfText(`คำสั่${ctrl(4)}${ctrl(5)}ง`)).toBe("คำสั่ง");
|
||||
});
|
||||
|
||||
it("ยุบช่องว่างซ้ำและเก็บตำแหน่งขึ้นบรรทัดใหม่ (\\n ไม่โดนตัด)", () => {
|
||||
expect(sanitizePdfText("กับลิ้ง ปุจฉา")).toBe("กับลิ้งปุจฉา");
|
||||
expect(sanitizePdfText("บรรทัดหนึ่ง \n บรรทัดสอง")).toBe("บรรทัดหนึ่ง\nบรรทัดสอง");
|
||||
});
|
||||
|
||||
it("heading จริงของคำสั่งศาลปกครอง (space ขยะเป็นชุด + ctrl กลางคำ) → ตัดหมด", () => {
|
||||
// "คำ"+" "×3+"สั่"+04+05+"งศาลปกครอง"+" "×2+"สั่"+0e+"ง"+" "×2+"สั่"+0f+"ด"
|
||||
const heading =
|
||||
`คำ สั่${ctrl(4)}${ctrl(5)}งศาลปกครอง สั่${ctrl(14)}ง สั่${ctrl(15)}ด`;
|
||||
expect(sanitizePdfText(heading)).toBe("คำสั่งศาลปกครองสั่งสั่ด");
|
||||
});
|
||||
|
||||
it("ข้อความปกติไม่มีขยะ = คงเดิม", () => {
|
||||
expect(sanitizePdfText("คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖")).toBe(
|
||||
"คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@ describe("mergeRectsByLine", () => {
|
|||
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 },
|
||||
|
|
|
|||
|
|
@ -244,6 +244,29 @@ describe("scaleAnnotationData", () => {
|
|||
expect(out).toEqual({ position: { x: 50, y: 10 }, message: "m" });
|
||||
});
|
||||
|
||||
it("STAMP anchor มุมตรงข้าม ×2: position ขยายจาก anchor และ scale ×2", () => {
|
||||
// bbox เดิม x 10 y 20 กว้าง 18 สูง 7.2 → anchor = มุมขวาล่าง (28, 27.2)
|
||||
const out = scaleAnnotationData(
|
||||
{ label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 },
|
||||
{ x: 28, y: 27.2 },
|
||||
2,
|
||||
2,
|
||||
);
|
||||
expect(out).toEqual({ label: "สำคัญ", position: { x: -8, y: 12.8 }, scale: 36 });
|
||||
});
|
||||
|
||||
it("STAMP หลังขยาย ×k รอบ anchor: bbox ใหม่ = ขอบเดิม ×k รอบ anchor (กว้าง-สูงผูกกัน)", () => {
|
||||
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 };
|
||||
const anchor = { x: 28, y: 27.2 };
|
||||
const out = scaleAnnotationData(data, anchor, 1.5, 1.5);
|
||||
const before = annotationBBox({ type: "STAMP", data }, S);
|
||||
const after = annotationBBox({ type: "STAMP", data: out }, S);
|
||||
expect(after.width).toBeCloseTo(before.width * 1.5);
|
||||
expect(after.height).toBeCloseTo(before.height * 1.5); // สัดส่วน 0.4 คงเดิม
|
||||
expect(after.x + after.width).toBeCloseTo(anchor.x); // ขอบที่ anchor อยู่นิ่ง
|
||||
expect(after.y + after.height).toBeCloseTo(anchor.y);
|
||||
});
|
||||
|
||||
it("DRAW: strokeWidth คงเดิมตามสเปก F1", () => {
|
||||
const out = scaleAnnotationData(
|
||||
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
|
||||
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมระหว่าง sidebar,
|
||||
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมกันระหว่าง sidebar,
|
||||
// หน้าสรุป (F15/D2) และ export CSV (F16/D3) เพื่อไม่ให้ logic แยกกันคนละที่
|
||||
|
||||
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
|
||||
|
|
@ -40,7 +40,7 @@ export const DRAW_PENS: Record<
|
|||
|
||||
/**
|
||||
* ความทึบสุดท้ายของเส้นวาด — เส้นที่สร้างตอนมีตัวปรับความทึบเก็บ `opacity` ชัดเสมอ
|
||||
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลงไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
|
||||
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
|
||||
* render บนจอกับ export PDF ต้องใช้ค่าจากที่นี่ที่เดียวกัน
|
||||
*/
|
||||
export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): number {
|
||||
|
|
@ -60,8 +60,47 @@ export const STAMP_PRESETS: readonly string[] = [
|
|||
export const STAMP_DEFAULT_SCALE_PCT = 18;
|
||||
/** สีตรายาง (ขอบ + ตัวอักษร) — ใช้ทั้ง render บนจอและ export */
|
||||
export const STAMP_COLOR = "#b91c1c";
|
||||
/** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ */
|
||||
/** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ (ค่าสูงสุด — ย่อลงเมื่อข้อความยาวเกินกรอบ) */
|
||||
export const STAMP_FONT_RATIO = 0.22;
|
||||
/** ความกว้างข้อความสูงสุดที่ยอมให้ = สัดส่วนของกรอบ (เว้นระยะจากขอบใน) */
|
||||
export const STAMP_TEXT_WIDTH_RATIO = 0.9;
|
||||
|
||||
/** ขนาดตัวอักษรตรายางที่ label อยู่บรรทัดเดียว — เริ่มที่ STAMP_FONT_RATIO แล้วย่อตาม
|
||||
* สัดส่วนที่ข้อความล้น (measure ผูกกับเชิงเส้นกับขนาดฟอนต์ วัดครั้งเดียวพอ) — measure เป็น
|
||||
* ความกว้างจริงของฝั่งนั้น ๆ: จอ = canvas measureText, export = font.widthOfTextAtSize
|
||||
* (หากไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดและล้นความสูงกรอบถูกตัดหาย) */
|
||||
export function stampFitFontSize(
|
||||
label: string,
|
||||
boxWidth: number,
|
||||
measure: (text: string, fontSize: number) => number,
|
||||
): number {
|
||||
const base = boxWidth * STAMP_FONT_RATIO;
|
||||
const textW = measure(label, base);
|
||||
const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO;
|
||||
return textW <= maxW ? base : (base * maxW) / textW;
|
||||
}
|
||||
|
||||
// คลาสอักขระขยะที่ปนมากับข้อความสกัดจาก textLayer ของ PDF ไทยบางไฟล์ (สร้างแบบ
|
||||
// glyph ต่อ glyph + cmap ของฟอนต์ผิด) = C0 control ยกเว้น \t \n \r
|
||||
const CTRL_JUNK = "\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f";
|
||||
|
||||
/** ล้างข้อความสกัดจาก textLayer ก่อนเก็บลง DB / ทำชื่อ bookmark / ใส่คลิปบอร์ด:
|
||||
* กลุ่มอักขระที่มี control ขยะคั่นและมีช่องว่างล้อมสองข้าง (" " ctrl " ") = ขยะ
|
||||
* ทั้งกลุ่ม (ตัดหมด — ช่องว่างนั้นเป็น artifact ที่ pdfjs เติมรอบ glyph เพี้ยน)
|
||||
* ส่วนกลุ่มที่ช่องว่างติดข้างเดียว = เก็บช่องว่างไว้ (อาจเป็นช่องว่างจริงระหว่างคำ)
|
||||
* และช่องว่างติดกัน ≥2 ตัว = artifact จาก letterspacing/gap ของ PDF เพี้ยน ตัดทั้งชุด
|
||||
* (ข้อความจริงไม่มีช่องว่างคู่ — ช่องว่างจริงระหว่างวลีเป็นตัวเดียวเสมอ) */
|
||||
export function sanitizePdfText(s: string): string {
|
||||
return s
|
||||
.replace(new RegExp(`[ ${CTRL_JUNK}]*[${CTRL_JUNK}][ ${CTRL_JUNK}]*`, "g"), (m) =>
|
||||
m.startsWith(" ") && m.endsWith(" ") && m.length > 1
|
||||
? ""
|
||||
: m.replace(new RegExp(`[${CTRL_JUNK}]`, "g"), ""),
|
||||
)
|
||||
.replace(/[ \t]{2,}/g, "")
|
||||
.replace(/ ?\n ?/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** AnnotationDTO เป็น flat type (type/data แยกกัน) → แยกตาม type ก่อนแคสต์ data */
|
||||
export function annotationTypeLabel(a: AnnotationDTO): string {
|
||||
|
|
|
|||
|
|
@ -67,8 +67,16 @@ export function mergeRectsByLine(rects: Rect[]): Rect[] {
|
|||
const merged: Rect[] = [];
|
||||
for (const r of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && r.y < last.y + last.height) {
|
||||
// บรรทัดเดียวกัน → ขยายกรอบเดิมให้ครอบ rect นี้ด้วย
|
||||
if (!last) {
|
||||
merged.push({ ...r });
|
||||
continue;
|
||||
}
|
||||
// บรรทัดเดียวกัน = ซ้อนแนวตั้งกัน "เกินครึ่ง" ของความสูงที่เตี้ยกว่า — กล่อง span
|
||||
// ของ textLayer สูงตาม font metrics (ฟอนต์ไทยมีสระบน/ล่าง) จึงชนกันเล็กน้อยระหว่าง
|
||||
// บรรทัดที่ leading แน่นได้ ต้องไม่ถือเป็นบรรทัดเดียวกัน ไม่งั้นมาร์กเกิน/เด้งไปบรรทัดอื่น
|
||||
const overlap = Math.min(last.y + last.height, r.y + r.height) - Math.max(last.y, r.y);
|
||||
if (overlap > Math.min(last.height, r.height) / 2) {
|
||||
// ขยายกรอบเดิมให้ครอบ rect นี้ด้วย
|
||||
const right = Math.max(last.x + last.width, r.x + r.width);
|
||||
last.x = Math.min(last.x, r.x);
|
||||
last.width = right - last.x;
|
||||
|
|
|
|||
|
|
@ -221,6 +221,15 @@ export function scaleAnnotationData(
|
|||
},
|
||||
};
|
||||
}
|
||||
if ("scale" in data) {
|
||||
// STAMP — ขนาดทั้งกว้าง-สูงเก็บใน scale % ตัวเดียว (สัดส่วนตายตัว
|
||||
// STAMP_HEIGHT_RATIO) จึงขยายด้วยตัวคูณเดียว: caller ส่ง sx = sy = k
|
||||
return {
|
||||
...data,
|
||||
position: { x: fx(data.position.x), y: fy(data.position.y) },
|
||||
scale: data.scale * sx,
|
||||
};
|
||||
}
|
||||
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue