SLOPSHOPPER

promptpay-qr-th

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

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · promptpay-qr-th
│ ┃ promptpay-qr-th ✕ › fix the failing auth test and add an audit log call │ ┃ ยังไม่มี QR สร้างด้วย /qr 0812345678 100 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /qr │ ⎿ promptpay-qr-th: 💸 PromptPay QR TH (Mod 14) │ ⎿ promptpay-qr-th: ======================================== │ ⎿ promptpay-qr-th: สร้าง: │ ⎿ promptpay-qr-th: /qr <เบอร์โทร> [ยอด] [ชื่อร้าน] เช่น / │ ⎿ promptpay-qr-th: /qr id <เลขบัตร 13 หลัก> [ยอด] [ชื่อ] │ ⎿ promptpay-qr-th: /qr wallet <เลข 15 หลัก> [ยอด] [ชื่อ] │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · promptpay-qr-th
ยังไม่มี QR สร้างด้วย /qr 0812345678 100
README

Mod 14: PromptPay QR TH

สร้าง QR พร้อมเพย์ที่สแกนจ่ายได้จริง จากเบอร์โทร บัตรประชาชน หรือ e-Wallet จะล็อคยอดเงินหรือให้ลูกค้ากรอกยอดเองก็ได้ QR จะแสดงในแผงด้านข้าง (ดำบนพื้นขาว) และส่งออกเป็นไฟล์ SVG ไปพิมพ์แปะหน้าร้านได้ ก่อนสร้างจะตรวจเบอร์โทรและ check digit ของเลขบัตรประชาชนให้ด้วย

คำสั่ง

คำสั่งทำอะไร
/qr 0812345678 100 ร้านส้มตำQR จากเบอร์โทร ยอด 100 บาท พร้อมชื่อร้าน
/qr 0812345678ไม่ระบุยอด (ลูกค้ากรอกเองตอนสแกน)
/qr id 1101700207030 500QR จากเลขบัตรประชาชน
/qr wallet 123456789012345 200QR จากเลข 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 ไฟล์:

  1. hooks/promptpay.ts สร้าง payload ตามมาตรฐาน EMVCo: TLV, CRC16-CCITT, แปลงเบอร์ 08x เป็น 0066 และตรวจ check digit ของบัตรประชาชน
  2. hooks/qr.ts ตัวสร้าง QR ที่เขียนเองทั้งหมด (Reed-Solomon, ลวดลาย finder/timing/alignment, วางข้อมูลแบบซิกแซก, เลือก mask) เพราะ Mod ใช้ library จาก npm ไม่ได้
  3. hooks/register.tsx คำสั่ง /qr และการวาด QR ในแผงด้วยตัวอักษรครึ่งบล็อก (▀ ▄ █) ให้ได้ช่องสี่เหลี่ยมจัตุรัส

และสอนการ import ไฟล์อื่นของ Mod เอง (import { encodeQR } from './qr')

หมายเหตุ

  • ทดสอบแล้วว่า payload ตรงกับ library promptpay-qr และ QR ทุกขนาด (เวอร์ชัน 1–10) ถอดกลับด้วย OpenCV ได้ถูกต้อง แต่ยังไม่ได้ลองสแกนจ่ายจริงด้วยแอปธนาคาร ควรลองโอนยอดเล็กๆ ก่อนใช้จริง
  • ถ้าสแกน QR จากหน้าจอไม่ติด ให้ขยายหน้าต่างหรือใช้ไฟล์ SVG จาก /qr export แทน
  • เลขในแผงจะซ่อนบางหลักไว้ (เช่น 1xxxxxxxx7030) แต่ใน QR และไฟล์ .txt เป็นเลขเต็ม
Source 3 files
hooks/register.tsx 247 lines
1/**
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 */
247
hooks/promptpay.ts 86 lines
1/**
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}
86
hooks/qr.ts 340 lines
1/**
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