Skip to content

Latest commit

 

History

43 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

📄 MeDF — แก้ PDF ภาษาไทย ในเบราว์เซอร์ ไฟล์ไม่ออกจากเครื่อง

ลากไฟล์ PDF เข้ามา แล้วแก้ได้เลยในเบราว์เซอร์ เติมข้อความ แทรกรูป เซ็นชื่อ วางตราประทับ หรือไฮไลต์ แล้วกด Export กลับออกมาเป็น PDF ที่ข้อความเดิมยังคมชัด คัดลอกได้ และค้นหาได้ เพราะหน้าต้นฉบับถูก คัดลอก มาทั้งแผ่นแล้ววาดทับ ไม่ได้แปลงเป็นรูปภาพ

ไม่มีเซิร์ฟเวอร์ ไม่มีการอัปโหลด ไม่มีบัญชีผู้ใช้ — ไฟล์ของคุณอยู่ในแท็บเบราว์เซอร์เท่านั้น ติดตั้งเป็นแอปบนเครื่องได้ (PWA) แล้วใช้ต่อแบบออฟไลน์

จุดที่ต่างจากเครื่องมือต่างชาติคือ ภาษาไทยไม่พัง — ฝัง Sarabun ครบทุกน้ำหนัก วางสระและวรรณยุกต์ตามตำแหน่งจริง และตัดคำด้วย Intl.Segmenter('th') ของเบราว์เซอร์เอง

Next.js React TypeScript Tailwind CSS pdf.js pdf-lib Live demo Tests CI License

English TL;DR — Drop a PDF in the browser and edit it: text, images, signatures, shapes, highlights. Export puts your overlay on top of the original page objects — copyPages(), not a raster — so the document's own text stays sharp, selectable and searchable. There is no server: no upload, no account, no database. The file never leaves the tab, and the whole thing installs as a PWA and keeps working offline. The part that is hard, and the reason this exists, is Thai: the full Sarabun family is embedded per export, vowels and tone marks are placed by real metrics rather than guessed, and line breaking uses the browser's own Intl.Segmenter('th') because Thai has no spaces between words. 154 unit tests run with node --test and no test framework; 59 more drive a real Chromium against the built site.


📋 สารบัญ


🎯 ทำไมถึงทำโปรเจกต์นี้

ปัญหาจริงคือ "ไม่มีไฟล์ต้นฉบับแล้ว" ไม่ใช่ "อยากวางของทับ PDF"

คนทำงานเอกสารมี PDF อยู่ในมือ แต่ไฟล์ Word หรือ InDesign ต้นทางหายไปแล้ว — คนทำลาออก เอเจนซี่คิดเงินเพิ่ม หรือผ่านมาสองปีไม่มีใครหาเจอ พอต้องแก้เลขสัญญาบรรทัดเดียว ทางเลือกที่เหลือคือพิมพ์ใหม่ทั้งหน้า หรือหาโปรแกรมเถื่อนมาลง

เครื่องมือที่มีอยู่ตอบไม่ตรงสามข้อ

  1. เว็บแก้ PDF ส่วนใหญ่ต้องอัปโหลดไฟล์ขึ้นเซิร์ฟเวอร์ต่างประเทศ ซึ่งองค์กรไทยที่มีนโยบาย ข้อมูลใช้ไม่ได้เลย สัญญา ใบเสนอราคา และเอกสาร HR ไม่ควรออกจากเครื่อง
  2. Acrobat/Foxit เถื่อน ราคา 0 บาท คือคู่แข่งอันดับหนึ่งจริง ๆ แผนที่ไม่มีคำตอบให้ข้อนี้ คือแผนที่ใช้ไม่ได้ — คำตอบของ MeDF คือ ของเถื่อนก็แก้ภาษาไทยพังเหมือนกัน และ บางองค์กรใช้ของเถื่อนไม่ได้ เพราะโดนตรวจลิขสิทธิ์
  3. เครื่องมือต่างชาติทำภาษาไทยไม่ดี สระลอย วรรณยุกต์เพี้ยน ฟอนต์เปลี่ยนเอง และตัดบรรทัดผิดเพราะภาษาไทยไม่มีช่องว่างระหว่างคำ

โฟกัสของโปรเจกต์จึงอยู่ที่ ความถูกต้องของภาษาไทย + ไฟล์ไม่ออกจากเครื่อง มากกว่าจำนวนฟีเจอร์ ทิศทางระยะยาวและเกณฑ์ที่จะใช้ตัดสินใจหยุด อยู่ใน docs/PRODUCT_DIRECTION.md


⏱️ ลองใน 60 วินาที

  1. เปิด หน้าลองใช้ — ไม่ต้องสมัคร ไม่ต้องติดตั้ง
  2. ลากไฟล์ PDF เข้ามา (หรือกด "ใช้เอกสารตัวอย่าง" ถ้ายังไม่มีไฟล์ในมือ) → ระบบสร้างสัญญาจ้างภาษาไทยให้ในเบราว์เซอร์
  3. กด T แล้วคลิกบนหน้า → พิมพ์ข้อความไทยลงไป ลากไปวางตรงไหนก็ได้
  4. กด Export PDF → ได้ไฟล์กลับมา ลองเปิดแล้ว ลากคลุมข้อความเดิม ดู จะเห็นว่ายังคัดลอกได้ ไม่ได้กลายเป็นรูป

เปิด DevTools → Network ไว้ด้วยก็ได้ จะเห็นว่าไม่มี request ไหนส่งไฟล์ออกไปเลย (มีเทสต์คุมข้อนี้อยู่จริง — ดูหัวข้อการทดสอบ)


🚀 วิธีรัน

ประมาณ 2 นาที · ไม่ต้องมีฐานข้อมูล ไม่ต้องมี backend ไม่ต้องตั้งค่า environment อะไรเลย

ขั้นที่ 0 — ดาวน์โหลดโค้ด

git clone https://github.com/SuruchBoss/MeDF.git
cd MeDF

ต้องมี: Node.js 20.9 ขึ้นไป (แนะนำ 22 — เทสต์ระดับ unit ใช้ TypeScript ของ Node โดยตรง)

ขั้นที่ 1 — ติดตั้งและรัน

npm install
npm run dev        # เปิด http://localhost:4173

เท่านี้ครบ ไม่มีไฟล์ .env ที่ต้องเตรียม เพราะไม่มีอะไรต้องเชื่อมต่อ

ขั้นที่ 2 — build เป็นเว็บสถิต

npm run build      # ได้ไฟล์ทั้งเว็บที่ apps/web/out/
npm run preview    # เสิร์ฟโฟลเดอร์นั้นที่ http://localhost:4173

out/ เป็นไฟล์ล้วน เอาไปวางบน GitHub Pages, Netlify, S3, nginx หรือแฟลชไดรฟ์ก็ได้

ขั้นที่ 3 — deploy ขึ้น GitHub Pages

มี workflow ให้แล้วที่ .github/workflows/pages.yml สิ่งที่ต้องทำครั้งเดียวคือไปที่ Settings → Pages → Source → GitHub Actions

ถ้า deploy ใต้เส้นทางย่อย (https://user.github.io/MeDF/) ต้องบอก base path ด้วย — workflow อ่านจาก Pages API ให้อัตโนมัติ ส่วนตอน build เองใช้

NEXT_PUBLIC_BASE_PATH=/MeDF npm run build

🔧 แก้ปัญหาที่พบบ่อย

อาการ สาเหตุและทางแก้
หน้าเปล่า ๆ หลัง deploy ตั้ง NEXT_PUBLIC_BASE_PATH ไม่ตรงกับเส้นทางที่เสิร์ฟจริง
PDF ไม่ขึ้น หมุนค้าง pdf.worker.min.mjs ไม่ได้ถูกคัดลอกไป public/ — รัน npm run build ใหม่ (มี prebuild คัดลอกให้)
ฟอนต์ไทยเป็นกล่องสี่เหลี่ยมใน PDF ที่ export ไฟล์ .ttf ใน apps/web/public/fonts/ หาย — ต้องมีครบ 4 น้ำหนัก
npm test ค้างที่ browser test ยังไม่ได้ npx playwright install chromium

✨ ฟีเจอร์

🧩 วางองค์ประกอบ 8 ชนิด

ชนิด คีย์ลัด ใช้ทำอะไร
กล่องข้อความ T เติมข้อมูลในช่องว่าง แก้ตัวเลข เพิ่มหมายเหตุ
รูปภาพ I โลโก้ ตราประทับ รูปถ่าย
ลายเซ็น S วาดด้วยเมาส์หรือนิ้ว
สี่เหลี่ยม R ปิดทับข้อความเดิม หรือทำกรอบ
วงกลม O วงเน้นข้อความ
เส้น L ขีดฆ่า ลูกศรชี้
ไฮไลต์ H เน้นข้อความด้วยสีโปร่ง
เครื่องหมายถูก K ติ๊กช่องในแบบฟอร์ม

ทุกชนิดมี type ของตัวเองใน discriminated union เดียว และแผงคุณสมบัติจะเปลี่ยนตามชนิดที่เลือก — กล่องข้อความได้ฟอนต์/ขนาด/การจัดวาง ส่วนเส้นได้หัวลูกศร ไม่ปนกัน

🖨 Export ที่ข้อความเดิมยังคมชัด

นี่คือหัวใจของโปรเจกต์ และเป็นจุดที่เครื่องมือหลายตัวทำพลาด

pdf-lib: copyPages(หน้าต้นฉบับ) → วาด overlay ทับ → บันทึก

หน้าต้นฉบับถูก คัดลอกทั้ง object ไม่ได้ raster เป็นรูป ผลคือ

  • ข้อความเดิม คัดลอกได้ ค้นหาได้ และคมชัดทุกระดับซูม
  • เวกเตอร์เดิม (ตาราง เส้นขอบ โลโก้) ยังเป็นเวกเตอร์
  • ขนาดไฟล์ไม่บวม เพราะไม่ได้ฝังภาพความละเอียดสูงเข้าไปแทน

การ "ลบ" ข้อความเดิมทำโดยวางสี่เหลี่ยมทับ ซึ่งเป็นพฤติกรรมที่ตั้งใจและปลอดภัยกว่า การไปเขียนทับ content stream ของเอกสารที่มีผลทางกฎหมาย

ข้อจำกัดที่ต้องรู้: ข้อความที่ถูกทับยังอยู่ในไฟล์ ถ้าต้องการลบจริงเพื่อความลับ (redaction) วิธีนี้ไม่พอ — อยู่ในแผนระยะถัดไป

🇹🇭 ภาษาไทยที่ไม่พัง

สามเรื่องที่ทำให้เครื่องมือต่างชาติทำภาษาไทยได้ไม่ดี และ MeDF จัดการไว้แล้ว

1. ฟอนต์ต้องฝังไปกับไฟล์ — ฝัง Sarabun ครบ 4 น้ำหนัก (regular / bold / italic / bold-italic) ผ่าน @pdf-lib/fontkit ทุกครั้งที่ export ไม่ได้พึ่งฟอนต์ที่เครื่องปลายทางมี ไฟล์ที่ได้จึงเปิดบนเครื่องไหนก็เหมือนกัน

2. ตัดคำไทยไม่มีช่องว่างให้ยึด — ใช้ Intl.Segmenter('th', { granularity: 'word' }) ของเบราว์เซอร์เอง ไม่ต้องแบกพจนานุกรมเข้ามาใน bundle

3. คำยาวคำเดียวที่ยาวกว่ากรอบ ต้องตัดกลางคำ ไม่ใช่ล้นออกไปเฉย ๆ

เคยเป็นบั๊กจริง: wrapText ตัดทีละตัวอักษรเฉพาะตอนคำนั้นขึ้นต้นบรรทัดเท่านั้น ถ้าคำยาวไปโผล่กลางบรรทัด ข้อความจะล้นออกนอกกรอบใน PDF ที่ export ออกมา unit test ชุดแรกที่เขียนจับได้ทันที ทั้งที่ end-to-end test 4 ชุดมองไม่เห็นมาตลอด

ข้อจำกัดที่ทราบ: สระอำ (ำ) ถูกแยกเป็นนิคหิต + สระอา ตอนวาด เพื่อให้ตำแหน่งถูกต้อง ผลข้างเคียงคือการคัดลอกข้อความจาก PDF ที่ export ออกมาจะได้ลำดับ codepoint ต่างจากต้นฉบับ (normalise แล้วตรงกัน) — มีเทสต์คุมไว้ และอธิบายไว้ใน docs/ARCHITECTURE.md

🖱 ลากและปรับขนาดแบบที่นิ้วกับเมาส์คาดหวัง

  • Snap อัตโนมัติ เข้าขอบหน้า เส้นกึ่งกลาง และองค์ประกอบอื่น — กด Alt ค้างเพื่อปิดชั่วคราว
  • หมุนได้ พร้อม snap ทุก 15° และคณิตศาสตร์ของการลากยังถูกต้องหลังหมุน (ลากขวาแล้วของต้องไปทางขวาบนหน้าจอ ไม่ใช่ไปตามแกนที่หมุนไปแล้ว)
  • ลากกรอบเลือกหลายชิ้น แล้วขยับพร้อมกัน หรือ Shift+คลิกเพื่อเลือกเพิ่ม
  • ลูกศรเลื่อนทีละ 1 pt (Shift = 10 pt) สำหรับงานที่ต้องเป๊ะ
  • ดับเบิลคลิกข้อความเพื่อแก้ในที่ ไม่ต้องไปหาแผงด้านข้าง

📑 จัดการหน้าเอกสาร

สลับลำดับหน้า หมุนทีละหน้า และซ่อนหน้าที่ไม่ต้องการก่อน export — จำนวนหน้าที่นับ จะไม่รวมหน้าที่ซ่อนไว้

✍️ เซ็นชื่อด้วยเมาส์หรือนิ้ว

แผงเซ็นชื่อเก็บลายเส้นเป็นพิกัดเวกเตอร์ ไม่ใช่ภาพ bitmap เวลา export จึงวาดเป็นเส้นจริง ลงใน PDF — ซูมเท่าไหร่ก็คม และไฟล์เล็กกว่าการฝังรูป

↩️ Undo/Redo ที่นับเป็นท่า ไม่ใช่เป็นเฟรม

หนึ่งการลาก = หนึ่งขั้น undo ไม่ใช่หนึ่งขั้นต่อหนึ่ง pointermove — ตอนกด pointer จะ dispatch({ type: 'checkpoint' }) ครั้งเดียว จากนั้นทุก update ระหว่างลากไม่บันทึกประวัติ กด Ctrl+Z ทีเดียวจึงย้อนทั้งท่า ไม่ใช่ย้อนไป 1 พิกเซล

📱 ใช้บนมือถือได้จริง

แผงคุณสมบัติกว้าง 288px เคยกินพื้นที่ 74% ของจอ 390px จนเหลือที่ให้เอกสารแค่ 102px — ตอนนี้ต่ำกว่า lg มันเป็นลิ้นชักที่เปิดจากปุ่มบนแถบเครื่องมือ และปิดด้วย Escape เอกสารจึงได้ความกว้างเต็มจอ ปุ่มทุกปุ่มบนจอสัมผัสมีขนาดอย่างน้อย 44×44px

🌐 สองภาษา (ไทย / English)

สลับได้จากหัวเว็บ ไม่มี route แยกภาษา ไม่มีการโหลดหน้าใหม่จากเซิร์ฟเวอร์ รายละเอียดด้านล่าง

📲 ติดตั้งเป็นแอป (PWA)

กด "ติดตั้ง" จากแถบที่อยู่ของ Chrome/Edge แล้วได้ไอคอนบนเดสก์ท็อป หน้าต่างของตัวเอง และใช้งานออฟไลน์ได้ — มี manifest, ไอคอน maskable สำหรับ Android และ apple-touch-icon สำหรับ iOS ครบ

เคยมีแอป Electron สำหรับ Windows แต่พักไว้แล้ว เพราะตัวติดตั้งที่ไม่ได้เซ็น certificate โดน SmartScreen เตือน ส่วน PWA ให้คุณค่าเดียวกันโดยไม่ต้องจ่ายค่า certificate รายปี โค้ดเดิมอยู่ที่ branch archive/desktop — เหตุผลเต็มอยู่ใน docs/WINDOWS.md


🛠 เทคโนโลยีที่ใช้

Framework / ภาษา

รุ่น ทำไมถึงเลือก
Next.js 16 (App Router) output: 'export' คายเว็บสถิตออกมาได้ทั้งชุด โฮสต์ที่ไหนก็ได้
React 19 useSyncExternalStore และ <dialog> ที่ต้องใช้จริงในโปรเจกต์นี้
TypeScript 5.7 strict discriminated union ขององค์ประกอบ 8 ชนิดพึ่ง type ทั้งหมด
Tailwind CSS 4 token สีอยู่ที่เดียว ไม่มีไฟล์ CSS กระจาย

ไลบรารีหลัก

ทำอะไร
pdfjs-dist เรนเดอร์หน้า PDF ลงแคนวาส (รันใน worker แยก)
pdf-lib ประกอบไฟล์ PDF ใหม่ตอน export
@pdf-lib/fontkit ฝังฟอนต์ไทยแบบ subset

ไม่ได้ใช้: state library, i18n library, UI component library, test framework, icon package — ทั้งหมดเขียนเองเพราะแต่ละอันเล็กกว่าที่คิด และควบคุมพฤติกรรมได้ตรงกว่า

เครื่องมือ

Playwright (เทสต์เบราว์เซอร์), node --test (unit), ESLint 9 flat config, GitHub Actions


🏛 สถาปัตยกรรม

ภาพรวมระบบ

เบราว์เซอร์ (ติดตั้งเป็น PWA ได้ · ใช้ออฟไลน์ได้)
        │
        │  ①  pdf.js เรนเดอร์หน้า PDF ลงแคนวาส (worker แยก ไม่บล็อก UI)
        │  ②  overlay elements วางทับด้วย DOM — ไม่แตะเนื้อหาต้นฉบับ
        │  ③  pdf-lib คัดลอกหน้าต้นฉบับ แล้ววาด overlay ทับ + ฝังฟอนต์
        ▼
ไฟล์ PDF ที่ดาวน์โหลดกลับลงเครื่อง

เซิร์ฟเวอร์: ไม่มี

ทำไมถึงไม่มีเซิร์ฟเวอร์

เวอร์ชันแรกมีครบ — สมาชิก ฐานข้อมูล โควตา แพ็กเกจรายเดือน หน้าผู้ดูแล — แล้วถูกถอดออกทั้งหมด เหตุผลอยู่ใน docs/PRODUCT_DIRECTION.md §1 สรุปสั้น ๆ:

ถ้าไฟล์ไม่เคยถูกอัปโหลด เซิร์ฟเวอร์ไม่ได้ทำอะไรนอกจากเก็บรหัสผ่านที่ผู้ใช้จะลืม และถือเอกสารที่สร้างภาระ PDPA — โดยที่ยังไม่มีแม้แต่ฟังก์ชันส่งอีเมลรีเซ็ตรหัสผ่าน

โค้ดชั้นเซิร์ฟเวอร์ไม่ได้ถูกลบ แต่ย้ายไปเก็บที่ branch archive/server ทั้งหมด

EditorBackend — ตะเข็บที่เหลือไว้ตั้งใจ

เอดิเตอร์ไม่รู้ว่าเอกสารมาจากไหน มันคุยผ่าน interface เดียว

interface EditorBackend {
  readonly kind: 'demo' | 'local';
  readonly pdfUrl: string;
  assetUrl(assetId: string): string;
  saveOverlay(input: { overlay: OverlayDoc; baseRevision: number }): Promise<{ revision: number }>;
  uploadAsset(input: { file: File; width: number; height: number }): Promise<{ assetId: string }>;
  exportPdf(input: { overlay: OverlayDoc; title: string }): Promise<{ blob: Blob; skippedAssets: number }>;
  rename(title: string): Promise<{ title: string; revision: number }>;
}

ตอนนี้มี DemoBackend ตัวเดียว (เก็บทุกอย่างไว้ในแท็บ) ตัวถัดไปคือ LocalBackend ที่เก็บลง IndexedDB — เป็นการเพิ่มไฟล์ใหม่ ไม่ใช่การรื้อ เพราะตะเข็บนี้อยู่ตรงนี้อยู่แล้ว

State management

ไม่มี state library — useReducer ตัวเดียวถือเนื้อหาเอกสารทั้งหมด

interface EditorState {
  doc: DocumentState;   // overlay, หน้า, ประวัติ undo/redo  ← persist ได้
  view: ViewState;      // selection, zoom, เครื่องมือที่เลือก  ← ephemeral
}

แยกสองก้อนเพราะเคยปนกัน แล้วการเลื่อน selection ก็กลายเป็นการแก้เอกสาร การแยกทำให้ doc อ้างอิงเดิมได้เมื่อมีแค่ view เปลี่ยน — memoisation จึงทำงานจริง

กฎที่บังคับด้วย ESLint และ unit test:

  • reducer ต้อง บริสุทธิ์ — ห้าม fetch ห้ามแตะ DOM ห้ามอ่านเวลา
  • ห้ามอ่าน ref.current ระหว่าง render (เคยเป็นบั๊กจริง: page observer ไม่เคยได้ root)
  • ห้ามใช้ alert / confirm / prompt — ใช้ useDialog() ที่เรนเดอร์ <dialog> จริง

ระบบสี

token ทั้งหมดอยู่ใน globals.css ที่เดียว — ink-* (เทาอมน้ำเงิน 11 ระดับ) กับ brand-* (ม่วง) เกณฑ์ที่บังคับไว้และมีเทสต์คุม: ข้อความต้องผ่านคอนทราสต์ 4.5:1 ส่วนไอคอน 3:1 text-ink-400 จึงใช้กับไอคอนเท่านั้น (3.45:1 — ผ่าน 3:1 ไม่ผ่าน 4.5:1)


📁 โครงสร้างโปรเจกต์

apps/web/
  src/app/
    page.tsx                  # landing — ฟีเจอร์ ราคา FAQ (client component ล้วน)
    try/page.tsx              # ตัวเอดิเตอร์จริง — นี่คือ "แอป"
    pricing/page.tsx          # ตารางเทียบแพ็กเกจ
    layout.tsx                # เลือกภาษาในเบราว์เซอร์ (static export ไม่มีเซิร์ฟเวอร์ให้ถาม)
    manifest.ts               # web app manifest สำหรับ PWA
    error.tsx, global-error.tsx, not-found.tsx

  src/components/editor/      # ← หัวใจของโปรเจกต์
    store.ts                  # reducer เดียวที่ถือเอกสารทั้งหมด + undo/redo
    editor-shell.tsx          # ประกอบทุกแผงเข้าด้วยกัน จัดการ autosave/export
    editor-page.tsx           # หน้าที่รับไฟล์เข้ามาแล้วเปิดเอดิเตอร์
    page-stage.tsx            # แคนวาสหนึ่งหน้า — ลาก ปรับขนาด หมุน เลือกหลายชิ้น
    element-view.tsx          # เรนเดอร์องค์ประกอบแต่ละชนิดลง DOM
    element-properties.tsx    # แผงคุณสมบัติที่เปลี่ยนตามชนิดที่เลือก
    geometry.ts               # snap, การหมุน, การแปลงพิกัด (บริสุทธิ์ทั้งไฟล์ → unit test ได้)
    backend.ts                # interface ด้านบน
    demo-backend.ts           # adapter ที่เก็บทุกอย่างไว้ในแท็บ
    signature-pad.tsx         # แผงเซ็นชื่อ เก็บเป็นเวกเตอร์
    pdf-page-canvas.tsx       # ครอบ pdf.js — เรนเดอร์เฉพาะหน้าที่มองเห็น
    use-*.ts                  # autosave, คีย์ลัด, zoom-to-fit, intersection observer

  src/lib/pdf/
    render.ts                 # วาด overlay ลง pdf-lib — ใช้ร่วมกันทุกที่ที่ export
    fonts.ts                  # FontBook: ฝังฟอนต์ครั้งเดียวต่อหนึ่งการ export
    fonts-browser.ts          # โหลดฟอนต์ด้วย fetch
    matrix.ts                 # เมทริกซ์ 2D — การหมุนและการแปลงพิกัด
    page-geometry.ts          # ขนาดหน้าหลังหมุน
    sample-document.ts        # สัญญาตัวอย่างภาษาไทย สร้างในเบราว์เซอร์

  src/lib/i18n/
    th.ts                     # พจนานุกรมต้นฉบับ (`as const`)
    en.ts                     # `Record<MessageKey, string>` — ลืมแปล = คอมไพล์ไม่ผ่าน
    provider.tsx              # useT() / useLocale() / setLocale()
    locales.ts                # เจรจาภาษา: คุกกี้ → navigator.language → ค่าตั้งต้น
    format.ts                 # Intl สำหรับตัวเลข เงิน และเวลาสัมพัทธ์

  src/lib/
    editor-types.ts           # union ขององค์ประกอบ 8 ชนิด + ElementPatch<T>
    plans.ts                  # แพ็กเกจฟรี/จ่ายแล้ว (Object.hasOwn กัน prototype pollution)
    features.ts               # ฟีเจอร์ไหนอยู่แพ็กเกจไหน — derive จาก plans.ts ไม่ใช่เขียนซ้ำ
    client/                   # hook เล็ก ๆ: useHydrated, useMediaQuery, useFocusTrap, useLatest

  scripts/
    ui-test.mjs               # เทสต์เบราว์เซอร์หลัก — ลาก ปรับขนาด เซ็น export แล้วอ่าน PDF กลับ
    demo-test.mjs             # พิสูจน์ว่าไม่มี request ไหนส่งไฟล์ออกไป
    ux-audit.mjs              # ตรวจ UX 36 ชุด (3 หน้า × 6 ขนาด × 2 ภาษา)
    register-ts-alias.mjs     # ให้ `node --test` โหลด TypeScript ของแอปได้ตรง ๆ

  tests/                      # unit test — ไม่มี framework ใช้ node --test เปล่า ๆ
scripts/
  test-harness.mjs            # ของที่เทสต์เบราว์เซอร์ใช้ร่วมกัน (หา Chromium, หาพอร์ตว่าง, ...)
  make-icons.mjs              # วาดไอคอนทุกขนาดจากโลโก้ตัวเดียว
docs/                         # สถาปัตยกรรม · มาตรฐานโค้ด · ทิศทางสินค้า · open core · deploy

📐 ระบบพิกัด 3 ชั้น

ส่วนที่ผิดง่ายที่สุดในโปรเจกต์ และเป็นเหตุผลที่ geometry.ts กับ matrix.ts ถูกแยกออกมา เป็นฟังก์ชันบริสุทธิ์ที่มี unit test คุมทุกตัว

ชั้น หน่วย จุดกำเนิด
PDF point (1/72 นิ้ว) ซ้ายล่าง
หน้าจอ CSS pixel × zoom ซ้ายบน
องค์ประกอบที่หมุน point ในระบบพิกัดของตัวเอง มุมซ้ายบนของกล่อง

สามอย่างที่ต้องระวัง

  1. แกน Y กลับด้าน ระหว่าง PDF กับหน้าจอ
  2. หน้าที่หมุน 90° สลับความกว้างกับความสูง — page-geometry.ts จัดการที่เดียว
  3. ลากองค์ประกอบที่หมุนแล้ว ต้องแปลงเวกเตอร์การเคลื่อนที่กลับเข้าระบบพิกัดของกล่อง ไม่งั้นลากขวาแล้วของวิ่งเฉียง — deltaToBaseSpace() และมีเทสต์ยืนยันว่ามันตรงกับ toBaseSpace() ทุกมุม

เคยเป็นบั๊กจริง: snapAngle คืนค่า 360 องศา เพราะ -7° normalise เป็น 353° แล้วปัดขึ้น ควรได้ 0 — unit test จับได้ ทั้งที่มองด้วยตาไม่มีทางเห็น


🌐 ระบบสองภาษา (i18n)

เขียนเอง ~120 บรรทัด ไม่ใช้ไลบรารี และไม่มี route แยกภาษา

ต้นฉบับคือ th.ts ประกาศเป็น as const ส่วน en.ts ประกาศเป็น Record<MessageKey, string> — เพิ่ม key ในไทยแล้วลืมภาษาอังกฤษ คอมไพล์ไม่ผ่าน ไม่ใช่ข้อความหายตอนรัน

const t = useT();
t('editor.save');                          // → "บันทึก" หรือ "Save"
t('quota.pageCount', { pages: 3, limit: 20 });  // {name} คือ placeholder เดียวที่มี

ตัวเลข เงิน และเวลาแบบ "3 นาทีที่แล้ว" ไม่อยู่ในพจนานุกรม แต่ผ่าน Intl ที่ format.ts เพราะจุดเรียกมี locale อยู่ในมือแล้ว

เจรจาภาษา: คุกกี้ medf_locale → navigator.language → ไทย

มีเทสต์คุม 3 อย่าง: key ครบทั้งสองภาษา · ไม่มีอักษรไทยหลุดอยู่นอกพจนานุกรม · ไม่มี key โผล่บนจอแทนข้อความ

เคยเป็นบั๊กจริง 2 รอบ: ปุ่มเครื่องมือ 9 ปุ่มเขียนว่า element.text เพราะ t() ถูกใส่ ที่ tooltip แต่ลืมที่ label และรายการฟีเจอร์ในหน้าแพ็กเกจเขียนว่า feature.exportPdf.label ทั้งสองจุด ผ่าน type check และผ่านเทสต์ทุกตัว เพราะ key พวกนั้นมีอยู่จริง ตอนนี้ ux-audit.mjs เทียบข้อความบนจอกับรายการ key จริงทุกตัวในพจนานุกรม


🧪 การทดสอบ

สามชั้น เลือกตามสิ่งที่จะทดสอบ

ชั้น จำนวน เวลา ทดสอบอะไร
unit 154 < 2 วิ คณิตศาสตร์ reducer การแปลงข้อมูล พจนานุกรม
เบราว์เซอร์ 59 ~2 นาที ลาก ปรับขนาด เซ็น export แล้วอ่าน PDF กลับ
UX/UI 36 ชุด ~60 วิ 3 หน้า × 6 ขนาดจอ × 2 ภาษา
npm run test:unit    # เร็วที่สุด
npm run test:ui      # เบราว์เซอร์จริง
npm run test:demo    # พิสูจน์ว่าไฟล์ไม่ออกจากเครื่อง
npm run test:ux      # ตรวจ UX
npm test             # ทั้งหมด

unit test ที่ไม่มี framework สักตัว

ใช้ node --test เปล่า ๆ กับ TypeScript ที่ Node 22 ลบ type ให้เอง — ไม่มี Jest ไม่มี Vitest ไม่มี transpiler ไม่มี dependency เพิ่ม

แลกมาด้วยข้อจำกัดหนึ่งข้อ: ห้ามใช้ไวยากรณ์ TypeScript ที่ Node ลบทิ้งเองไม่ได้ (parameter property, enum, namespace) ซึ่งบังคับด้วย ESLint แล้ว เพราะถ้าเผลอใส่ โมดูลนั้นจะ unit test ไม่ได้อีกเลยโดยไม่มีอะไรเตือน

บั๊กจริงที่เทสต์ขุดเจอ (ไม่ได้ตั้งใจหา)

unit test ชุดแรกที่เขียน เจอบั๊กจริงทันที 5 ตัวที่ end-to-end test 4 ชุดมองไม่เห็นมาตลอด

บั๊ก อาการ
wrapText ตัดคำยาวเฉพาะตอนขึ้นต้นบรรทัด ข้อความล้นกรอบใน PDF ที่ export
snapAngle คืน 360 องศา ตั้งแต่ 353° ขึ้นไปปัดเป็น 360 แทนที่จะเป็น 0
getPlan('__proto__') คืน Object.prototype อ็อบเจกต์ที่ไม่มี limits → พังตอนอ่านค่าแรก
planAllows(plan, 'toString') คืน true ด่านตรวจสิทธิ์ตอบ "ใช่" ให้ชื่อที่ไม่มีใครประกาศ
mutate() ทิ้ง cache ที่พังไว้ callback ที่ throw กลางคันทิ้ง state ครึ่ง ๆ กลาง ๆ ไว้

ทุกตัวถูก mutation test ซ้ำ: ย้อนการแก้ทีละอัน แล้วยืนยันว่าเทสต์กลับไปแดงจริง

เทสต์ที่พิสูจน์คำสัญญาหลักของสินค้า

demo-test.mjs ดักทุก request ที่หน้าเว็บยิงออกไประหว่างการใช้งานเต็มรอบ (เปิดไฟล์ → แก้ → export) แล้ว fail ถ้ามี request ไหนไปที่ API — คำโฆษณาว่า "ไฟล์ไม่ออกจากเครื่อง" จึงเป็นสิ่งที่เทสต์คุมอยู่ ไม่ใช่แค่คำพูด


🎨 ตรวจ UX/UI อัตโนมัติ

ux-audit.mjs เปิดทุกหน้าในเบราว์เซอร์จริง แล้ววัดของจริง ไม่ได้อ่านจากโค้ด

npm run test:ux    # desktop + mobile ภาษาไทย (อยู่ใน npm test)
npm run audit:ux   # ครบทุกขนาด สองภาษา + ภาพหน้าจอทุกหน้า

สิ่งที่มันจับ และ fail จริง ไม่ใช่แค่เตือน

ตรวจอะไร เกณฑ์
ชื่อ key โผล่บนจอแทนข้อความ เทียบกับรายการ key จริงทั้ง 564 ตัว
คอนทราสต์ข้อความ 4.5:1 (WCAG 2.2 AA)
ขนาดปุ่มบนจอสัมผัส ≥ 24×24px บังคับ · 44×44px แนะนำ
ปุ่มที่หลุดออกนอกจอและเลื่อนไปหาไม่ได้ ต้องไม่มี
หน้าเลื่อนแนวนอน · id ซ้ำ · ลำดับหัวข้อข้าม ต้องไม่มี

ขนาดจอที่ตรวจรวม 1024×680 ซึ่งเป็นขนาดที่ไม่มีใครลองด้วยมือ

บั๊กในเครื่องตรวจเองก็เจอ 2 ตัว — มันอ่านสีด้วย regex rgb() แต่ Tailwind v4 เขียนสีเป็น lab() จึงคืน null แล้ว ข้าม องค์ประกอบที่ตัวเองมีหน้าที่ตรวจ (แก้เป็นวาดสีลง canvas แล้วอ่านพิกเซล) และมันใส่คุกกี้เดียวกันทุกหน้า ทำให้ /login redirect ไป /app แล้วรายงานผลผิดหน้า


🔭 สิ่งที่จะทำต่อ

แผนเต็มพร้อมเกณฑ์ตัดสินใจอยู่ใน docs/PRODUCT_DIRECTION.md สรุปลำดับ

กำลังทำ — เฟส 0

  • เก็บงานลง IndexedDB แล้วเปิดต่อได้เมื่อกลับมา (#9)
  • ตัวจำแนกชั้นไฟล์ A/B/C/D — บอกตั้งแต่วินาทีแรกว่าไฟล์นี้แก้ข้อความเดิมได้แค่ไหน (#11)
  • service worker เต็มรูปแบบสำหรับใช้ออฟไลน์ (#10)

ถัดไป — สิ่งที่ยากที่สุด

  • spike: ลบ text operator ออกจาก content stream แล้ววาดข้อความใหม่ทับ (#15) — นี่คือฟีเจอร์ที่ทั้งโปรเจกต์ ตั้งอยู่บนมัน ถ้าทำไม่ได้ในคุณภาพที่ยอมรับได้ แผนจะเปลี่ยน
  • สกัด text run แล้วจับกลุ่มเป็นบรรทัดและย่อหน้า (#17)
  • จับคู่ฟอนต์จาก PDF และบอกตรง ๆ เมื่อแทนที่ไม่ได้ (#19)
  • ตัดคำไทยและจัดบรรทัดใหม่ในกรอบเดิม (#20)

หลังจากนั้น

  • ไฟล์ .medf — บันทึกงานลงเครื่อง ย้ายเครื่องได้ (#21)
  • license key ตรวจแบบออฟไลน์ด้วย ed25519 + WebCrypto (#23)
  • redaction จริง (ลบข้อมูลออกจากไฟล์ ไม่ใช่วางทับ)
  • OCR สำหรับเอกสารสแกน — ปีที่ 2 เป็นอย่างเร็ว

👤 ผู้เขียน

สร้างโดย Suruch Boss

สนใจโปรเจกต์นี้ อยากคุยเรื่องงาน หรือมีคำถาม ทักมาได้เลยครับ


📄 License

MIT — เอาไปใช้ แก้ไข และใช้ในเชิงพาณิชย์ได้ฟรี

core ทั้งหมดในที่นี้เป็น MIT สิ่งเดียวที่ไม่อยู่ใน repo นี้คือโมดูลแก้ข้อความเดิมในไฟล์ ซึ่งอยู่ใน repo ส่วนตัวและต่อเข้ามาเป็น dependency ธรรมดา เหตุผลและขอบเขตอยู่ใน docs/OPEN_CORE.md

"private": true ใน package.json ยังอยู่และควรอยู่ต่อ — มันกัน npm publish พลาด ไม่เกี่ยวกับว่าโค้ดเปิดหรือปิด

About

Browser-based PDF editor built for Thai documents. Correct Thai typography, embedded fonts, and files that never leave your device.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages