- โหลด 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>
148 lines
8.4 KiB
TypeScript
148 lines
8.4 KiB
TypeScript
import type {
|
|
AnnotationDTO,
|
|
CommentData,
|
|
DrawPen,
|
|
HighlightData,
|
|
HighlightStyle,
|
|
ShapeData,
|
|
StampData,
|
|
TextboxData,
|
|
} from "@/db/schema";
|
|
|
|
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมกันระหว่าง sidebar,
|
|
// หน้าสรุป (F15/D2) และ export CSV (F16/D3) เพื่อไม่ให้ logic แยกกันคนละที่
|
|
|
|
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
|
|
export const HIGHLIGHT_STYLE_LABELS: Record<HighlightStyle, string> = {
|
|
highlight: "ไฮไลต์",
|
|
underline: "เส้นใต้",
|
|
strikethrough: "ขีดฆ่า",
|
|
squiggle: "ขยุกขยิก",
|
|
};
|
|
|
|
/** ชื่อรูปทรงตามชนิด (F5) */
|
|
export const SHAPE_LABELS: Record<ShapeData["shape"], string> = {
|
|
rect: "สี่เหลี่ยม",
|
|
ellipse: "วงรี",
|
|
line: "เส้นตรง",
|
|
arrow: "ลูกศร",
|
|
};
|
|
|
|
// ชนิดปากกาของ tool DRAW — สี/ความหนา (% ของความกว้างหน้า)/ความทึบ ที่ render บนจอ
|
|
// กับ export PDF ใช้ร่วมกัน (เก็บใน DB เฉพาะ `pen` — ค่าอื่นอ่านจากตรงนี้ตอนสร้างเส้น)
|
|
export const DRAW_PENS: Record<
|
|
DrawPen,
|
|
{ label: string; color: string; strokeWidthPct: number; opacity: number }
|
|
> = {
|
|
pencil: { label: "ปากกา", color: "#ef4444", strokeWidthPct: 0.4, opacity: 1 },
|
|
highlighter: { label: "ไฮไลต์", color: "#fde047", strokeWidthPct: 1.2, opacity: 0.45 },
|
|
};
|
|
|
|
/**
|
|
* ความทึบสุดท้ายของเส้นวาด — เส้นที่สร้างตอนมีตัวปรับความทึบเก็บ `opacity` ชัดเสมอ
|
|
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
|
|
* render บนจอกับ export PDF ต้องใช้ค่าจากที่นี่ที่เดียวกัน
|
|
*/
|
|
export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): number {
|
|
return d.opacity ?? DRAW_PENS[d.pen ?? "pencil"].opacity;
|
|
}
|
|
|
|
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
|
|
export const TEXTBOX_DEFAULT_COLOR = "#111827";
|
|
export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 1.2;
|
|
|
|
// STAMP preset (F18/B5, D7 — คงที่ในโค้ด) + ค่าการแสดงผลที่ render กับ export ใช้ร่วมกัน
|
|
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 {
|
|
switch (a.type) {
|
|
case "HIGHLIGHT":
|
|
return HIGHLIGHT_STYLE_LABELS[(a.data as HighlightData).style ?? "highlight"];
|
|
case "DRAW":
|
|
return "เส้นวาด";
|
|
case "COMMENT":
|
|
return "คอมเมนต์";
|
|
case "SHAPE":
|
|
return `รูปทรง (${SHAPE_LABELS[(a.data as ShapeData).shape]})`;
|
|
case "REDACTION":
|
|
return "ปิดพราง";
|
|
case "TEXTBOX":
|
|
return "กล่องข้อความ";
|
|
case "STAMP":
|
|
return "ตรายาง";
|
|
}
|
|
}
|
|
|
|
/** ข้อความตัวอย่างของแต่ละ annotation ตามประเภท */
|
|
export function annotationPreviewText(a: AnnotationDTO): string {
|
|
switch (a.type) {
|
|
case "HIGHLIGHT":
|
|
return (a.data as HighlightData).text || "(ไม่มีข้อความ)";
|
|
case "DRAW":
|
|
return "เส้นวาดอิสระ";
|
|
case "COMMENT":
|
|
return (a.data as CommentData).message;
|
|
case "SHAPE":
|
|
return `รูปทรง: ${SHAPE_LABELS[(a.data as ShapeData).shape]}`;
|
|
case "REDACTION":
|
|
return "ปิดพราง";
|
|
case "TEXTBOX":
|
|
return (a.data as TextboxData).text;
|
|
case "STAMP":
|
|
return (a.data as StampData).label;
|
|
}
|
|
}
|
|
|
|
/** โน้ตคำอธิบายของ annotation เช่น เหตุผลที่วาดรายการนี้ — ว่าง = "" (COMMENT ไม่มี note) */
|
|
export function annotationNote(a: AnnotationDTO): string {
|
|
return "note" in a.data && a.data.note ? a.data.note : "";
|
|
}
|