ซ่อม 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
|
|
@ -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,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;
|
||||
|
|
|
|||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue