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 = { highlight: "ไฮไลต์ข้อความ", underline: "เส้นใต้", strikethrough: "ขีดฆ่า", squiggle: "ขยุกขยิก", }; /** ชื่อรูปทรงตามชนิด (F5) */ export const SHAPE_LABELS: Record = { 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"; // M2: 2.5% ≈ 15px ที่หน้า A4 (595pt, scale 1) — อ่านออกทันทีที่พิมพ์เสร็จ (เดิม 1.2% ≈ 7px // เล็กกว่า preset ต่ำสุด 10px ของ NumberCombo ด้วยซ้ำ) / กระทบเฉพาะกล่องที่สร้างใหม่เท่านั้น export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 2.5; // 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 : ""; }