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:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-10 14:29:40 +07:00
parent 6fd0c3174f
commit eddcec28c7
60 changed files with 15055 additions and 0 deletions

109
utils/annotation-display.ts Normal file
View file

@ -0,0 +1,109 @@
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;
/** 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 : "";
}

82
utils/coordinates.ts Normal file
View file

@ -0,0 +1,82 @@
import type { Point, Rect } from "@/db/schema";
// พิกัดทุกอย่างที่เก็บลง DB เป็น % ของขนาดหน้า PDF (PLAN.md §2, CLAUDE.md invariant #2)
// ทิศทางแปลง: px → % = ÷size×100, % → px = ×size÷100 — ผ่านไฟล์นี้เท่านั้น
// Point/Rect define ที่ db/schema.ts (PLAN-FEAT.md §1) แล้ว re-export ที่นี่
export type { Rect, Point };
export type Size = { width: number; height: number };
export const toPercent = (px: number, size: number): number => (px / size) * 100;
export const toPx = (percent: number, size: number): number => (percent / 100) * size;
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export const pointToPercent = (p: Point, size: Size): Point => ({
x: toPercent(p.x, size.width),
y: toPercent(p.y, size.height),
});
export const pointToPx = (p: Point, size: Size): Point => ({
x: toPx(p.x, size.width),
y: toPx(p.y, size.height),
});
export const rectToPercent = (r: Rect, size: Size): Rect => ({
x: toPercent(r.x, size.width),
y: toPercent(r.y, size.height),
width: toPercent(r.width, size.width),
height: toPercent(r.height, size.height),
});
export const rectToPx = (r: Rect, size: Size): Rect => ({
x: toPx(r.x, size.width),
y: toPx(r.y, size.height),
width: toPx(r.width, size.width),
height: toPx(r.height, size.height),
});
export const pathToPercent = (path: Point[], size: Size): Point[] =>
path.map((p) => pointToPercent(p, size));
export const pathToPx = (path: Point[], size: Size): Point[] =>
path.map((p) => pointToPx(p, size));
/** พิกัด pointer จาก event เทียบ page element → % (clamp 0–100 เสมอ) */
export function getPointerPercent(
e: { clientX: number; clientY: number },
pageEl: HTMLElement,
): Point {
const rect = pageEl.getBoundingClientRect();
return {
x: clamp(toPercent(e.clientX - rect.left, rect.width), 0, 100),
y: clamp(toPercent(e.clientY - rect.top, rect.height), 0, 100),
};
}
/**
* รวบ rects ที่อยู่บรรทัดเดียวกัน (แนวตั้ง overlap กัน = y/height ใกล้กัน)
* เหลือ 1 rect ต่อบรรทัด โดย union แกน x ให้ครอบข้อความเดิมทั้งบรรทัด
* เรียกทุกครั้งก่อนบันทึก HIGHLIGHT ลง DB กัน JSONB บวม
*/
export function mergeRectsByLine(rects: Rect[]): Rect[] {
const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x);
const merged: Rect[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && r.y < last.y + last.height) {
// บรรทัดเดียวกัน → ขยายกรอบเดิมให้ครอบ rect นี้ด้วย
const right = Math.max(last.x + last.width, r.x + r.width);
last.x = Math.min(last.x, r.x);
last.width = right - last.x;
const bottom = Math.max(last.y + last.height, r.y + r.height);
last.height = bottom - last.y;
} else {
merged.push({ ...r });
}
}
return merged;
}

9
utils/csv.ts Normal file
View file

@ -0,0 +1,9 @@
// CSV field สำหรับ export สรุปหมายเหตุ (F16/D3) — route export-csv ใช้ร่วม
/** ครอบ field ด้วย double quote (RFC 4180) แล้ว escape `"` เป็น `""`
* FIX-6: field ที่ขึ้นต้นด้วย = + - @ หรือ TAB/CR ต้องนำหน้าด้วย '
* ก่อนครอบ quote — กัน Excel/Sheets ตีความค่าเป็นสูตรเมื่อเปิดไฟล์ (OWASP CSV Injection) */
export function csvField(v: string): string {
const guarded = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
return `"${guarded.replace(/"/g, '""')}"`;
}

300
utils/geometry.ts Normal file
View file

@ -0,0 +1,300 @@
import type { AnnotationData, AnnotationType, ShapeData } from "@/db/schema";
import {
clamp,
pointToPx,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
// Geometry ของ annotation (PLAN-FEAT.md §3 F1) — pure functions ทดสอบได้
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data */
export const COMMENT_PIN_BOX_PCT = 4;
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
export const TEXTBOX_MAX_WIDTH_PCT = 50;
export const TEXTBOX_CHAR_W = 0.6;
export const TEXTBOX_LINE_H = 1.5;
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
export const STAMP_HEIGHT_RATIO = 0.4;
type AnnotationLike = { type: AnnotationType; data: AnnotationData };
/**
* bbox ของ annotation (หน่วย px ตาม size ที่ส่งเข้ามา)
* - HIGHLIGHT: union ของ rects ทุกอัน
* - DRAW: union ของจุดทุกจุด ขยายออกด้วย strokeWidth/2 (เส้นมีความหนา)
* - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position
* เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง
*/
export function annotationBBox(a: AnnotationLike, size: Size): Rect {
const d = a.data;
// union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant
if ("rects" in d) {
const rects = d.rects.map((r) => rectToPx(r, size));
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
const x1 = Math.min(...rects.map((r) => r.x));
const y1 = Math.min(...rects.map((r) => r.y));
const x2 = Math.max(...rects.map((r) => r.x + r.width));
const y2 = Math.max(...rects.map((r) => r.y + r.height));
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("paths" in d) {
const pts = d.paths.map((p) => pointToPx(p, size));
const half = toPx(d.strokeWidth, size.width) / 2;
const x1 = Math.min(...pts.map((p) => p.x)) - half;
const y1 = Math.min(...pts.map((p) => p.y)) - half;
const x2 = Math.max(...pts.map((p) => p.x)) + half;
const y2 = Math.max(...pts.map((p) => p.y)) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("shape" in d) {
const half = toPx(d.strokeWidth, size.width) / 2;
if ("rect" in d) {
const r = rectToPx(d.rect, size);
return {
x: r.x - half,
y: r.y - half,
width: r.width + half * 2,
height: r.height + half * 2,
};
}
const a1 = pointToPx(d.from, size);
const b1 = pointToPx(d.to, size);
const x1 = Math.min(a1.x, b1.x) - half;
const y1 = Math.min(a1.y, b1.y) - half;
const x2 = Math.max(a1.x, b1.x) + half;
const y2 = Math.max(a1.y, b1.y) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("rect" in d) {
// REDACTION — กรอบดำทึบ ไม่มีความหนาเส้นให้ขยาย
return rectToPx(d.rect, size);
}
if ("fontSize" in d) {
// TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง)
const fontSizePx = toPx(d.fontSize, size.width);
const charW = fontSizePx * TEXTBOX_CHAR_W;
const lineH = fontSizePx * TEXTBOX_LINE_H;
const maxW = toPx(TEXTBOX_MAX_WIDTH_PCT, size.width);
let widest = 0;
let lines = 0;
for (const raw of d.text.split(/\r?\n/)) {
widest = Math.max(widest, raw.length * charW);
lines += Math.max(1, Math.ceil((raw.length * charW) / maxW));
}
const p = pointToPx(d.position, size);
return {
x: p.x,
y: p.y,
width: Math.min(widest, maxW) || charW,
height: Math.max(lines, 1) * lineH,
};
}
if ("scale" in d) {
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
const w = toPx(d.scale, size.width);
const p = pointToPx(d.position, size);
return { x: p.x, y: p.y, width: w, height: w * STAMP_HEIGHT_RATIO };
}
const p = pointToPx(d.position, size);
// กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space %
// (size={100,100}) ไม่เกิดหน่วยเพี้ยนตอน clamp translate/scale ของ COMMENT
const box = toPx(COMMENT_PIN_BOX_PCT, size.width);
return { x: p.x - box / 2, y: p.y - box / 2, width: box, height: box };
}
/**
* เลื่อน annotation ทั้งชิ้นตาม offset (หน่วย % ของหน้า) — ใช้ตอนลากย้าย
* strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)
* ผู้เรียกต้อง clamp dx/dy เองให้ bbox ยังอยู่ใน 0–100 ก่อนส่งเข้า action
*/
export function translateAnnotationData(
data: AnnotationData,
dxPct: number,
dyPct: number,
): AnnotationData {
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({ ...r, x: r.x + dxPct, y: r.y + dyPct })),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: p.x + dxPct, y: p.y + dyPct })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
from: { x: data.from.x + dxPct, y: data.from.y + dyPct },
to: { x: data.to.x + dxPct, y: data.to.y + dyPct },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
position: { x: data.position.x + dxPct, y: data.position.y + dyPct },
};
}
/**
* ย่อ–ขยาย annotation รอบจุด anchor (หน่วย % ของหน้า) ด้วยตัวคูณแกน x/y
* — ใช้ตอนลากมุมกรอบ (anchor = มุมตรงข้าม) ครอบ rects ทุกอัน / จุดทุกจุด / position
* strokeWidth คงเดิมตามแผน (สเปก F1 ระบุแค่ rects/points/position)
*/
export function scaleAnnotationData(
data: AnnotationData,
anchorPct: Point,
sx: number,
sy: number,
): AnnotationData {
const fx = (x: number) => anchorPct.x + (x - anchorPct.x) * sx;
const fy = (y: number) => anchorPct.y + (y - anchorPct.y) * sy;
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({
x: fx(r.x),
y: fy(r.y),
width: r.width * sx,
height: r.height * sy,
})),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: fx(p.x), y: fy(p.y) })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
return {
...data,
from: { x: fx(data.from.x), y: fy(data.from.y) },
to: { x: fx(data.to.x), y: fy(data.to.y) },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
}
function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq, 0, 1);
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/**
* hit-test จุด (px) กับ polyline (% ของหน้า) — ระยะถึง segment ใด ๆ ≤ thresholdPx
* ใช้ใน F6 (eraser); threshold หน่วย px เพราะความคาดหวังของผู้ใช้อิงขนาดจอ ไม่ใช่ % ของหน้า
*/
export function hitTestPath(
pointsPct: Point[],
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
if (pointsPct.length === 0) return false;
const pts = pointsPct.map((p) => pointToPx(p, size));
const pt = pointToPx(pointPct, size);
if (pts.length === 1) return Math.hypot(pt.x - pts[0].x, pt.y - pts[0].y) <= thresholdPx;
for (let i = 0; i < pts.length - 1; i++) {
if (distToSegment(pt, pts[i], pts[i + 1]) <= thresholdPx) return true;
}
return false;
}
/** ระยะจากจุดถึงเส้นขอบสี่เหลี่ยม (px) — อยู่ใน "พื้นที่" กรอบจะได้ระยะถึงขอบ ไม่ใช่ 0 */
function distToRectOutline(p: Point, r: Rect): number {
const inside =
p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
if (inside) {
const dx = Math.min(p.x - r.x, r.x + r.width - p.x);
const dy = Math.min(p.y - r.y, r.y + r.height - p.y);
return Math.min(dx, dy);
}
const cx = Math.max(r.x, Math.min(p.x, r.x + r.width));
const cy = Math.max(r.y, Math.min(p.y, r.y + r.height));
return Math.hypot(p.x - cx, p.y - cy);
}
/** ประมาณระยะจากจุดถึงเส้นรอบวงรี (px) — scale-space: |r−1|×min(rx,ry) พอสำหรับ eraser */
function distToEllipseOutline(p: Point, r: Rect): number {
const cx = r.x + r.width / 2;
const cy = r.y + r.height / 2;
const rx = r.width / 2;
const ry = r.height / 2;
if (rx === 0 || ry === 0) return Math.hypot(p.x - cx, p.y - cy);
const ratio = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry);
return Math.abs(ratio - 1) * Math.min(rx, ry);
}
/**
* hit-test จุดกับรูปทรง (F6 eraser) — เฉพาะ "เส้น stroke/ขอบ" เท่านั้น
* ผ่านพื้นที่ตรงกลางรูปทรงไม่โดน (PLAN-FEAT.md §5)
*/
export function hitTestShape(
data: ShapeData,
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
const p = pointToPx(pointPct, size);
if ("rect" in data) {
const r = rectToPx(data.rect, size);
if (data.shape === "ellipse") return distToEllipseOutline(p, r) <= thresholdPx;
return distToRectOutline(p, r) <= thresholdPx;
}
const a = pointToPx(data.from, size);
const b = pointToPx(data.to, size);
return distToSegment(p, a, b) <= thresholdPx;
}

146
utils/pdf-export.ts Normal file
View file

@ -0,0 +1,146 @@
import type { Rect } from "@/db/schema";
import type { Point, Size } from "@/utils/coordinates";
// ตัวช่วยแปลงพิกัดสำหรับ export PDF (F8/C1 — PLAN-FEAT.md §3, §5)
// พิกัดของเราเป็น % ของ "หน้าที่แสดงบนจอ" (pdfjs คิด /Rotate แล้ว, จุด 0,0 มุมบนซ้าย แกน y ลง)
// ฝั่ง pdf-lib วาดในพิกัด PDF ของหน้า **ที่ยังไม่ถูกหมุน** (จุด 0,0 มุมล่างซ้าย แกน y ขึ้น)
// → ทุกจุดต้องแปลงตามมุม /Rotate ก่อนวาดเสมอ
export type Rot = 0 | 90 | 180 | 270;
/** ขนาดหน้า "บนจอ" หลังคิด rotation — 90/270 สลับกว้าง-สูง */
export function displaySize(pageSize: Size, rotation: Rot): Size {
return rotation % 180 === 0
? pageSize
: { width: pageSize.height, height: pageSize.width };
}
/**
* จุดบนจอ (px, มุมบนซ้าย y ลง, ขอบเขต displaySize) → พิกัด PDF (pt, มุมล่างซ้าย y ขึ้น)
* /Rotate ของ PDF หมายถึง "หมุน content ตามเข็มนาฬิกาตอนแสดง" (ตามพฤติกรรม pdfjs)
* อนุพันธ์ inverse mapping ของการหมุน CW แต่ละมุม
*/
export function displayToPdfPoint(
p: Point,
pageSize: Size,
rotation: Rot,
): Point {
switch (rotation) {
case 90:
return { x: p.y, y: p.x };
case 180:
return { x: pageSize.width - p.x, y: p.y };
case 270:
return { x: pageSize.width - p.y, y: pageSize.height - p.x };
default:
return { x: p.x, y: pageSize.height - p.y };
}
}
/**
* rect บนจอ (แกนขนานกับจอ) → rect ในพิกัด PDF — หมุน 90/270 แล้วยังแกนขนาน
* (เพราะเป็นมุมฉากพอดี) แปลงสองมุมแล้ว normalize
*/
export function displayRectToPdf(
rect: Rect,
pageSize: Size,
rotation: Rot,
): Rect {
const a = displayToPdfPoint({ x: rect.x, y: rect.y }, pageSize, rotation);
const b = displayToPdfPoint(
{ x: rect.x + rect.width, y: rect.y + rect.height },
pageSize,
rotation,
);
return {
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
width: Math.abs(b.x - a.x),
height: Math.abs(b.y - a.y),
};
}
/** แปลงสี hex (#rrggbb) → สัดส่วน 0–1 สำหรับ rgb() ของ pdf-lib — ผิดรูปแบบใช้ดำ */
export function hexToRgb01(hex: string): { r: number; g: number; b: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
const v = m ? m[1] : "000000";
return {
r: parseInt(v.slice(0, 2), 16) / 255,
g: parseInt(v.slice(2, 4), 16) / 255,
b: parseInt(v.slice(4, 6), 16) / 255,
};
}
/** ตัดบรรทัดแบบตามความกว้างจริงของฟอนต์ (ภาษาไทยไม่มีช่องว่าง → เดินทีละตัวอักษร) */
export function wrapTextByWidth(
text: string,
maxWidth: number,
widthOf: (s: string) => number,
): string[] {
const lines: string[] = [];
for (const raw of text.split(/\r?\n/)) {
let cur = "";
for (const ch of raw) {
if (cur && widthOf(cur + ch) > maxWidth) {
lines.push(cur);
cur = ch;
} else {
cur += ch;
}
}
lines.push(cur);
}
return lines;
}
/** ช่วงข้อความที่มีสไตล์เดียวกัน (ตัวหนา/ปกติ) — ใช้ตอน export TEXTBOX แบบ rich text */
export type StyledRun = { text: string; bold: boolean };
/**
* ตัดบรรทัดข้อความที่มีสไตล์ผสม (ตัวหนาเฉพาะส่วน) ตามความกว้าง — แตกเป็นตัวอักษร
* (แต่ละตัวใช้ฟอนต์ตามสไตล์ของมันเองตอนวัดความกว้าง) แล้วม้วนกลับเป็น run ต่อบรรทัด
* พฤติกรรมการตัดคำเดียวกับ wrapTextByWidth (ตัดทีละตัวอักษร — ภาษาไทยไม่มีช่องว่างคำ)
*/
export function wrapStyledText(
runs: StyledRun[],
maxWidth: number,
widthOf: (s: string, bold: boolean) => number,
): StyledRun[][] {
const chars: { ch: string; bold: boolean }[] = [];
for (const r of runs) {
for (const ch of Array.from(r.text)) chars.push({ ch, bold: r.bold });
}
const lines: { ch: string; bold: boolean }[][] = [[]];
let w = 0;
for (const c of chars) {
if (c.ch === "\n") {
lines.push([]);
w = 0;
continue;
}
const cw = widthOf(c.ch, c.bold);
if (w > 0 && w + cw > maxWidth) {
lines.push([]);
w = 0;
}
lines[lines.length - 1].push(c);
w += cw;
}
return lines.map((line) => {
const out: StyledRun[] = [];
for (const c of line) {
const last = out[out.length - 1];
if (last && last.bold === c.bold) last.text += c.ch;
else out.push({ text: c.ch, bold: c.bold });
}
return out;
});
}
/** ตัดชื่อไฟล์แบบ RFC 5987 สำหรับ Content-Disposition (ชื่อไฟล์ภาษาไทย) */
export function encodeRFC5987(s: string): string {
return encodeURIComponent(s).replace(
/['()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}

25
utils/user.ts Normal file
View file

@ -0,0 +1,25 @@
// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4)
// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth
export type LocalUser = { userId: string; userName: string };
const STORAGE_KEY = "judes-note:user";
export function getLocalUser(): LocalUser {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as LocalUser;
if (typeof parsed.userId === "string" && typeof parsed.userName === "string") {
return parsed;
}
} catch {
// ข้อมูลเก่าเสียหาย → สร้างใหม่ต่อด้านล่าง
}
}
const userId = crypto.randomUUID();
const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName }));
return { userId, userName };
}