refactor(routes): move routes under /note, APIs under /api/note
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b123ecc9b2
commit
8cc15ea75d
52 changed files with 174 additions and 148 deletions
720
app/api/note/annotations/export/route.ts
Normal file
720
app/api/note/annotations/export/route.ts
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import fontkit from "@pdf-lib/fontkit";
|
||||
import {
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFString,
|
||||
LineCapStyle,
|
||||
degrees,
|
||||
rgb,
|
||||
type PDFFont,
|
||||
type PDFDict,
|
||||
type PDFPage,
|
||||
type PDFRef,
|
||||
} from "pdf-lib";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
type AnnotationType,
|
||||
type BookmarkRow,
|
||||
type CommentData,
|
||||
type DrawData,
|
||||
type HighlightData,
|
||||
type RedactionData,
|
||||
type ReplyRow,
|
||||
type ShapeData,
|
||||
type StampData,
|
||||
type TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
displayRectToPdf,
|
||||
displaySize,
|
||||
displayToPdfPoint,
|
||||
encodeRFC5987,
|
||||
hexToRgb01,
|
||||
wrapStyledText,
|
||||
type Rot,
|
||||
type StyledRun,
|
||||
} from "@/utils/pdf-export";
|
||||
import type { AnnotationDTO, Rect } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { fileKindFromUrl } from "@/utils/file-kind";
|
||||
import { imageDocFromBytes } from "@/utils/image-to-pdf";
|
||||
import {
|
||||
annotationBBox,
|
||||
rotatePoint,
|
||||
stampBoxHeight,
|
||||
stampLayout,
|
||||
textboxMaxWidthPct,
|
||||
} from "@/utils/geometry";
|
||||
import {
|
||||
STAMP_COLOR,
|
||||
annotationNote,
|
||||
effectiveDrawOpacity,
|
||||
} from "@/utils/annotation-display";
|
||||
|
||||
// Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3)
|
||||
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
|
||||
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
|
||||
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → TEXTBOX/STAMP
|
||||
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
|
||||
// - COMMENT เป็น sticky note จริงของ PDF (/Subtype /Text — คลิกหมุดที่ reader เปิด
|
||||
// popup อ่าน message + replies), bookmarks เป็น PDF outline (แผง Bookmarks)
|
||||
|
||||
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 → TEXTBOX → STAMP
|
||||
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า — D4) — COMMENT ไม่เข้าลำดับนี้แล้ว:
|
||||
// เป็น annotation object ที่ reader วาดเหนือ content เสมอ จึงไม่มีปัญหาถูกกลบ
|
||||
const TYPE_ORDER: Record<AnnotationType, number> = {
|
||||
HIGHLIGHT: 0,
|
||||
DRAW: 1,
|
||||
SHAPE: 2,
|
||||
REDACTION: 3,
|
||||
COMMENT: 4,
|
||||
TEXTBOX: 5,
|
||||
STAMP: 6,
|
||||
};
|
||||
|
||||
/** ISO string → PDF date string (D:YYYYMMDDHHmmSSZ) สำหรับ /M ของ annotation */
|
||||
function toPdfDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `D:${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* โน้ตคำอธิบาย (N1) ของ annotation → sticky note เล็กแนบมุมขวาบนของ bbox —
|
||||
* Chrome/Edge แสดง tooltip จาก /Contents ตอน hover, Acrobat คลิกเปิด popup
|
||||
* (แทน hover tooltip บนเว็บที่ flatten ลงหน้าแล้วอ่านไม่ได้)
|
||||
*/
|
||||
function attachNoteSticky(
|
||||
doc: PDFDocument,
|
||||
page: PDFPage,
|
||||
a: AnnotationDTO,
|
||||
note: string,
|
||||
anchor: Point, // มุมขวาบนของ bbox ในพิกัด PDF (pt)
|
||||
): void {
|
||||
const annot = doc.context.obj({
|
||||
Type: "Annot",
|
||||
Subtype: "Text",
|
||||
// หมุดเล็ก 16pt ห้อยลงจากจุดยึด (แกน y ของ PDF ชี้ขึ้น)
|
||||
Rect: [anchor.x, anchor.y - 16, anchor.x + 16, anchor.y],
|
||||
Contents: PDFHexString.fromText(note),
|
||||
T: PDFHexString.fromText(a.userName),
|
||||
C: [254 / 255, 243 / 255, 199 / 255],
|
||||
M: PDFString.of(toPdfDate(a.updatedAt)),
|
||||
F: 4, // Print — พิมพ์แล้ว icon ติดไปด้วย
|
||||
});
|
||||
page.node.addAnnot(doc.context.register(annot));
|
||||
}
|
||||
|
||||
/**
|
||||
* ฝัง bookmarks เป็น PDF outline (แผง Bookmarks ของ reader กดกระโดดได้) — โครงสร้าง
|
||||
* ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตามลำดับหน้าเหมือน sidebar (N5 —
|
||||
* ลำดับมาจาก query ด้านผู้เรียก คง relative order ไว้ทั้ง root และ children ในแม่เดียวกัน) แต่ละรายการ
|
||||
* /Dest ชี้หน้า + ตำแหน่ง yOffset — pdf-lib ไม่มี API ระดับสูงสำหรับ outline จึงต่อ
|
||||
* PDFDict เอง (Title เป็น PDFHexString UTF-16BE รองรับภาษาไทย)
|
||||
*/
|
||||
function buildOutline(doc: PDFDocument, rows: BookmarkRow[]): void {
|
||||
if (rows.length === 0) return;
|
||||
const ctx = doc.context;
|
||||
const ids = new Set(rows.map((b) => b.id));
|
||||
const childrenOf = new Map<string, BookmarkRow[]>();
|
||||
const roots: BookmarkRow[] = [];
|
||||
for (const b of rows) {
|
||||
// parent หาย (ความครบถ้วนของข้อมูล) → ผลักขึ้น root เหมือนที่ sidebar ทำ
|
||||
if (b.parentId && ids.has(b.parentId)) {
|
||||
const list = childrenOf.get(b.parentId) ?? [];
|
||||
list.push(b);
|
||||
childrenOf.set(b.parentId, list);
|
||||
} else {
|
||||
roots.push(b);
|
||||
}
|
||||
}
|
||||
// Major 2 (code review): ข้อมูลวนซ้ำไร้ root (A แม่ของ B, B แม่ของ A — action/sidebar กัน
|
||||
// ตอนเขียนแล้ว แต่กันเคส DB ถูกแก้ตรง ๆ) roots จะว่างและ kids[0] พังทั้ง export (500) —
|
||||
// ปล่อยเป็นไฟล์ไม่มี outline เงียบ ๆ ดีกว่าไฟล์พังทั้งใบ
|
||||
if (roots.length === 0) return;
|
||||
|
||||
const buildNode = (
|
||||
b: BookmarkRow,
|
||||
parentRef: PDFRef,
|
||||
): { ref: PDFRef; dict: PDFDict; count: number } => {
|
||||
const ref = ctx.nextRef();
|
||||
const kids = (childrenOf.get(b.id) ?? []).map((k) => buildNode(k, ref));
|
||||
const page = doc.getPage(
|
||||
Math.min(Math.max(b.pageIndex, 0), doc.getPageCount() - 1),
|
||||
);
|
||||
const size = page.getSize();
|
||||
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
|
||||
const display = displaySize(size, rotation);
|
||||
// yOffset (% ของความสูงหน้าบนจอ) → พิกัด PDF ทางเดียวกับ annotation (invariant #2)
|
||||
// นิยาม yOffset = "จุดจริงบนหน้า" (ขอบบนของสิ่งที่ดูอยู่ตอนสร้าง) และในเว็บกดแล้ว
|
||||
// จุดถูกดันชิดใต้ header — dest /XYZ ของ PDF ก็วางจุดยึดติดขอบบน viewport เหมือนกัน
|
||||
// จึงใช้ตำแหน่งเป๊ะ ๆ ไม่ต้องชดเชย (ก่อนหน้านี้ yOffset เป็น "กึ่งกลางจอตอนสร้าง"
|
||||
// จึงเคยต้องเลื่อนไถออก 25% ของความสูงหน้า)
|
||||
const dest = displayToPdfPoint(
|
||||
{ x: 0, y: toPx(b.yOffset, display.height) },
|
||||
size,
|
||||
rotation,
|
||||
);
|
||||
const count = kids.reduce((sum, k) => sum + 1 + k.count, 0);
|
||||
const dict = ctx.obj({
|
||||
Title: PDFHexString.fromText(b.title),
|
||||
Parent: parentRef,
|
||||
// /XYZ = กระโดดไปตำแหน่งนี้โดยคง zoom ของผู้อ่าน (null)
|
||||
Dest: [page.ref, PDFName.of("XYZ"), dest.x, dest.y, null],
|
||||
...(kids.length > 0
|
||||
? { First: kids[0].ref, Last: kids[kids.length - 1].ref, Count: count }
|
||||
: {}),
|
||||
});
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
if (i > 0) kids[i].dict.set(PDFName.of("Prev"), kids[i - 1].ref);
|
||||
if (i < kids.length - 1)
|
||||
kids[i].dict.set(PDFName.of("Next"), kids[i + 1].ref);
|
||||
}
|
||||
ctx.assign(ref, dict);
|
||||
return { ref, dict, count };
|
||||
};
|
||||
|
||||
const rootRef = ctx.nextRef();
|
||||
const kids = roots.map((r) => buildNode(r, rootRef));
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
if (i > 0) kids[i].dict.set(PDFName.of("Prev"), kids[i - 1].ref);
|
||||
if (i < kids.length - 1) kids[i].dict.set(PDFName.of("Next"), kids[i + 1].ref);
|
||||
}
|
||||
ctx.assign(
|
||||
rootRef,
|
||||
ctx.obj({
|
||||
Type: "Outlines",
|
||||
First: kids[0].ref,
|
||||
Last: kids[kids.length - 1].ref,
|
||||
// Count เป็นบวก = แผง outline เปิดทั้งหมดตอนเปิดไฟล์
|
||||
Count: kids.reduce((sum, k) => sum + 1 + k.count, 0),
|
||||
}),
|
||||
);
|
||||
doc.catalog.set(PDFName.of("Outlines"), rootRef);
|
||||
}
|
||||
|
||||
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)
|
||||
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
|
||||
let srcBytes: Buffer;
|
||||
if (isRemoteSource(file.pdfUrl)) {
|
||||
try {
|
||||
srcBytes = await fetchSourceBytes(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 });
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) {
|
||||
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
|
||||
}
|
||||
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// N3: ไฟล์รูปภาพ — สร้าง PDF ใหม่ที่ฝังรูปเต็มหน้า (embedJpg/embedPng + EXIF rotate)
|
||||
// แล้ววาด annotation ต่อด้วย pipeline เดิมทั้งหมด (rotation=0, page 595×สัดส่วนรูป
|
||||
// ตรง scale contract ฝั่ง viewer → พิกัด % ตรงกันทั้งจอและ export)
|
||||
// ไฟล์เสีย (ผ่าน magic bytes ตอนอัปโหลดแต่เนื้อข้างในพัง) → ข้อความชัด ๆ แทน 500 เปล่า
|
||||
let doc: Awaited<ReturnType<typeof PDFDocument.load>>;
|
||||
try {
|
||||
doc =
|
||||
fileKindFromUrl(file.pdfUrl) === "image"
|
||||
? await imageDocFromBytes(srcBytes, file.pdfUrl)
|
||||
: await PDFDocument.load(srcBytes);
|
||||
} catch (err) {
|
||||
console.error("สร้างเอกสารสำหรับ export ไม่สำเร็จ", err);
|
||||
return new Response("ไฟล์ต้นฉบับเสียหาย สร้างเอกสาร export ไม่ได้", { status: 500 });
|
||||
}
|
||||
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));
|
||||
// F4: export ตาม filter รายคนที่กำลังดู (?user=<userId> จาก sidebar — ไม่ส่ง = ทั้งหมด)
|
||||
// REDACTION ติดไปเสมอแม้กรองคนอื่น (D4 — กล่องดำหายไป = เนื้อหาที่ปิดกั้นกลับมาอ่านได้)
|
||||
const userParam = url.searchParams.get("user");
|
||||
const filteredRows = userParam
|
||||
? rows.filter((r) => r.userId === userParam || r.type === "REDACTION")
|
||||
: rows;
|
||||
// ชื่อผู้ใช้เติมท้ายชื่อไฟล์ (mirror ในตาราง — ไม่รับ text จาก client) — กรองแล้ว
|
||||
// ผู้นั้นยังไม่มีรายการเลย = ใช้ userId ตรง ๆ
|
||||
const filterName = userParam
|
||||
? (rows.find((r) => r.userId === userParam && r.userName)?.userName ??
|
||||
userParam)
|
||||
: null;
|
||||
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
|
||||
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
|
||||
const dtos = filteredRows
|
||||
.map(toAnnotationDTO)
|
||||
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
||||
|
||||
// replies ของ COMMENT สำหรับฝังลง popup ของ sticky note (F9) — query เฉพาะเมื่อมี COMMENT
|
||||
const repliesByAnnotation = new Map<string, ReplyRow[]>();
|
||||
const commentIds = dtos.filter((a) => a.type === "COMMENT").map((a) => a.id);
|
||||
if (commentIds.length > 0) {
|
||||
const replyRows = await db
|
||||
.select()
|
||||
.from(annotationReplies)
|
||||
.where(inArray(annotationReplies.annotationId, commentIds))
|
||||
.orderBy(asc(annotationReplies.createdAt));
|
||||
for (const r of replyRows) {
|
||||
const list = repliesByAnnotation.get(r.annotationId) ?? [];
|
||||
list.push(r);
|
||||
repliesByAnnotation.set(r.annotationId, list);
|
||||
}
|
||||
}
|
||||
|
||||
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": {
|
||||
// sticky note จริงของ PDF (/Subtype /Text) — คลิกหมุดที่ reader แล้วเปิด popup
|
||||
// อ่าน message + replies (F9); ไม่ต้องมี /AP เพราะ reader วาด icon เอง
|
||||
const d = a.data as CommentData;
|
||||
const pin = toPdf(pxPoint(d.position));
|
||||
const parts = [d.message];
|
||||
for (const r of repliesByAnnotation.get(a.id) ?? []) {
|
||||
parts.push(`— ${r.userName}: ${r.message}`);
|
||||
}
|
||||
const annot = doc.context.obj({
|
||||
Type: "Annot",
|
||||
Subtype: "Text",
|
||||
// หมุด icon 20pt ห้อยลงจากจุด position (แกน y ของ PDF ชี้ขึ้น)
|
||||
Rect: [pin.x, pin.y - 20, pin.x + 20, pin.y],
|
||||
Contents: PDFHexString.fromText(parts.join("\n")),
|
||||
T: PDFHexString.fromText(a.userName),
|
||||
// สีเดียวกับหมุดบนจอ (เหลืองอ่อน) — reader เอาไปทา icon
|
||||
C: [254 / 255, 243 / 255, 199 / 255],
|
||||
M: PDFString.of(toPdfDate(a.updatedAt)),
|
||||
F: 4, // Print — พิมพ์แล้ว icon ติดไปด้วย
|
||||
});
|
||||
page.node.addAnnot(doc.context.register(annot));
|
||||
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);
|
||||
// clamp ขอบขวาแบบเดียวกับบนจอ (textboxMaxWidthPct) — ไม่งั้นกล่อง
|
||||
// ที่ position.x สูง (ครึ่งขวาของหน้า) ข้อความทุกบรรทัดทะลุขอบหน้ากระดาษ
|
||||
// กล่อง width คงที่ (ถ้ามี) wrap ที่ความกว้างนั้น (บีบด้วย cap เหมือน render จอ)
|
||||
const capPct = textboxMaxWidthPct(d.position.x);
|
||||
const maxWidth = Math.max(
|
||||
px(Math.min(d.width ?? capPct, capPct), display.width),
|
||||
10,
|
||||
);
|
||||
// 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);
|
||||
// กล่องห้อยจากมุมซ้ายบน (pos) เหมือน render จอ — align จัดบรรทัด*ใน*กล่องอย่างเดียว
|
||||
// (แบบ Smallpdf กล่องไม่ขยับ): กล่อง width คงที่กว้างเต็ม maxWidth ตามที่กำหนด
|
||||
// ส่วนแถวเดิม shrink-to-fit กว้างเท่าบรรทัดยาวสุด (ไม่เกิน maxWidth)
|
||||
const align = d.align ?? "left";
|
||||
const lineWOf = (lineRuns: StyledRun[]) =>
|
||||
lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
|
||||
const boxW =
|
||||
d.width !== undefined
|
||||
? maxWidth
|
||||
: Math.max(Math.min(...lines.map(lineWOf), maxWidth), 1);
|
||||
const boxLeft = pos.x;
|
||||
lines.forEach((lineRuns, i) => {
|
||||
const lineW = lineWOf(lineRuns);
|
||||
let x =
|
||||
align === "center"
|
||||
? boxLeft + (boxW - lineW) / 2
|
||||
: align === "right"
|
||||
? boxLeft + (boxW - lineW)
|
||||
: boxLeft;
|
||||
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);
|
||||
// จัดวางข้อความแบบเดียวกับบนจอ (stampLayout): คำสั้น 1 บรรทัด, คำยาวพอดี ๆ
|
||||
// ย่อฟอนต์ให้บรรทัดเดียวก่อน (จนถึง floor 10%), ยาวเกินค่อย wrap หลายบรรทัด
|
||||
// กรอบสูงขึ้นตาม — measure ของฝั่งนี้คือ pdf-lib font (2026-09-28)
|
||||
const lay = thaiFont
|
||||
? stampLayout(d.label, w, (s, size) => thaiFont.widthOfTextAtSize(s, size))
|
||||
: null;
|
||||
const rect = displayRectToPdf(
|
||||
{
|
||||
x: px(d.position.x, display.width),
|
||||
y: px(d.position.y, display.height),
|
||||
width: w,
|
||||
height: lay ? stampBoxHeight(lay, w) : w * 0.4,
|
||||
},
|
||||
size,
|
||||
rotation,
|
||||
);
|
||||
// มุมเอียงของตรายาง: จอหมุนรอบ**กลางกรอบ** ส่วน pdf-lib หมุนรอบ anchor มุม
|
||||
// ล่างซ้ายของ object — จึงต้อง (1) ใช้มุมลบ (แกน y ของ PDF ชี้ขึ้น ส่วน CSS
|
||||
// rotate วัดบนจอที่ y ชี้ลง) และ (2) เลื่อน anchor ไปตำแหน่งมุมล่างซ้าย*หลัง
|
||||
// หมุน* ให้ pdf-lib หมุนต่อรอบมัน ผลลัพธ์กลับมาตรงตำแหน่งเดียวกับบนจอ
|
||||
const rot = d.rotation ?? 0;
|
||||
const pdfRot = -rot;
|
||||
const ctr = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
const anchor = rot
|
||||
? rotatePoint({ x: rect.x, y: rect.y }, ctr, pdfRot)
|
||||
: { x: rect.x, y: rect.y };
|
||||
const rotateOpt = rot ? { rotate: degrees(pdfRot) } : undefined;
|
||||
page.drawRectangle({
|
||||
x: anchor.x,
|
||||
y: anchor.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
borderColor: stampRgb,
|
||||
borderWidth: Math.max(w * 0.02, 1),
|
||||
...rotateOpt,
|
||||
});
|
||||
if (lay && thaiFont) {
|
||||
const font = thaiFont;
|
||||
// วาดทีละบรรทัดจากกึ่งกลางแนวตั้งของกรอบ (pdf y ชี้ขึ้นจากล่าง) — จุดวาด
|
||||
// คำนวณในพิกัดกรอบ*ก่อนหมุน* แล้วหมุนรอบกลางกรอบเหมือนจอก่อนส่ง drawText
|
||||
const lineStep = lay.fontSize * lay.lineHeight;
|
||||
const totalH = lay.lines.length * lineStep;
|
||||
let baseline = rect.y + rect.height / 2 + totalH / 2 - lay.fontSize;
|
||||
for (const ln of lay.lines) {
|
||||
const textW = font.widthOfTextAtSize(ln, lay.fontSize);
|
||||
const tx = rect.x + (rect.width - textW) / 2;
|
||||
const p = rot
|
||||
? rotatePoint({ x: tx, y: baseline }, ctr, pdfRot)
|
||||
: { x: tx, y: baseline };
|
||||
page.drawText(ln, {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
size: lay.fontSize,
|
||||
font,
|
||||
color: stampRgb,
|
||||
...rotateOpt,
|
||||
});
|
||||
baseline -= lineStep;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// โน้ตคำอธิบาย (N1) → sticky note แนบมุมขวาบนของ bbox — COMMENT ไม่มี note
|
||||
// (มี message อยู่ใน popup ของตัวเองแล้ว)
|
||||
if (a.type !== "COMMENT") {
|
||||
const note = annotationNote(a);
|
||||
if (note) {
|
||||
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF — ส่ง
|
||||
// measure ของ pdf-lib font เดียวกับตอนวาดกรอบ STAMP ด้วย ให้มุมเกาะ sticky
|
||||
// note ตรงกรอบที่วาดจริง (ไม่ส่ง = stampLayoutEstimate คลาดจากกรอบจริงเล็กน้อย
|
||||
// — code review 2026-09-29)
|
||||
const bbox = annotationBBox(
|
||||
a,
|
||||
display,
|
||||
thaiFont ? (s, size) => thaiFont.widthOfTextAtSize(s, size) : undefined,
|
||||
);
|
||||
attachNoteSticky(
|
||||
doc,
|
||||
page,
|
||||
a,
|
||||
note,
|
||||
toPdf({ x: bbox.x + bbox.width, y: bbox.y }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
|
||||
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
|
||||
// กรองตามรายคนเหมือน annotation (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่
|
||||
// ลูกขึ้น root (buildOutline จัดการ parent หายเคสนี้อยู่แล้ว)
|
||||
const bookmarkRows = await db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(
|
||||
userParam
|
||||
? and(eq(bookmarks.fileId, fileId), eq(bookmarks.userId, userParam))
|
||||
: eq(bookmarks.fileId, fileId),
|
||||
)
|
||||
.orderBy(
|
||||
asc(bookmarks.pageIndex),
|
||||
asc(bookmarks.yOffset),
|
||||
asc(bookmarks.createdAt),
|
||||
);
|
||||
buildOutline(doc, bookmarkRows);
|
||||
|
||||
const out = await doc.save();
|
||||
const filename = encodeRFC5987(
|
||||
`${file.title}-annotated${filterName ? `-${filterName}` : ""}.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/note/annotations/stream/route.ts
Normal file
64
app/api/note/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",
|
||||
},
|
||||
});
|
||||
}
|
||||
98
app/api/note/external/documents/route.ts
vendored
Normal file
98
app/api/note/external/documents/route.ts
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||
|
||||
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
|
||||
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
|
||||
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
|
||||
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
|
||||
// ระดับเดียวกับ annotations/bookmarks): ถาม fileId = ยาว 1 ชิ้น (ไม่เคยเปิด
|
||||
// ในระบบ = 404 — ไฟล์ url: fallback ของ N8 ค้นด้วย ecourt: prefix ไม่ได้ =
|
||||
// 404 ตามสัญญาลิงก์), ถาม typeId = ทุกไฟล์ในกลุ่ม N9 (กลุ่มว่าง = documents: []),
|
||||
// ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (2026-09-25 —
|
||||
// รอบเดียวกันเคยเป็น OR รวมผลแล้วเปลี่ยนหลังทดสอบจริง: ผู้เรียกต้องการเอกสาร
|
||||
// เดียวตามที่ส่งมา ไม่ใช่ทั้งกลุ่ม)
|
||||
// read-only: ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก
|
||||
|
||||
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
||||
|
||||
/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้
|
||||
* 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto /
|
||||
* x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย
|
||||
* fallback จาก host/protocol ของ request ตรง ๆ */
|
||||
function requestOrigin(req: Request): string {
|
||||
const url = new URL(req.url);
|
||||
const proto =
|
||||
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
|
||||
url.protocol.replace(":", "");
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
|
||||
req.headers.get("host") ??
|
||||
url.host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const unauthorized = requireExternalApiKey(req);
|
||||
if (unauthorized) return unauthorized;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return externalApiError("body ต้องเป็น JSON ที่ถูกต้อง", 400);
|
||||
}
|
||||
|
||||
const query = parseQueryBody(body);
|
||||
if (!query.ok) return externalApiError(query.error, 400);
|
||||
|
||||
const origin = requestOrigin(req);
|
||||
|
||||
// ครอบทุก DB call (code review 2026-09-25) — ล้มไม่ว่าเหตุใด (connection
|
||||
// หลุด/timeout/table มีปัญหา) ต้องได้ 500 JSON ตาม contract เดียวกับ error
|
||||
// อื่นของ endpoint นี้ ไม่ใช่ 500 ของ framework ที่ format ต่างออกไป; log
|
||||
// รายละเอียดฝั่ง server อย่างเดียว — ข้อความตอบกลาง ๆ กัน leak SQL/path
|
||||
try {
|
||||
if (query.mode === "file") {
|
||||
// ส่งทั้งคู่ = AND กรองทั้ง externalId และกลุ่มใน query เดียว เหลือเอกสารเดียว
|
||||
// (external_id unique — match ได้มากสุด 1 ฉบับ) ไม่ตรง = 404 โดยแยกสาเหตุ:
|
||||
// ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ
|
||||
const [file] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
query.typeId
|
||||
? and(
|
||||
eq(files.externalId, `ecourt:${query.fileId}`),
|
||||
eq(files.typeId, query.typeId),
|
||||
)
|
||||
: eq(files.externalId, `ecourt:${query.fileId}`),
|
||||
)
|
||||
.limit(1);
|
||||
if (!file) {
|
||||
return externalApiError(
|
||||
query.typeId
|
||||
? "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด หรือไม่ได้อยู่ในกลุ่ม typeId ที่ระบุ)"
|
||||
: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)",
|
||||
404,
|
||||
);
|
||||
}
|
||||
const documents = await loadDocumentBundles([file], query.parts, origin);
|
||||
return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS });
|
||||
}
|
||||
|
||||
const fileRows = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.typeId, query.typeId))
|
||||
.orderBy(asc(files.createdAt));
|
||||
const documents = await loadDocumentBundles(fileRows, query.parts, origin);
|
||||
return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS });
|
||||
} catch (err) {
|
||||
console.error("external/documents: query ฐานข้อมูลล้ม", err);
|
||||
return externalApiError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
|
||||
}
|
||||
}
|
||||
67
app/api/note/pdf/route.ts
Normal file
67
app/api/note/pdf/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind";
|
||||
|
||||
// ให้ 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)
|
||||
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
|
||||
let srcBytes: Buffer;
|
||||
if (isRemoteSource(file.pdfUrl)) {
|
||||
try {
|
||||
srcBytes = await fetchSourceBytes(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof RemoteFileError) return new Response(err.message, { status: 502 });
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
srcBytes = readPublicPdf(file.pdfUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) return new Response(err.message, { status: 400 });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// N3: ไฟล์รูปภาพไม่มี ToUnicode ให้ซ่อม — คืน bytes ดิบ + content-type ถูกต้อง
|
||||
// (viewer โหลดรูปจาก static pdfUrl ตรง ๆ อยู่แล้ว — branch นี้กัน stray call
|
||||
// หลุดเข้า fixPdfActualText ที่จะ throw กับ bytes ที่ไม่ใช่ PDF)
|
||||
if (fileKindFromUrl(file.pdfUrl) === "image") {
|
||||
return new Response(new Uint8Array(srcBytes), {
|
||||
headers: {
|
||||
"Content-Type": imageContentTypeFromUrl(file.pdfUrl),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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