ลากไฟล์ PDF เข้ามา แล้วแก้ได้เลยในเบราว์เซอร์ เติมข้อความ แทรกรูป เซ็นชื่อ วางตราประทับ หรือไฮไลต์ แล้วกด Export กลับออกมาเป็น PDF ที่ข้อความเดิมยังคมชัด คัดลอกได้ และค้นหาได้ เพราะหน้าต้นฉบับถูก คัดลอก มาทั้งแผ่นแล้ววาดทับ ไม่ได้แปลงเป็นรูปภาพ
ไม่มีเซิร์ฟเวอร์ ไม่มีการอัปโหลด ไม่มีบัญชีผู้ใช้ — ไฟล์ของคุณอยู่ในแท็บเบราว์เซอร์เท่านั้น ติดตั้งเป็นแอปบนเครื่องได้ (PWA) แล้วใช้ต่อแบบออฟไลน์
จุดที่ต่างจากเครื่องมือต่างชาติคือ ภาษาไทยไม่พัง — ฝัง Sarabun ครบทุกน้ำหนัก วางสระและวรรณยุกต์ตามตำแหน่งจริง และตัดคำด้วย
Intl.Segmenter('th')ของเบราว์เซอร์เอง
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.
- ทำไมถึงทำโปรเจกต์นี้
- ลองใน 60 วินาที
- วิธีรัน
- ฟีเจอร์
- เทคโนโลยีที่ใช้
- สถาปัตยกรรม
- โครงสร้างโปรเจกต์
- ระบบพิกัด 3 ชั้น
- ระบบสองภาษา (i18n)
- การทดสอบ
- ตรวจ UX/UI อัตโนมัติ
- สิ่งที่จะทำต่อ
- ผู้เขียน
- License
ปัญหาจริงคือ "ไม่มีไฟล์ต้นฉบับแล้ว" ไม่ใช่ "อยากวางของทับ PDF"
คนทำงานเอกสารมี PDF อยู่ในมือ แต่ไฟล์ Word หรือ InDesign ต้นทางหายไปแล้ว — คนทำลาออก เอเจนซี่คิดเงินเพิ่ม หรือผ่านมาสองปีไม่มีใครหาเจอ พอต้องแก้เลขสัญญาบรรทัดเดียว ทางเลือกที่เหลือคือพิมพ์ใหม่ทั้งหน้า หรือหาโปรแกรมเถื่อนมาลง
เครื่องมือที่มีอยู่ตอบไม่ตรงสามข้อ
- เว็บแก้ PDF ส่วนใหญ่ต้องอัปโหลดไฟล์ขึ้นเซิร์ฟเวอร์ต่างประเทศ ซึ่งองค์กรไทยที่มีนโยบาย ข้อมูลใช้ไม่ได้เลย สัญญา ใบเสนอราคา และเอกสาร HR ไม่ควรออกจากเครื่อง
- Acrobat/Foxit เถื่อน ราคา 0 บาท คือคู่แข่งอันดับหนึ่งจริง ๆ แผนที่ไม่มีคำตอบให้ข้อนี้ คือแผนที่ใช้ไม่ได้ — คำตอบของ MeDF คือ ของเถื่อนก็แก้ภาษาไทยพังเหมือนกัน และ บางองค์กรใช้ของเถื่อนไม่ได้ เพราะโดนตรวจลิขสิทธิ์
- เครื่องมือต่างชาติทำภาษาไทยไม่ดี สระลอย วรรณยุกต์เพี้ยน ฟอนต์เปลี่ยนเอง และตัดบรรทัดผิดเพราะภาษาไทยไม่มีช่องว่างระหว่างคำ
โฟกัสของโปรเจกต์จึงอยู่ที่ ความถูกต้องของภาษาไทย + ไฟล์ไม่ออกจากเครื่อง
มากกว่าจำนวนฟีเจอร์ ทิศทางระยะยาวและเกณฑ์ที่จะใช้ตัดสินใจหยุด อยู่ใน
docs/PRODUCT_DIRECTION.md
- เปิด หน้าลองใช้ — ไม่ต้องสมัคร ไม่ต้องติดตั้ง
- ลากไฟล์ PDF เข้ามา (หรือกด "ใช้เอกสารตัวอย่าง" ถ้ายังไม่มีไฟล์ในมือ) → ระบบสร้างสัญญาจ้างภาษาไทยให้ในเบราว์เซอร์
- กด T แล้วคลิกบนหน้า → พิมพ์ข้อความไทยลงไป ลากไปวางตรงไหนก็ได้
- กด Export PDF → ได้ไฟล์กลับมา ลองเปิดแล้ว ลากคลุมข้อความเดิม ดู จะเห็นว่ายังคัดลอกได้ ไม่ได้กลายเป็นรูป
เปิด DevTools → Network ไว้ด้วยก็ได้ จะเห็นว่าไม่มี request ไหนส่งไฟล์ออกไปเลย (มีเทสต์คุมข้อนี้อยู่จริง — ดูหัวข้อการทดสอบ)
ประมาณ 2 นาที · ไม่ต้องมีฐานข้อมูล ไม่ต้องมี backend ไม่ต้องตั้งค่า environment อะไรเลย
git clone https://github.com/SuruchBoss/MeDF.git
cd MeDFต้องมี: Node.js 20.9 ขึ้นไป (แนะนำ 22 — เทสต์ระดับ unit ใช้ TypeScript ของ Node โดยตรง)
npm install
npm run dev # เปิด http://localhost:4173เท่านี้ครบ ไม่มีไฟล์ .env ที่ต้องเตรียม เพราะไม่มีอะไรต้องเชื่อมต่อ
npm run build # ได้ไฟล์ทั้งเว็บที่ apps/web/out/
npm run preview # เสิร์ฟโฟลเดอร์นั้นที่ http://localhost:4173out/ เป็นไฟล์ล้วน เอาไปวางบน GitHub Pages, Netlify, S3, nginx หรือแฟลชไดรฟ์ก็ได้
มี 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 |
| ชนิด | คีย์ลัด | ใช้ทำอะไร |
|---|---|---|
| กล่องข้อความ | T |
เติมข้อมูลในช่องว่าง แก้ตัวเลข เพิ่มหมายเหตุ |
| รูปภาพ | I |
โลโก้ ตราประทับ รูปถ่าย |
| ลายเซ็น | S |
วาดด้วยเมาส์หรือนิ้ว |
| สี่เหลี่ยม | R |
ปิดทับข้อความเดิม หรือทำกรอบ |
| วงกลม | O |
วงเน้นข้อความ |
| เส้น | L |
ขีดฆ่า ลูกศรชี้ |
| ไฮไลต์ | H |
เน้นข้อความด้วยสีโปร่ง |
| เครื่องหมายถูก | K |
ติ๊กช่องในแบบฟอร์ม |
ทุกชนิดมี type ของตัวเองใน discriminated union เดียว และแผงคุณสมบัติจะเปลี่ยนตามชนิดที่เลือก — กล่องข้อความได้ฟอนต์/ขนาด/การจัดวาง ส่วนเส้นได้หัวลูกศร ไม่ปนกัน
นี่คือหัวใจของโปรเจกต์ และเป็นจุดที่เครื่องมือหลายตัวทำพลาด
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 ไม่ใช่หนึ่งขั้นต่อหนึ่ง pointermove — ตอนกด pointer จะ
dispatch({ type: 'checkpoint' }) ครั้งเดียว จากนั้นทุก update ระหว่างลากไม่บันทึกประวัติ
กด Ctrl+Z ทีเดียวจึงย้อนทั้งท่า ไม่ใช่ย้อนไป 1 พิกเซล
แผงคุณสมบัติกว้าง 288px เคยกินพื้นที่ 74% ของจอ 390px จนเหลือที่ให้เอกสารแค่ 102px —
ตอนนี้ต่ำกว่า lg มันเป็นลิ้นชักที่เปิดจากปุ่มบนแถบเครื่องมือ และปิดด้วย Escape
เอกสารจึงได้ความกว้างเต็มจอ ปุ่มทุกปุ่มบนจอสัมผัสมีขนาดอย่างน้อย 44×44px
สลับได้จากหัวเว็บ ไม่มี route แยกภาษา ไม่มีการโหลดหน้าใหม่จากเซิร์ฟเวอร์ รายละเอียดด้านล่าง
กด "ติดตั้ง" จากแถบที่อยู่ของ Chrome/Edge แล้วได้ไอคอนบนเดสก์ท็อป หน้าต่างของตัวเอง และใช้งานออฟไลน์ได้ — มี manifest, ไอคอน maskable สำหรับ Android และ apple-touch-icon สำหรับ iOS ครบ
เคยมีแอป Electron สำหรับ Windows แต่พักไว้แล้ว เพราะตัวติดตั้งที่ไม่ได้เซ็น certificate โดน SmartScreen เตือน ส่วน PWA ให้คุณค่าเดียวกันโดยไม่ต้องจ่ายค่า certificate รายปี โค้ดเดิมอยู่ที่ branch
archive/desktop— เหตุผลเต็มอยู่ในdocs/WINDOWS.md
| รุ่น | ทำไมถึงเลือก | |
|---|---|---|
| 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 ทั้งหมด
เอดิเตอร์ไม่รู้ว่าเอกสารมาจากไหน มันคุยผ่าน 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 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
ส่วนที่ผิดง่ายที่สุดในโปรเจกต์ และเป็นเหตุผลที่ geometry.ts กับ matrix.ts ถูกแยกออกมา
เป็นฟังก์ชันบริสุทธิ์ที่มี unit test คุมทุกตัว
| ชั้น | หน่วย | จุดกำเนิด |
|---|---|---|
| point (1/72 นิ้ว) | ซ้ายล่าง | |
| หน้าจอ | CSS pixel × zoom | ซ้ายบน |
| องค์ประกอบที่หมุน | point ในระบบพิกัดของตัวเอง | มุมซ้ายบนของกล่อง |
สามอย่างที่ต้องระวัง
- แกน Y กลับด้าน ระหว่าง PDF กับหน้าจอ
- หน้าที่หมุน 90° สลับความกว้างกับความสูง —
page-geometry.tsจัดการที่เดียว - ลากองค์ประกอบที่หมุนแล้ว ต้องแปลงเวกเตอร์การเคลื่อนที่กลับเข้าระบบพิกัดของกล่อง
ไม่งั้นลากขวาแล้วของวิ่งเฉียง —
deltaToBaseSpace()และมีเทสต์ยืนยันว่ามันตรงกับtoBaseSpace()ทุกมุม
เคยเป็นบั๊กจริง:
snapAngleคืนค่า 360 องศา เพราะ -7° normalise เป็น 353° แล้วปัดขึ้น ควรได้ 0 — unit test จับได้ ทั้งที่มองด้วยตาไม่มีทางเห็น
เขียนเอง ~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 # ทั้งหมดใช้ 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-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 แล้วอ่านพิกเซล) และมันใส่คุกกี้เดียวกันทุกหน้า ทำให้/loginredirect ไป/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
- GitHub — github.com/SuruchBoss
สนใจโปรเจกต์นี้ อยากคุยเรื่องงาน หรือมีคำถาม ทักมาได้เลยครับ
MIT — เอาไปใช้ แก้ไข และใช้ในเชิงพาณิชย์ได้ฟรี
core ทั้งหมดในที่นี้เป็น MIT สิ่งเดียวที่ไม่อยู่ใน repo นี้คือโมดูลแก้ข้อความเดิมในไฟล์
ซึ่งอยู่ใน repo ส่วนตัวและต่อเข้ามาเป็น dependency ธรรมดา เหตุผลและขอบเขตอยู่ใน
docs/OPEN_CORE.md
"private": true ใน package.json ยังอยู่และควรอยู่ต่อ — มันกัน npm publish พลาด
ไม่เกี่ยวกับว่าโค้ดเปิดหรือปิด