ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว
- โหลด 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>
This commit is contained in:
parent
289d735bf3
commit
6fd3a1515f
19 changed files with 890 additions and 53 deletions
|
|
@ -9,7 +9,7 @@ import type {
|
|||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
|
||||
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมระหว่าง sidebar,
|
||||
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมกันระหว่าง sidebar,
|
||||
// หน้าสรุป (F15/D2) และ export CSV (F16/D3) เพื่อไม่ให้ logic แยกกันคนละที่
|
||||
|
||||
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
|
||||
|
|
@ -40,7 +40,7 @@ export const DRAW_PENS: Record<
|
|||
|
||||
/**
|
||||
* ความทึบสุดท้ายของเส้นวาด — เส้นที่สร้างตอนมีตัวปรับความทึบเก็บ `opacity` ชัดเสมอ
|
||||
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลงไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
|
||||
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
|
||||
* render บนจอกับ export PDF ต้องใช้ค่าจากที่นี่ที่เดียวกัน
|
||||
*/
|
||||
export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): number {
|
||||
|
|
@ -60,8 +60,47 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -67,8 +67,16 @@ export function mergeRectsByLine(rects: Rect[]): Rect[] {
|
|||
const merged: Rect[] = [];
|
||||
for (const r of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && r.y < last.y + last.height) {
|
||||
// บรรทัดเดียวกัน → ขยายกรอบเดิมให้ครอบ rect นี้ด้วย
|
||||
if (!last) {
|
||||
merged.push({ ...r });
|
||||
continue;
|
||||
}
|
||||
// บรรทัดเดียวกัน = ซ้อนแนวตั้งกัน "เกินครึ่ง" ของความสูงที่เตี้ยกว่า — กล่อง span
|
||||
// ของ textLayer สูงตาม font metrics (ฟอนต์ไทยมีสระบน/ล่าง) จึงชนกันเล็กน้อยระหว่าง
|
||||
// บรรทัดที่ leading แน่นได้ ต้องไม่ถือเป็นบรรทัดเดียวกัน ไม่งั้นมาร์กเกิน/เด้งไปบรรทัดอื่น
|
||||
const overlap = Math.min(last.y + last.height, r.y + r.height) - Math.max(last.y, r.y);
|
||||
if (overlap > Math.min(last.height, r.height) / 2) {
|
||||
// ขยายกรอบเดิมให้ครอบ 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;
|
||||
|
|
|
|||
|
|
@ -221,6 +221,15 @@ export function scaleAnnotationData(
|
|||
},
|
||||
};
|
||||
}
|
||||
if ("scale" in data) {
|
||||
// STAMP — ขนาดทั้งกว้าง-สูงเก็บใน scale % ตัวเดียว (สัดส่วนตายตัว
|
||||
// STAMP_HEIGHT_RATIO) จึงขยายด้วยตัวคูณเดียว: caller ส่ง sx = sy = k
|
||||
return {
|
||||
...data,
|
||||
position: { x: fx(data.position.x), y: fy(data.position.y) },
|
||||
scale: data.scale * sx,
|
||||
};
|
||||
}
|
||||
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue