diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index 4bf1662..40e6e04 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -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()` (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` diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 4c44cfb..3467c15 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -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 ซ้อนสองจังหวะ) diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md index 6c9c008..fcdb359 100644 --- a/.claude/rules/realtime.md +++ b/.claude/rules/realtime.md @@ -10,7 +10,7 @@ paths: - Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=` และ `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 diff --git a/CLAUDE.md b/CLAUDE.md index 8a43225..2631421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 จะทำให้ความหนาเส้นบิดเบี้ยว) diff --git a/app/actions/annotations.ts b/app/actions/annotations.ts index 525bf54..267405b 100644 --- a/app/actions/annotations.ts +++ b/app/actions/annotations.ts @@ -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 { const raw = input as unknown as Record | null | undefined; const fileId = vUuid(raw?.fileId, "fileId"); @@ -394,6 +406,7 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise { .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 { } 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 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; } diff --git a/app/api/annotations/export/route.ts b/app/api/annotations/export/route.ts index aae1eb2..47464d8 100644 --- a/app/api/annotations/export/route.ts +++ b/app/api/annotations/export/route.ts @@ -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,22 +415,21 @@ 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, { - x: rect.x + (rect.width - textW) / 2, - y: rect.y + rect.height - (rect.height - lines.length * lineH) / 2 - lineH * i - fontSize, - size: fontSize, - font, - color: stampRgb, - }); + 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 - lineH) / 2 + lineH - fontSize, + size: fontSize, + font, + color: stampRgb, }); } break; diff --git a/app/api/pdf/route.ts b/app/api/pdf/route.ts new file mode 100644 index 0000000..605fde8 --- /dev/null +++ b/app/api/pdf/route.ts @@ -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", + }, + }); +} diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx index ad40b59..2f2f4bb 100644 --- a/components/pdf/AnnotationSelectionLayer.tsx +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -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), diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index eaa1469..11e6283 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -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( '', )}") 2 17, auto`; -/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS */ +/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS + * (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */ const DRAW_PEN_ICONS: Record = { 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, + ), + )} {/* 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(null); const [draft, setDraft] = useState(""); const [openPinId, setOpenPinId] = useState(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("[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("[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 (
- {/* tool highlight — ซ่อนสี selection น้ำเงินของเบราว์เซอร์ ให้เห็นแต่ preview มาร์กของเรา */} - {tool === "highlight" && ( + {/* tool highlight/select — ซ่อนสี selection ของเบราว์เซอร์ ให้เห็นแต่ preview + มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว สะอาดกว่า native) */} + {(tool === "highlight" || tool === "select") && ( )} {/* 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 ตาม diff --git a/components/pdf/TextboxStampLayer.tsx b/components/pdf/TextboxStampLayer.tsx index 1a0dc4d..c118433 100644 --- a/components/pdf/TextboxStampLayer.tsx +++ b/components/pdf/TextboxStampLayer.tsx @@ -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 + +
) */ 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", }} diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 81c50c0..c843653 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -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" | ` + 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) diff --git a/lib/pdf-actual-text.ts b/lib/pdf-actual-text.ts new file mode 100644 index 0000000..431c9da --- /dev/null +++ b/lib/pdf-actual-text.ts @@ -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 { + const runsByFont = new Map(); + + 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 Tf (ชื่อ → ref ใน Resources ของหน้า) + const fontByName = new Map(); + 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) { + // 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 { + const entries = new Map(); + 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 { + 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>(); + 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(); + 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 อะไรก็ตามที่พัง ใช้ไฟล์เดิมเลย + } +} diff --git a/tests/lib/pdf-actual-text.test.ts b/tests/lib/pdf-actual-text.test.ts new file mode 100644 index 0000000..b89a43c --- /dev/null +++ b/tests/lib/pdf-actual-text.test.ts @@ -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(//); // ไม่ใส่ 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); + }); +}); diff --git a/tests/utils/annotation-display.test.ts b/tests/utils/annotation-display.test.ts index 8fa6b02..d3a6990 100644 --- a/tests/utils/annotation-display.test.ts +++ b/tests/utils/annotation-display.test.ts @@ -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( + "คำสั่งศาลปกครองสูงสุด คดีหมายเลขดำที่ อ. ๔๕๖/๒๕๖๖", + ); + }); +}); diff --git a/tests/utils/coordinates.test.ts b/tests/utils/coordinates.test.ts index 43d75f2..b445d8a 100644 --- a/tests/utils/coordinates.test.ts +++ b/tests/utils/coordinates.test.ts @@ -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 }, diff --git a/tests/utils/geometry.test.ts b/tests/utils/geometry.test.ts index f6c2569..74416fd 100644 --- a/tests/utils/geometry.test.ts +++ b/tests/utils/geometry.test.ts @@ -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 }] }, diff --git a/utils/annotation-display.ts b/utils/annotation-display.ts index 3925f87..6fee9e4 100644 --- a/utils/annotation-display.ts +++ b/utils/annotation-display.ts @@ -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 { diff --git a/utils/coordinates.ts b/utils/coordinates.ts index 4a2693d..1aced16 100644 --- a/utils/coordinates.ts +++ b/utils/coordinates.ts @@ -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; diff --git a/utils/geometry.ts b/utils/geometry.ts index 768432e..56db47a 100644 --- a/utils/geometry.ts +++ b/utils/geometry.ts @@ -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) } }; }