Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight, freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo, shapes, redaction, export PDF/CSV, reply, resolve, search, summary, textbox, stamp, version history) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
55
app/api/annotations/export-csv/route.ts
Normal file
55
app/api/annotations/export-csv/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { asc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { annotations, files, toAnnotationDTO } from "@/db/schema";
|
||||
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
|
||||
import { csvField } from "@/utils/csv";
|
||||
import { encodeRFC5987 } from "@/utils/pdf-export";
|
||||
|
||||
// Export สรุปหมายเหตุเป็น CSV (F16/D3) — ข้อมูลเดียวกับหน้าสรุป (F15)
|
||||
// ขึ้นต้นด้วย UTF-8 BOM เพื่อให้ Excel ภาษาไทยอ่านถูกต้อง (RFC 4180: ครอบ field
|
||||
// ด้วย double quote, ขึ้นบรรทัดใหม่ด้วย CRLF)
|
||||
|
||||
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 fileId = new URL(req.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 });
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, fileId))
|
||||
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
|
||||
|
||||
const header = ["หน้า", "ชนิด", "ผู้เขียน", "ข้อความ", "โน้ต", "สร้างเมื่อ"];
|
||||
const lines = rows.map((row) => {
|
||||
const a = toAnnotationDTO(row);
|
||||
return [
|
||||
String(a.pageIndex + 1),
|
||||
annotationTypeLabel(a),
|
||||
a.userName,
|
||||
annotationPreviewText(a),
|
||||
annotationNote(a),
|
||||
new Date(a.createdAt).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }),
|
||||
]
|
||||
.map(csvField)
|
||||
.join(",");
|
||||
});
|
||||
// = UTF-8 BOM — ต้องเป็นตัวแรกสุดของไฟล์เสมอ
|
||||
const csv = `${[header.map(csvField).join(","), ...lines].join("\r\n")}\r\n`;
|
||||
|
||||
const filename = encodeRFC5987(`${file.title}-summary.csv`);
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="summary.csv"; filename*=UTF-8''${filename}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
450
app/api/annotations/export/route.ts
Normal file
450
app/api/annotations/export/route.ts
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import fontkit from "@pdf-lib/fontkit";
|
||||
import { PDFDocument, LineCapStyle, rgb, type PDFFont } from "pdf-lib";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import {
|
||||
annotations,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
type AnnotationType,
|
||||
type CommentData,
|
||||
type DrawData,
|
||||
type HighlightData,
|
||||
type RedactionData,
|
||||
type ShapeData,
|
||||
type StampData,
|
||||
type TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
displayRectToPdf,
|
||||
displaySize,
|
||||
displayToPdfPoint,
|
||||
encodeRFC5987,
|
||||
hexToRgb01,
|
||||
wrapStyledText,
|
||||
wrapTextByWidth,
|
||||
type Rot,
|
||||
type StyledRun,
|
||||
} from "@/utils/pdf-export";
|
||||
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";
|
||||
|
||||
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
|
||||
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
|
||||
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
|
||||
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → COMMENT หมุด
|
||||
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
|
||||
|
||||
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → COMMENT
|
||||
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า และหมุด COMMENT ต้องไม่ถูกกลบ — D4)
|
||||
const TYPE_ORDER: Record<AnnotationType, number> = {
|
||||
HIGHLIGHT: 0,
|
||||
DRAW: 1,
|
||||
SHAPE: 2,
|
||||
REDACTION: 3,
|
||||
COMMENT: 4,
|
||||
TEXTBOX: 5,
|
||||
STAMP: 6,
|
||||
};
|
||||
|
||||
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("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
|
||||
}
|
||||
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
|
||||
}
|
||||
|
||||
const doc = await PDFDocument.load(srcBytes);
|
||||
doc.registerFontkit(fontkit);
|
||||
|
||||
// D5: ฟอนต์ไทย optional — ไม่มีก็ export สำเร็จ (COMMENT เป็นหมุดอย่างเดียว)
|
||||
let thaiFont: PDFFont | null = null;
|
||||
const fontPath = path.join(process.cwd(), "public", SARABUN_REL);
|
||||
if (fs.existsSync(fontPath)) {
|
||||
try {
|
||||
thaiFont = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
|
||||
} catch (err) {
|
||||
console.error("embed Sarabun ไม่สำเร็จ — ไปทาง fallback หมุดอย่างเดียว", err);
|
||||
thaiFont = null;
|
||||
}
|
||||
}
|
||||
// TEXTBOX ตัวหนา — optional เช่นกัน: ไม่มี Sarabun-Bold.ttf ก็ fallback เป็น Regular
|
||||
// (บนจอ font-weight 700 อยู่แล้ว แต่ export ต้องมีไฟล์น้ำหนักหนาจริงจึงตรงกัน)
|
||||
let thaiBoldFont: PDFFont | null = null;
|
||||
const boldFontPath = path.join(process.cwd(), "public", "fonts", "Sarabun-Bold.ttf");
|
||||
if (fs.existsSync(boldFontPath)) {
|
||||
try {
|
||||
thaiBoldFont = await doc.embedFont(fs.readFileSync(boldFontPath), { subset: true });
|
||||
} catch (err) {
|
||||
console.error("embed Sarabun-Bold ไม่สำเร็จ — ตัวหนา fallback เป็น Regular", err);
|
||||
thaiBoldFont = null;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, fileId))
|
||||
.orderBy(asc(annotations.createdAt));
|
||||
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
|
||||
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
|
||||
const dtos = rows
|
||||
.map(toAnnotationDTO)
|
||||
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
||||
|
||||
for (const a of dtos) {
|
||||
const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1));
|
||||
const pageSize = page.getSize();
|
||||
const size: Size = { width: pageSize.width, height: pageSize.height };
|
||||
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
|
||||
const display = displaySize(size, rotation);
|
||||
|
||||
// % ของหน้า (บนจอ) → px บนจอ — ผ่าน utils/coordinates เท่านั้น (invariant #2)
|
||||
const px = toPx;
|
||||
const pxPoint = (p: Point): Point => ({
|
||||
x: px(p.x, display.width),
|
||||
y: px(p.y, display.height),
|
||||
});
|
||||
const pxRect = (r: Rect): Rect => ({
|
||||
x: px(r.x, display.width),
|
||||
y: px(r.y, display.height),
|
||||
width: px(r.width, display.width),
|
||||
height: px(r.height, display.height),
|
||||
});
|
||||
const toPdf = (p: Point) => displayToPdfPoint(p, size, rotation);
|
||||
// strokeWidth เป็น % ของความกว้างหน้า **บนจอ**
|
||||
const thickness = (swPct: number) => px(swPct, display.width);
|
||||
|
||||
// AnnotationDTO เป็น flat type (type/data แยกกัน) → cast ตาม case ที่เช็คแล้ว (pattern เดียวกับ sidebar)
|
||||
switch (a.type) {
|
||||
case "HIGHLIGHT": {
|
||||
const d = a.data as HighlightData;
|
||||
const { r, g, b } = hexToRgb01(d.color);
|
||||
const col = rgb(r, g, b);
|
||||
const st = d.style ?? "highlight";
|
||||
// ความทึบของผู้ใช้ (0–1, ไม่มี = ทึบเต็ม) — ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35
|
||||
// (สอดคล้อง mix-blend-multiply บนจอ), เส้นทุกแบบใช้ตรง ๆ
|
||||
const op = d.opacity ?? 1;
|
||||
for (const rPct of d.rects) {
|
||||
const rect = displayRectToPdf(pxRect(rPct), size, rotation);
|
||||
if (st === "underline") {
|
||||
// เส้นใต้ที่ขอบล่างของบรรทัด (rect.y ของ PDF = ขอบล่างบนจอ)
|
||||
page.drawLine({
|
||||
start: { x: rect.x, y: rect.y },
|
||||
end: { x: rect.x + rect.width, y: rect.y },
|
||||
thickness: 1,
|
||||
color: col,
|
||||
opacity: op,
|
||||
lineCap: LineCapStyle.Round,
|
||||
});
|
||||
} else if (st === "strikethrough") {
|
||||
const y = rect.y + rect.height / 2;
|
||||
page.drawLine({
|
||||
start: { x: rect.x, y },
|
||||
end: { x: rect.x + rect.width, y },
|
||||
thickness: 1,
|
||||
color: col,
|
||||
opacity: op,
|
||||
lineCap: LineCapStyle.Round,
|
||||
});
|
||||
} else if (st === "squiggle") {
|
||||
// เส้นขยุกขยิก — sine เล็ก ๆ ระดับขอบล่าง วาดต่อเป็นท่อนสั้น ๆ
|
||||
let prev = { x: rect.x, y: rect.y + 1.5 };
|
||||
for (let t = 2; t <= rect.width; t += 2) {
|
||||
const p = {
|
||||
x: rect.x + t,
|
||||
y: rect.y + 1.5 * Math.sin((t / 6) * Math.PI * 2),
|
||||
};
|
||||
page.drawLine({
|
||||
start: prev,
|
||||
end: p,
|
||||
thickness: 1,
|
||||
color: col,
|
||||
opacity: op,
|
||||
lineCap: LineCapStyle.Round,
|
||||
});
|
||||
prev = p;
|
||||
}
|
||||
} else {
|
||||
page.drawRectangle({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: col,
|
||||
opacity: 0.35 * op,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "DRAW": {
|
||||
const d = a.data as DrawData;
|
||||
const { r, g, b } = hexToRgb01(d.strokeColor);
|
||||
const t = Math.max(thickness(d.strokeWidth), 0.5);
|
||||
// ความทึบตกลงมาจาก effectiveDrawOpacity (ค่าเดียวกับ render บนจอ) — โปร่งใส
|
||||
// ใช้ lineCap butt กันปลาย segment ซ้อนกันเป็นจุดเข้มกว่าช่วงอื่น
|
||||
const op = effectiveDrawOpacity(d);
|
||||
const pts = d.paths.map((p) => toPdf(pxPoint(p)));
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
page.drawLine({
|
||||
start: pts[i],
|
||||
end: pts[i + 1],
|
||||
thickness: t,
|
||||
color: rgb(r, g, b),
|
||||
opacity: op,
|
||||
lineCap: op < 1 ? LineCapStyle.Butt : LineCapStyle.Round,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "SHAPE": {
|
||||
const d = a.data as ShapeData;
|
||||
const { r, g, b } = hexToRgb01(d.strokeColor);
|
||||
const color = rgb(r, g, b);
|
||||
const t = Math.max(thickness(d.strokeWidth), 0.5);
|
||||
if ("rect" in d) {
|
||||
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
|
||||
if (d.shape === "ellipse") {
|
||||
page.drawEllipse({
|
||||
x: rect.x + rect.width / 2,
|
||||
y: rect.y + rect.height / 2,
|
||||
xScale: Math.max(rect.width / 2, 0.1),
|
||||
yScale: Math.max(rect.height / 2, 0.1),
|
||||
borderColor: color,
|
||||
borderWidth: t,
|
||||
});
|
||||
} else {
|
||||
page.drawRectangle({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
borderColor: color,
|
||||
borderWidth: t,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const p1 = toPdf(pxPoint(d.from));
|
||||
const p2 = toPdf(pxPoint(d.to));
|
||||
page.drawLine({
|
||||
start: p1,
|
||||
end: p2,
|
||||
thickness: t,
|
||||
color,
|
||||
lineCap: LineCapStyle.Round,
|
||||
});
|
||||
if (d.shape === "arrow") {
|
||||
// หัวลูกศร = เส้นสั้น 2 เส้นกลับหลังจากปลาย to (มุม ±π/7 เหมือนบนจอ)
|
||||
const head = Math.max(t * 3.5, 8);
|
||||
const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x);
|
||||
for (const spread of [Math.PI / 7, -Math.PI / 7]) {
|
||||
page.drawLine({
|
||||
start: p2,
|
||||
end: {
|
||||
x: p2.x - head * Math.cos(ang + spread),
|
||||
y: p2.y - head * Math.sin(ang + spread),
|
||||
},
|
||||
thickness: t,
|
||||
color,
|
||||
lineCap: LineCapStyle.Round,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "REDACTION": {
|
||||
// ดำทึบทับจริง — ความปลอดภัยของ redaction อยู่ที่นี่ที่เดียว (D4)
|
||||
const d = a.data as RedactionData;
|
||||
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
|
||||
page.drawRectangle({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "COMMENT": {
|
||||
const d = a.data as CommentData;
|
||||
const pin = toPdf(pxPoint(d.position));
|
||||
page.drawCircle({
|
||||
x: pin.x,
|
||||
y: pin.y,
|
||||
size: 12,
|
||||
color: rgb(254 / 255, 243 / 255, 199 / 255),
|
||||
borderColor: rgb(252 / 255, 211 / 255, 77 / 255),
|
||||
borderWidth: 1,
|
||||
});
|
||||
if (thaiFont) {
|
||||
const font = thaiFont;
|
||||
const fontSize = 9;
|
||||
// FIX-5: ย่อความกว้างกล่องตามหน้าจริง — หน้าแคบกว่า 220pt
|
||||
// เดิมทำให้ x ติดลบจนข้อความหลุดออกนอกขอบซ้ายเงียบ ๆ
|
||||
const maxWidth = Math.min(220, size.width);
|
||||
const lines = wrapTextByWidth(
|
||||
`${a.userName}: ${d.message}`,
|
||||
maxWidth,
|
||||
(s) => font.widthOfTextAtSize(s, fontSize),
|
||||
);
|
||||
// FIX-5 (ต่อ): กันหลุดขอบบน — ข้อความวาด "ขึ้น" จากหมุด (แกน y ของ PDF ชี้ขึ้น)
|
||||
// เมื่อหมุดใกล้ขอบบนหรือ wrap ยาวหลายบรรทัดจนบรรทัดบนสุดเลยขอบหน้า
|
||||
// เลื่อนทั้งก้อนลงมาเท่าที่จำเป็น (ไม่ clamp รายบรรทัด ไม่งั้นบรรทัดซ้อนกัน)
|
||||
const lineH = fontSize + 2;
|
||||
const blockTop = pin.y + 4 + (lines.length - 1) * lineH;
|
||||
const shift = Math.max(0, blockTop + fontSize - size.height);
|
||||
lines.forEach((line, i) => {
|
||||
page.drawText(line, {
|
||||
x: Math.max(0, Math.min(pin.x + 10, size.width - maxWidth)),
|
||||
y: pin.y + 4 + i * lineH - shift,
|
||||
size: fontSize,
|
||||
font,
|
||||
color: rgb(0.2, 0.2, 0.2),
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TEXTBOX": {
|
||||
// F17/B2 — วาดข้อความด้วย Sarabun (D5); ไม่มีฟอนต์ → กรอบแทน ต้องไม่พัง
|
||||
const d = a.data as TextboxData;
|
||||
const pos = toPdf(pxPoint(d.position));
|
||||
const { r, g, b } = hexToRgb01(d.color);
|
||||
if (thaiFont) {
|
||||
const fontSize = Math.max(thickness(d.fontSize), 4);
|
||||
const maxWidth = px(TEXTBOX_MAX_WIDTH_PCT, display.width);
|
||||
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
|
||||
const runs: StyledRun[] = d.segments?.length
|
||||
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
|
||||
: [{ text: d.text, bold: !!d.bold }];
|
||||
const fontFor = (bold: boolean) => (bold && thaiBoldFont ? thaiBoldFont : thaiFont);
|
||||
const widthOf = (s: string, bold: boolean) =>
|
||||
fontFor(bold).widthOfTextAtSize(s, fontSize);
|
||||
const lines = wrapStyledText(runs, maxWidth, widthOf);
|
||||
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
|
||||
const align = d.align ?? "left";
|
||||
lines.forEach((lineRuns, i) => {
|
||||
const lineW = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
|
||||
let x =
|
||||
align === "center"
|
||||
? pos.x + (maxWidth - lineW) / 2
|
||||
: align === "right"
|
||||
? pos.x + (maxWidth - lineW)
|
||||
: pos.x;
|
||||
for (const rn of lineRuns) {
|
||||
page.drawText(rn.text, {
|
||||
x,
|
||||
// position เป็นมุมซ้ายบนบนจอ → เดินลงจากขอบบนทีละบรรทัด (พิกัด PDF แกน y ขึ้น)
|
||||
y: pos.y - fontSize - i * (fontSize * 1.5),
|
||||
size: fontSize,
|
||||
font: fontFor(rn.bold),
|
||||
color: rgb(r, g, b),
|
||||
opacity: d.opacity ?? 1,
|
||||
});
|
||||
x += widthOf(rn.text, rn.bold);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const rect = displayRectToPdf(
|
||||
pxRect({ x: d.position.x, y: d.position.y, width: 40, height: 10 }),
|
||||
size,
|
||||
rotation,
|
||||
);
|
||||
page.drawRectangle({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
borderColor: rgb(r, g, b),
|
||||
borderWidth: 1,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "STAMP": {
|
||||
// F18/B5, D7 — วาดกรอบ+ข้อความ; ไม่มีฟอนต์ → กรอบอย่างเดียว (D5 ต้องไม่พัง)
|
||||
const d = a.data as StampData;
|
||||
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
|
||||
const stampRgb = rgb(r, g, b);
|
||||
const w = px(d.scale, display.width);
|
||||
const rect = displayRectToPdf(
|
||||
{
|
||||
x: px(d.position.x, display.width),
|
||||
y: px(d.position.y, display.height),
|
||||
width: w,
|
||||
height: w * STAMP_HEIGHT_RATIO,
|
||||
},
|
||||
size,
|
||||
rotation,
|
||||
);
|
||||
page.drawRectangle({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
borderColor: stampRgb,
|
||||
borderWidth: Math.max(w * 0.02, 1),
|
||||
});
|
||||
if (thaiFont) {
|
||||
const font = thaiFont;
|
||||
const fontSize = w * STAMP_FONT_RATIO;
|
||||
const lines = wrapTextByWidth(
|
||||
d.label,
|
||||
w * 0.9,
|
||||
(s) => font.widthOfTextAtSize(s, fontSize),
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = await doc.save();
|
||||
const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
|
||||
return new Response(new Uint8Array(out), {
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย
|
||||
"content-disposition": `attachment; filename="annotated.pdf"; filename*=UTF-8''${filename}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
64
app/api/annotations/stream/route.ts
Normal file
64
app/api/annotations/stream/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { subscribeAnnotateEvents } from "@/lib/annotate-events";
|
||||
|
||||
// SSE endpoint (PLAN.md §4): client เปิด EventSource ไปที่นี่เพื่อรับ annotate event
|
||||
// ของไฟล์เดียวกันแบบ realtime — event ถูก publish โดย Server Actions ผ่าน lib/annotate-events
|
||||
|
||||
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 const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request): Promise<Response> {
|
||||
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
|
||||
if (!UUID_RE.test(fileId)) {
|
||||
return new Response("fileId ต้องเป็น UUID", { status: 400 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let cleanup: () => void = () => {};
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const send = (event: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
};
|
||||
send({ type: "connected" });
|
||||
const unsubscribe = subscribeAnnotateEvents(fileId, send);
|
||||
|
||||
// keep-alive กัน connection ถูกตัดเมื่อเงียบนาน (comment line — client ไม่เห็น)
|
||||
const ping = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
// stream ปิดไปแล้ว — cleanup ทำหน้าที่ตัด interval
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
let done = false;
|
||||
cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearInterval(ping);
|
||||
unsubscribe();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// ปิดอยู่แล้ว
|
||||
}
|
||||
};
|
||||
// client ปิดแท็บ/ตัดการเชื่อมต่อ → unsubscribe คืน memory (rules/realtime.md)
|
||||
req.signal.addEventListener("abort", cleanup);
|
||||
},
|
||||
cancel() {
|
||||
cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue