สร้าง QR พร้อมเพย์ตามมาตรฐาน EMVCo ด้วยเบอร์โทร บัตรประชาชน หรือ e-Wallet แสดง QR ที่สแกนได้จริงและส่งออกเป็น SVG

สร้าง QR พร้อมเพย์ที่สแกนจ่ายได้จริง จากเบอร์โทร บัตรประชาชน หรือ e-Wallet จะล็อคยอดเงินหรือให้ลูกค้ากรอกยอดเองก็ได้ QR จะแสดงในแผงด้านข้าง (ดำบนพื้นขาว) และส่งออกเป็นไฟล์ SVG ไปพิมพ์แปะหน้าร้านได้ ก่อนสร้างจะตรวจเบอร์โทรและ check digit ของเลขบัตรประชาชนให้ด้วย
| คำสั่ง | ทำอะไร |
|---|---|
/qr 0812345678 100 ร้านส้มตำ | QR จากเบอร์โทร ยอด 100 บาท พร้อมชื่อร้าน |
/qr 0812345678 | ไม่ระบุยอด (ลูกค้ากรอกเองตอนสแกน) |
/qr id 1101700207030 500 | QR จากเลขบัตรประชาชน |
/qr wallet 123456789012345 200 | QR จากเลข e-Wallet 15 หลัก |
/qr list | ดูรายการ QR ที่สร้างไว้ |
/qr show <id> | แสดง QR ในแผง |
/qr payload <id> | ดู payload (ข้อความที่อยู่ใน QR) |
/qr export <id> | บันทึกเป็น promptpay-<id>.svg และ .txt |
/qr validate <เบอร์หรือเลขบัตร> | ตรวจว่าเลขถูกต้องไหม |
/qr delete <id> / /qr clear | ลบ / ลบทั้งหมด |
/qr demo | สร้างตัวอย่างด้วยเบอร์สมมติ |
/plugin install promptpay-qr-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
Mod นี้แบ่งเป็น 3 ไฟล์:
/qr และการวาด QR ในแผงด้วยตัวอักษรครึ่งบล็อก (▀ ▄ █) ให้ได้ช่องสี่เหลี่ยมจัตุรัสและสอนการ import ไฟล์อื่นของ Mod เอง (import { encodeQR } from './qr')
/qr export แทน1xxxxxxxx7030) แต่ใน QR และไฟล์ .txt เป็นเลขเต็มhooks/register.tsx 247 lines1/**
2 * ============================================================================
3 * MOD 14: PromptPay QR TH - สร้าง QR พร้อมเพย์ที่สแกนจ่ายได้จริง (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: promptpay-qr (https://github.com/dtinth/promptpay-qr) และมาตรฐาน Thai QR Payment
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /qr 0812345678 100 สร้าง QR พร้อมเพย์จากเบอร์โทร (หรือบัตรประชาชน / e-Wallet)
9 * 2. แสดง QR ที่สแกนได้จริงในแผงด้านข้าง (ดำบนพื้นขาว)
10 * 3. ส่งออกเป็น SVG เอาไปพิมพ์แปะหน้าร้านได้
11 * 4. ตรวจเบอร์และเลขบัตรประชาชน (มี check digit) ก่อนสร้าง
12 *
13 * ไฟล์ในโฟลเดอร์ hooks:
14 * - promptpay.ts : สร้าง payload ตามมาตรฐาน EMVCo + CRC16
15 * - qr.ts : แปลง payload เป็นภาพ QR (เขียนเองทั้งหมด เพราะ Mod ใช้ npm ไม่ได้)
16 * - register.tsx : คำสั่ง /qr และการวาดแผง (ไฟล์นี้)
17 */
18
19import type { EngineInterface, Register } from 'claude-code'
20
21import { buildPayload, digitsOnly, isValidPhone, isValidThaiId, kindOf } from './promptpay'
22import type { TargetKind } from './promptpay'
23import { encodeQR, qrToLines, qrToSvg } from './qr'
24
25interface SavedQR {
26 id: number
27 kind: TargetKind
28 target: string // ตัวเลขล้วน
29 amount?: number
30 name?: string
31 payload: string
32 createdAt: number
33}
34
35const PANE = 'promptpay-qr-th'
36const PROMPTPAY_BLUE = '#1a3c6e'
37const KIND_TH: Record<TargetKind, string> = { phone: '📱 เบอร์โทร', id: '🪪 บัตรประชาชน', wallet: '💳 e-Wallet' }
38
39// ==========================================================================
40// บทที่ 1: เก็บ QR ที่สร้างไว้ใน $.store (จำข้าม session)
41// ==========================================================================
42let saved: SavedQR[] = []
43let nextId = 0
44let showingId: number | undefined
45let projectRoot = ''
46
47async function persist($: EngineInterface): Promise<void> {
48 await $.store.set('qrs', { saved, nextId })
49}
50
51// ซ่อนเลขบางส่วนเวลาแสดง (ไม่ให้คนที่มองจอเห็นเลขบัตรเต็มๆ)
52function masked(q: SavedQR): string {
53 if (q.kind === 'phone') return q.target.replace(/^(\d{3})(\d{3})(\d+)$/, '$1-$2-$3')
54 return q.target.slice(0, 1) + 'x'.repeat(q.target.length - 5) + q.target.slice(-4)
55}
56
57const baht = (n: number) => `฿${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
58
59function describe(q: SavedQR): string {
60 return `#${q.id} ${KIND_TH[q.kind]} ${masked(q)} ${q.amount ? baht(q.amount) : '(ไม่ระบุยอด)'}${q.name ? ` - ${q.name}` : ''}`
61}
62
63// ==========================================================================
64// บทที่ 2: ตรวจข้อมูลก่อนสร้าง (เบอร์, เลขบัตร, ยอดเงิน)
65// ==========================================================================
66function checkTarget(kind: TargetKind, target: string): string | undefined {
67 if (kind === 'id' && !isValidThaiId(target)) return 'เลขบัตรประชาชนไม่ถูกต้อง (check digit ไม่ตรง)'
68 if (kind === 'wallet' && target.length !== 15) return 'เลข e-Wallet ต้องมี 15 หลัก'
69 if (kind === 'phone' && !isValidPhone(target)) return 'เบอร์โทรไม่ถูกต้อง (เช่น 0812345678)'
70 return undefined
71}
72
73// รับ "100", "1,500.50" -> ตัวเลข; ไม่ใช่ตัวเลขบวกที่ทศนิยมไม่เกิน 2 ตำแหน่ง -> undefined
74function parseAmount(text: string | undefined): number | undefined {
75 if (!text) return undefined
76 const clean = text.replace(/,/g, '')
77 if (!/^\d+(\.\d{1,2})?$/.test(clean)) return undefined
78 const value = Number(clean)
79 return value > 0 && value <= 9_999_999 ? value : undefined
80}
81
82// ==========================================================================
83// บทที่ 3: สร้าง QR แล้วเปิดแผงให้ดู
84// ==========================================================================
85async function create($: EngineInterface, kind: TargetKind, rawTarget: string, rest: string[]): Promise<{ text: string }> {
86 const target = digitsOnly(rawTarget)
87 const problem = checkTarget(kind, target)
88 if (problem) return { text: `❌ ${problem}` }
89
90 // คำแรกที่เป็นตัวเลข = ยอดเงิน, ที่เหลือ = ชื่อร้าน
91 const amount = parseAmount(rest[0])
92 if (rest[0] && amount === undefined && /^[\d.,]+$/.test(rest[0])) return { text: `❌ ยอดเงิน "${rest[0]}" ไม่ถูกต้อง (ทศนิยมได้ไม่เกิน 2 ตำแหน่ง)` }
93 const name = (amount === undefined ? rest : rest.slice(1)).join(' ') || undefined
94
95 const q: SavedQR = { id: nextId++, kind, target, amount, name, payload: buildPayload(target, amount), createdAt: await $.clock.now() }
96 saved = [...saved, q].slice(-30)
97 await persist($)
98 return show($, q, '✅ สร้าง QR แล้ว')
99}
100
101async function show($: EngineInterface, q: SavedQR, title: string): Promise<{ text: string }> {
102 showingId = q.id
103 const opened = await $.ui.open({ id: PANE, title: '💸 PromptPay QR' })
104 $.ui.invalidate('ui.render')
105 const note = opened.isPlaced ? 'ดู QR ในแผงด้านข้าง' : `เปิดแผงไม่ได้ ใช้ /qr export ${q.id} เพื่อได้ไฟล์ SVG`
106 return { text: `${title}: ${describe(q)}\n${note} | /qr export ${q.id} = ไฟล์ SVG สำหรับพิมพ์` }
107}
108
109function findQR(arg: string | undefined): SavedQR | undefined {
110 return saved.find(q => q.id === Number(arg))
111}
112
113const HELP = `💸 PromptPay QR TH (Mod 14)
114========================================
115สร้าง:
116 /qr <เบอร์โทร> [ยอด] [ชื่อร้าน] เช่น /qr 0812345678 100 ร้านส้มตำ
117 /qr id <เลขบัตร 13 หลัก> [ยอด] [ชื่อ]
118 /qr wallet <เลข 15 หลัก> [ยอด] [ชื่อ]
119 ไม่ใส่ยอด = ลูกค้ากรอกยอดเองตอนสแกน
120
121จัดการ:
122 /qr list | show <id> | payload <id> | export <id> | delete <id> | clear
123 /qr validate <เบอร์หรือเลขบัตร>
124 /qr demo`
125
126export const register: Register = on => {
127 on('session.start', async ($, e, next) => {
128 projectRoot = e.cwd
129 const stored = (await $.store.get('qrs')) as { saved?: SavedQR[]; nextId?: number } | undefined
130 saved = stored?.saved ?? []
131 nextId = stored?.nextId ?? 0
132 await $.command.register({ name: 'qr', description: 'สร้าง QR พร้อมเพย์ (/qr help)', argumentHint: '<เบอร์> [ยอด] [ชื่อร้าน]' })
133 return next(e)
134 })
135
136 // ==========================================================================
137 // บทที่ 4: วาด QR ในแผง - ดำบนพื้นขาวเสมอ (ไม่ขึ้นกับธีมของ terminal) ไม่งั้นกล้องอ่านไม่ออก
138 // ==========================================================================
139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140 const { Box, Text } = $.ui.resolve(e)
141 const q = showingId === undefined ? undefined : findQR(String(showingId))
142 if (!q) return <Text dimColor>ยังไม่มี QR สร้างด้วย /qr 0812345678 100</Text>
143
144 const lines = qrToLines(encodeQR(q.payload), 2)
145 return (
146 <Box flexDirection="column" alignItems="center">
147 <Box backgroundColor={PROMPTPAY_BLUE} paddingX={2}>
148 <Text color="#ffffff" bold>
149 PromptPay พร้อมเพย์
150 </Text>
151 </Box>
152 <Box flexDirection="column" backgroundColor="#ffffff" paddingX={1}>
153 {lines.map(line => (
154 <Text color="#000000" backgroundColor="#ffffff">
155 {line}
156 </Text>
157 ))}
158 </Box>
159 {q.name && <Text bold>{q.name}</Text>}
160 <Text>
161 {KIND_TH[q.kind]} {masked(q)}
162 </Text>
163 <Text bold color="#06c755">
164 {q.amount ? baht(q.amount) : 'ลูกค้ากรอกยอดเอง'}
165 </Text>
166 <Text dimColor>#{q.id} · /qr export {q.id} เพื่อพิมพ์</Text>
167 </Box>
168 )
169 })
170
171 // ==========================================================================
172 // บทที่ 5: คำสั่ง /qr
173 // ==========================================================================
174 on('command.run', { command: 'qr' }, async ($, e) => {
175 const words = e.args.trim().split(/\s+/).filter(Boolean)
176 const [sub = 'help', ...rest] = words
177
178 switch (sub) {
179 case 'help':
180 return { text: HELP }
181 case 'id':
182 return rest[0] ? create($, 'id', rest[0], rest.slice(1)) : { text: 'วิธีใช้: /qr id <เลขบัตร 13 หลัก> [ยอด] [ชื่อ]' }
183 case 'wallet':
184 return rest[0] ? create($, 'wallet', rest[0], rest.slice(1)) : { text: 'วิธีใช้: /qr wallet <เลข 15 หลัก> [ยอด] [ชื่อ]' }
185 case 'list':
186 return { text: saved.length ? ['💸 QR ที่สร้างไว้', ...saved.map(q => ` ${describe(q)}`)].join('\n') : 'ยังไม่มี QR ลอง /qr demo' }
187 case 'show': {
188 const q = findQR(rest[0])
189 return q ? show($, q, '💸 แสดง QR') : { text: `❌ ไม่พบ QR #${rest[0] ?? ''}` }
190 }
191 case 'payload': {
192 const q = findQR(rest[0])
193 return q ? { text: `Payload ของ QR #${q.id} (เอาไปสร้าง QR ด้วยโปรแกรมอื่นได้)\n${q.payload}` } : { text: `❌ ไม่พบ QR #${rest[0] ?? ''}` }
194 }
195 case 'export': {
196 const q = findQR(rest[0])
197 if (!q) return { text: `❌ ไม่พบ QR #${rest[0] ?? ''}` }
198 const sep = projectRoot.includes('\\') ? '\\' : '/'
199 const base = `${projectRoot}${sep}promptpay-${q.id}`
200 await $.fs.write(`${base}.svg`, qrToSvg(encodeQR(q.payload)))
201 await $.fs.write(`${base}.txt`, `${describe(q)}\nPayload: ${q.payload}\n`)
202 return { text: `💾 บันทึกแล้ว:\n ${base}.svg (เปิดใน browser แล้วพิมพ์ได้)\n ${base}.txt` }
203 }
204 case 'validate': {
205 const target = digitsOnly(rest[0] ?? '')
206 if (!target) return { text: 'วิธีใช้: /qr validate <เบอร์หรือเลขบัตร>' }
207 const kind = kindOf(target)
208 const problem = checkTarget(kind, target)
209 return { text: problem ? `❌ ${KIND_TH[kind]}: ${problem}` : `✅ ${KIND_TH[kind]} ถูกต้อง` }
210 }
211 case 'delete': {
212 const q = findQR(rest[0])
213 if (!q) return { text: `❌ ไม่พบ QR #${rest[0] ?? ''}` }
214 saved = saved.filter(x => x !== q)
215 await persist($)
216 if (showingId === q.id) showingId = undefined
217 $.ui.invalidate('ui.render')
218 return { text: `🗑️ ลบ QR #${q.id} แล้ว` }
219 }
220 case 'clear':
221 saved = []
222 showingId = undefined
223 await persist($)
224 $.ui.invalidate('ui.render')
225 return { text: '🗑️ ลบ QR ทั้งหมดแล้ว' }
226 case 'demo': {
227 // เบอร์ 0812345678 เป็นเบอร์ตัวอย่าง: สแกนได้ แต่อย่าโอนเงินจริง
228 await create($, 'phone', '0812345678', [])
229 return create($, 'phone', '0812345678', ['100', 'ร้านตัวอย่าง (ห้ามโอนจริง)'])
230 }
231 default:
232 // ไม่มี subcommand = เป็นเบอร์/เลขบัตรเลย: /qr 0812345678 100 ร้านส้มตำ
233 if (/^[\d-]+$/.test(sub)) return create($, kindOf(sub), sub, rest)
234 return { text: `❌ ไม่รู้จัก "${sub}"\n\n${HELP}` }
235 }
236 })
237}
238
239/**
240 * ========================================================================
241 * การบ้าน / ต่อยอด
242 * ========================================================================
243 * 1. ทำระบบ POS: ดัก prompt "คิดเงิน 3 รายการ" ให้ Claude รวมยอด แล้วเรียก create() สร้าง QR ทันที
244 * 2. ใส่ชื่อร้านลง payload ด้วย tag 59 (Merchant Name) ตามมาตรฐาน EMVCo
245 * 3. ส่ง QR (SVG) เข้า LINE ผ่าน Flex Message ของ Mod 12
246 */
247hooks/promptpay.ts 86 lines1/**
2 * ============================================================================
3 * สร้าง Payload ของ PromptPay QR ตามมาตรฐาน EMVCo (Thai QR Payment)
4 * ============================================================================
5 * ใช้วิธีเดียวกับ library promptpay-qr (https://github.com/dtinth/promptpay-qr)
6 *
7 * Payload คือชุดของ "TLV" = Tag (2 หลัก) + Length (2 หลัก) + Value
8 * เช่น "5802TH" = tag 58 (ประเทศ), ยาว 02, ค่า "TH"
9 */
10
11export type TargetKind = 'phone' | 'id' | 'wallet'
12
13// ==========================================================================
14// บทที่ 1: TLV - หัวใจของ EMVCo
15// ==========================================================================
16function tlv(tag: string, value: string): string {
17 return tag + String(value.length).padStart(2, '0') + value
18}
19
20// ==========================================================================
21// บทที่ 2: CRC16-CCITT (FALSE) - checksum 4 หลักท้าย payload
22// ==========================================================================
23// ถ้า CRC ผิด แอปธนาคารจะไม่ยอมสแกน
24export function crc16(data: string): string {
25 let crc = 0xffff
26 for (let i = 0; i < data.length; i++) {
27 crc ^= data.charCodeAt(i) << 8
28 for (let j = 0; j < 8; j++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff
29 }
30 return crc.toString(16).toUpperCase().padStart(4, '0')
31}
32
33// ==========================================================================
34// บทที่ 3: แปลงเบอร์ / บัตรประชาชน / e-Wallet ให้อยู่ในรูปที่ PromptPay ต้องการ
35// ==========================================================================
36export function digitsOnly(text: string): string {
37 return text.replace(/[^0-9]/g, '')
38}
39
40// เดาว่าเป็นอะไรจากจำนวนหลัก: 13 = บัตรประชาชน, 15 = e-Wallet, อื่นๆ = เบอร์โทร
41export function kindOf(target: string): TargetKind {
42 const n = digitsOnly(target).length
43 return n === 13 ? 'id' : n === 15 ? 'wallet' : 'phone'
44}
45
46// เบอร์โทร: 0812345678 -> 0066812345678 (ตัด 0 หน้า ใส่รหัสประเทศ 66 แล้วเติม 0 ให้ครบ 13 หลัก)
47export function formatTarget(target: string): string {
48 const digits = digitsOnly(target)
49 if (digits.length >= 13) return digits
50 return ('0000000000000' + digits.replace(/^0/, '66')).slice(-13)
51}
52
53// บัตรประชาชนไทย: หลักที่ 13 คือ check digit
54export function isValidThaiId(id: string): boolean {
55 const digits = digitsOnly(id)
56 if (digits.length !== 13) return false
57 let sum = 0
58 for (let i = 0; i < 12; i++) sum += Number(digits[i]) * (13 - i)
59 return (11 - (sum % 11)) % 10 === Number(digits[12])
60}
61
62export function isValidPhone(phone: string): boolean {
63 return /^0[689]\d{8}$/.test(digitsOnly(phone)) || /^0[2-7]\d{7}$/.test(digitsOnly(phone))
64}
65
66// ==========================================================================
67// บทที่ 4: ประกอบ Payload
68// ==========================================================================
69const PROMPTPAY_AID = 'A000000677010111' // รหัสของ PromptPay (ธุรกรรมแบบ credit transfer)
70const TAG_OF: Record<TargetKind, string> = { phone: '01', id: '02', wallet: '03' }
71
72export function buildPayload(target: string, amount?: number): string {
73 const kind = kindOf(target)
74 const value = kind === 'phone' ? formatTarget(target) : digitsOnly(target)
75 const parts = [
76 tlv('00', '01'), // Payload Format Indicator
77 tlv('01', amount ? '12' : '11'), // 11 = QR ใช้ซ้ำได้ (ลูกค้าใส่ยอดเอง), 12 = QR ครั้งเดียว (ล็อคยอด)
78 tlv('29', tlv('00', PROMPTPAY_AID) + tlv(TAG_OF[kind], value)), // ข้อมูลผู้รับเงิน
79 tlv('58', 'TH'), // ประเทศ
80 tlv('53', '764'), // สกุลเงินบาท (ISO 4217)
81 ]
82 if (amount) parts.push(tlv('54', amount.toFixed(2)))
83 const withoutCrc = parts.join('') + '6304' // tag 63 ยาว 04 = CRC ต่อท้าย
84 return withoutCrc + crc16(withoutCrc)
85}
86hooks/qr.ts 340 lines1/**
2 * ============================================================================
3 * ตัวสร้าง QR Code แบบเขียนเอง (ใช้ใน Mod 14)
4 * ============================================================================
5 * Mod รันในที่ที่ไม่มี npm จึงเขียนตัวสร้าง QR เอง ตามมาตรฐาน ISO/IEC 18004
6 * รองรับ: byte mode, ระดับแก้ผิด M (กู้ได้ ~15%), เวอร์ชัน 1-10 (สูงสุด ~213 ตัวอักษร)
7 * แนวทางอ้างอิงจาก QR Code generator library ของ Project Nayuki (MIT)
8 *
9 * ผลลัพธ์คือ boolean[][] : true = ช่องสีดำ
10 */
11
12// ==========================================================================
13// บทที่ 1: ตารางของระดับแก้ผิด M (index = เวอร์ชัน)
14// ==========================================================================
15const ECC_PER_BLOCK = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26]
16const NUM_BLOCKS = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5]
17const MAX_VERSION = 10
18
19// จำนวนช่องที่ใช้เก็บข้อมูลได้จริง (หักส่วนที่เป็นลวดลายคงที่ออก)
20function rawDataModules(ver: number): number {
21 let result = (16 * ver + 128) * ver + 64
22 if (ver >= 2) {
23 const align = Math.floor(ver / 7) + 2
24 result -= (25 * align - 10) * align - 55
25 if (ver >= 7) result -= 36
26 }
27 return result
28}
29
30function dataCodewords(ver: number): number {
31 return Math.floor(rawDataModules(ver) / 8) - (ECC_PER_BLOCK[ver] ?? 0) * (NUM_BLOCKS[ver] ?? 1)
32}
33
34// ==========================================================================
35// บทที่ 2: Reed-Solomon (ตัวแก้ผิดของ QR) ใน GF(256)
36// ==========================================================================
37function gfMul(x: number, y: number): number {
38 let z = 0
39 for (let i = 7; i >= 0; i--) {
40 z = (z << 1) ^ ((z >>> 7) * 0x11d)
41 z ^= ((y >>> i) & 1) * x
42 }
43 return z & 0xff
44}
45
46function rsDivisor(degree: number): number[] {
47 const result = Array<number>(degree).fill(0)
48 result[degree - 1] = 1
49 let root = 1
50 for (let i = 0; i < degree; i++) {
51 for (let j = 0; j < degree; j++) {
52 result[j] = gfMul(result[j] ?? 0, root)
53 if (j + 1 < degree) result[j] = (result[j] ?? 0) ^ (result[j + 1] ?? 0)
54 }
55 root = gfMul(root, 0x02)
56 }
57 return result
58}
59
60function rsRemainder(data: number[], divisor: number[]): number[] {
61 const result = divisor.map(() => 0)
62 for (const b of data) {
63 const factor = b ^ (result.shift() ?? 0)
64 result.push(0)
65 divisor.forEach((coef, i) => {
66 result[i] = (result[i] ?? 0) ^ gfMul(coef, factor)
67 })
68 }
69 return result
70}
71
72// ==========================================================================
73// บทที่ 3: แปลงข้อความเป็น codeword (mode, ความยาว, ข้อมูล, pad) + เติม ECC
74// ==========================================================================
75function encodeCodewords(bytes: number[], ver: number): number[] {
76 const bits: number[] = []
77 const put = (value: number, length: number) => {
78 for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1)
79 }
80 const capacity = dataCodewords(ver) * 8
81 put(0b0100, 4) // byte mode
82 put(bytes.length, ver <= 9 ? 8 : 16)
83 bytes.forEach(b => put(b, 8))
84 put(0, Math.min(4, capacity - bits.length)) // terminator
85 put(0, (8 - (bits.length % 8)) % 8)
86 for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8)
87
88 const data: number[] = []
89 for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((acc, bit) => (acc << 1) | bit, 0))
90
91 // แบ่งเป็น block แล้วเติม ECC ของแต่ละ block
92 const numBlocks = NUM_BLOCKS[ver] ?? 1
93 const eccLen = ECC_PER_BLOCK[ver] ?? 0
94 const rawCodewords = Math.floor(rawDataModules(ver) / 8)
95 const numShort = numBlocks - (rawCodewords % numBlocks)
96 const shortLen = Math.floor(rawCodewords / numBlocks)
97 const divisor = rsDivisor(eccLen)
98 const blocks: number[][] = []
99 for (let i = 0, k = 0; i < numBlocks; i++) {
100 const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1))
101 k += dat.length
102 const ecc = rsRemainder(dat, divisor)
103 if (i < numShort) dat.push(-1) // ช่องว่างให้ block สั้นยาวเท่า block ยาว
104 blocks.push([...dat, ...ecc])
105 }
106
107 // สลับลำดับ (interleave): หยิบ byte ที่ i ของทุก block
108 const result: number[] = []
109 for (let i = 0; i < (blocks[0]?.length ?? 0); i++) {
110 blocks.forEach((block, j) => {
111 if (i !== shortLen - eccLen || j >= numShort) result.push(block[i] ?? 0)
112 })
113 }
114 return result
115}
116
117// ==========================================================================
118// บทที่ 4: วาดลวดลายคงที่ (finder, timing, alignment, format, version)
119// ==========================================================================
120class Matrix {
121 readonly ver: number
122 readonly size: number
123 readonly dark: boolean[][]
124 readonly fixed: boolean[][]
125
126 constructor(ver: number) {
127 this.ver = ver
128 this.size = ver * 4 + 17
129 this.dark = Array.from({ length: this.size }, () => Array<boolean>(this.size).fill(false))
130 this.fixed = Array.from({ length: this.size }, () => Array<boolean>(this.size).fill(false))
131 }
132
133 set(x: number, y: number, isDark: boolean, isFixed = true): void {
134 const row = this.dark[y]
135 const fixedRow = this.fixed[y]
136 if (!row || !fixedRow || x < 0 || x >= this.size) return
137 row[x] = isDark
138 if (isFixed) fixedRow[x] = true
139 }
140}
141
142function alignmentPositions(ver: number): number[] {
143 if (ver === 1) return []
144 const count = Math.floor(ver / 7) + 2
145 const step = Math.ceil((ver * 4 + 4) / (count * 2 - 2)) * 2
146 const result = [6]
147 for (let pos = ver * 4 + 17 - 7; result.length < count; pos -= step) result.splice(1, 0, pos)
148 return result
149}
150
151function drawFormat(m: Matrix, mask: number): void {
152 const data = (0 << 3) | mask // ระดับ M = 00
153 let rem = data
154 for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
155 const bits = ((data << 10) | rem) ^ 0x5412
156 const bit = (i: number) => ((bits >>> i) & 1) === 1
157 for (let i = 0; i <= 5; i++) m.set(8, i, bit(i))
158 m.set(8, 7, bit(6))
159 m.set(8, 8, bit(7))
160 m.set(7, 8, bit(8))
161 for (let i = 9; i < 15; i++) m.set(14 - i, 8, bit(i))
162 for (let i = 0; i < 8; i++) m.set(m.size - 1 - i, 8, bit(i))
163 for (let i = 8; i < 15; i++) m.set(8, m.size - 15 + i, bit(i))
164 m.set(8, m.size - 8, true) // ช่องดำบังคับ
165}
166
167function drawFixedPatterns(m: Matrix): void {
168 for (let i = 0; i < m.size; i++) {
169 m.set(6, i, i % 2 === 0)
170 m.set(i, 6, i % 2 === 0)
171 }
172 // finder 3 มุม
173 for (const [cx, cy] of [[3, 3], [m.size - 4, 3], [3, m.size - 4]] as const) {
174 for (let dy = -4; dy <= 4; dy++) {
175 for (let dx = -4; dx <= 4; dx++) {
176 const dist = Math.max(Math.abs(dx), Math.abs(dy))
177 const x = cx + dx
178 const y = cy + dy
179 if (x >= 0 && x < m.size && y >= 0 && y < m.size) m.set(x, y, dist !== 2 && dist !== 4)
180 }
181 }
182 }
183 // alignment (เว้นตำแหน่งที่ทับ finder)
184 const pos = alignmentPositions(m.ver)
185 const last = pos.length - 1
186 pos.forEach((y, i) =>
187 pos.forEach((x, j) => {
188 if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) return
189 for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) m.set(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
190 }),
191 )
192 drawFormat(m, 0) // จองตำแหน่งไว้ก่อน วาดจริงตอนเลือก mask
193 if (m.ver >= 7) {
194 let rem = m.ver
195 for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
196 const bits = (m.ver << 12) | rem
197 for (let i = 0; i < 18; i++) {
198 const isDark = ((bits >>> i) & 1) === 1
199 const a = m.size - 11 + (i % 3)
200 const b = Math.floor(i / 3)
201 m.set(a, b, isDark)
202 m.set(b, a, isDark)
203 }
204 }
205}
206
207// วางข้อมูลแบบซิกแซกจากมุมขวาล่าง ทีละ 2 คอลัมน์
208function drawData(m: Matrix, codewords: number[]): void {
209 let i = 0
210 for (let right = m.size - 1; right >= 1; right -= 2) {
211 if (right === 6) right = 5
212 for (let vert = 0; vert < m.size; vert++) {
213 for (let j = 0; j < 2; j++) {
214 const x = right - j
215 const upward = ((right + 1) & 2) === 0
216 const y = upward ? m.size - 1 - vert : vert
217 if (!m.fixed[y]?.[x] && i < codewords.length * 8) {
218 m.set(x, y, (((codewords[i >>> 3] ?? 0) >>> (7 - (i & 7))) & 1) === 1, false)
219 i++
220 }
221 }
222 }
223 }
224}
225
226// ==========================================================================
227// บทที่ 5: mask - กลับสีบางช่องให้ลายไม่ซ้ำจนกล้องอ่านยาก แล้วเลือกอันที่ดีที่สุด
228// ==========================================================================
229const MASKS: ((x: number, y: number) => boolean)[] = [
230 (x, y) => (x + y) % 2 === 0,
231 (_x, y) => y % 2 === 0,
232 x => x % 3 === 0,
233 (x, y) => (x + y) % 3 === 0,
234 (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
235 (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
236 (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
237 (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
238]
239
240function applyMask(m: Matrix, mask: number): void {
241 const fn = MASKS[mask] ?? MASKS[0]!
242 for (let y = 0; y < m.size; y++) {
243 for (let x = 0; x < m.size; x++) {
244 if (!m.fixed[y]?.[x] && fn(x, y)) m.set(x, y, !m.dark[y]?.[x], false)
245 }
246 }
247}
248
249// คะแนนโทษตามมาตรฐาน (น้อย = ดี): แถวสีเดียวยาว, บล็อก 2x2, ลายคล้าย finder, สัดส่วนดำ/ขาว
250function penalty(m: Matrix): number {
251 const n = m.size
252 const at = (x: number, y: number) => m.dark[y]?.[x] === true
253 let score = 0
254 const lines: boolean[][] = []
255 for (let i = 0; i < n; i++) {
256 lines.push(Array.from({ length: n }, (_, k) => at(k, i)))
257 lines.push(Array.from({ length: n }, (_, k) => at(i, k)))
258 }
259 for (const line of lines) {
260 let run = 1
261 for (let k = 1; k <= n; k++) {
262 if (k < n && line[k] === line[k - 1]) run++
263 else {
264 if (run >= 5) score += run - 2
265 run = 1
266 }
267 }
268 const text = line.map(d => (d ? '1' : '0')).join('')
269 score += 40 * (text.match(/(?=(00001011101|10111010000))/g)?.length ?? 0)
270 }
271 for (let y = 0; y < n - 1; y++) {
272 for (let x = 0; x < n - 1; x++) {
273 const c = at(x, y)
274 if (c === at(x + 1, y) && c === at(x, y + 1) && c === at(x + 1, y + 1)) score += 3
275 }
276 }
277 const darkCount = m.dark.flat().filter(Boolean).length
278 score += Math.floor(Math.abs(darkCount * 20 - n * n * 10) / (n * n)) * 10
279 return score
280}
281
282// ==========================================================================
283// บทที่ 6: รวมทุกขั้น - เลือกเวอร์ชันเล็กสุดที่พอ แล้วลองทุก mask
284// ==========================================================================
285export function encodeQR(text: string): boolean[][] {
286 const bytes = [...new TextEncoder().encode(text)]
287 let ver = 1
288 while (ver <= MAX_VERSION && dataCodewords(ver) * 8 < 4 + (ver <= 9 ? 8 : 16) + bytes.length * 8) ver++
289 if (ver > MAX_VERSION) throw new Error(`ข้อความยาวเกินไป (${bytes.length} bytes)`)
290
291 const codewords = encodeCodewords(bytes, ver)
292 let best: Matrix | undefined
293 let bestScore = Infinity
294 for (let mask = 0; mask < 8; mask++) {
295 const m = new Matrix(ver)
296 drawFixedPatterns(m)
297 drawData(m, codewords)
298 applyMask(m, mask)
299 drawFormat(m, mask)
300 const score = penalty(m)
301 if (score < bestScore) {
302 best = m
303 bestScore = score
304 }
305 }
306 return best?.dark ?? []
307}
308
309// ==========================================================================
310// บทที่ 7: แปลง QR เป็นข้อความ (ครึ่งบล็อก 2 แถวต่อบรรทัด) และ SVG
311// ==========================================================================
312// ใช้ ▀ ▄ █ วาดสีดำบนพื้นขาว 1 ตัวอักษร = กว้าง 1 ช่อง สูง 2 ช่อง จึงได้สัดส่วนจัตุรัส
313export function qrToLines(modules: boolean[][], quiet = 2): string[] {
314 const n = modules.length
315 const at = (x: number, y: number) => x >= 0 && y >= 0 && x < n && y < n && modules[y]?.[x] === true
316 const lines: string[] = []
317 for (let y = -quiet; y < n + quiet; y += 2) {
318 let line = ''
319 for (let x = -quiet; x < n + quiet; x++) {
320 const top = at(x, y)
321 const bottom = at(x, y + 1)
322 line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
323 }
324 lines.push(line)
325 }
326 return lines
327}
328
329export function qrToSvg(modules: boolean[][], scale = 10, quiet = 4): string {
330 const n = modules.length
331 const size = (n + quiet * 2) * scale
332 let path = ''
333 modules.forEach((row, y) =>
334 row.forEach((dark, x) => {
335 if (dark) path += `M${(x + quiet) * scale},${(y + quiet) * scale}h${scale}v${scale}h-${scale}z`
336 }),
337 )
338 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="100%" height="100%" fill="#ffffff"/><path d="${path}" fill="#000000"/></svg>`
339}
340