feat: qr code
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { createQrCodeMatrix } from '@/utils/qrcode'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: string
|
||||||
|
size?: number
|
||||||
|
}>(), {
|
||||||
|
size: 180,
|
||||||
|
})
|
||||||
|
|
||||||
|
const quietZone = 4
|
||||||
|
|
||||||
|
const qrCode = computed(() => createQrCodeMatrix(props.value))
|
||||||
|
const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2)
|
||||||
|
|
||||||
|
const darkModules = computed(() => {
|
||||||
|
return qrCode.value.modules.flatMap((row, y) => {
|
||||||
|
return row
|
||||||
|
.map((dark, x) => ({ dark, x: x + quietZone, y: y + quietZone }))
|
||||||
|
.filter(module => module.dark)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
aria-label="報到 QR Code"
|
||||||
|
class="qr-code"
|
||||||
|
:height="size"
|
||||||
|
role="img"
|
||||||
|
shape-rendering="crispEdges"
|
||||||
|
:viewBox="`0 0 ${viewBoxSize} ${viewBoxSize}`"
|
||||||
|
:width="size"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect fill="#ffffff" height="100%" width="100%" />
|
||||||
|
|
||||||
|
<rect
|
||||||
|
v-for="module in darkModules"
|
||||||
|
:key="`${module.x}-${module.y}`"
|
||||||
|
fill="#000000"
|
||||||
|
height="1"
|
||||||
|
width="1"
|
||||||
|
:x="module.x"
|
||||||
|
:y="module.y"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-code {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<Layout>
|
<Layout>
|
||||||
<template #main>
|
<template #main>
|
||||||
<div class="mb-6 text-center">
|
<v-card class="mb-6" variant="flat">
|
||||||
<h1 class="text-h5 font-weight-black text-primary">公益普測與活動預約</h1>
|
<v-card-title class="text-primary">公益普測與活動預約</v-card-title>
|
||||||
<p class="text-caption text-grey">報名免費體能普測,取得個人運動處方</p>
|
|
||||||
</div>
|
<v-card-subtitle class="text-grey-darken-3">
|
||||||
|
報名免費體能普測,取得個人運動處方
|
||||||
|
</v-card-subtitle>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
<!-- 活動分類選擇 -->
|
<!-- 活動分類選擇 -->
|
||||||
<v-tabs v-model="filterTab" class="mb-4 bg-white rounded-lg elevation-1" color="primary" grow>
|
<v-tabs v-model="filterTab" class="mb-4 bg-white rounded-lg elevation-1" color="primary" grow>
|
||||||
@@ -169,156 +172,10 @@
|
|||||||
|
|
||||||
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p>
|
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p>
|
||||||
|
|
||||||
<!-- 模擬 QR Code SVG,更高級精緻 -->
|
|
||||||
<div
|
<div
|
||||||
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center"
|
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center"
|
||||||
>
|
>
|
||||||
<svg class="qrcode-svg" height="180" viewBox="0 0 100 100" width="180">
|
<QRCode :size="220" :value="selectedCheckinPayload" />
|
||||||
<!-- 四個角定位框 -->
|
|
||||||
<path d="M 0,0 H 25 V 8 H 8 V 25 H 0 Z" fill="#1D7063" />
|
|
||||||
<path d="M 75,0 H 100 V 25 H 92 V 8 H 75 Z" fill="#1D7063" />
|
|
||||||
<path d="M 0,75 V 100 H 25 V 92 H 8 V 75 Z" fill="#1D7063" />
|
|
||||||
<path d="M 92,75 V 92 H 75 V 100 H 100 V 75 Z" fill="#1D7063" />
|
|
||||||
<!-- 內嵌二維碼方塊 -->
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="20"
|
|
||||||
width="20"
|
|
||||||
x="12"
|
|
||||||
y="12"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
height="12"
|
|
||||||
width="12"
|
|
||||||
x="16"
|
|
||||||
y="16"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#E66926"
|
|
||||||
height="6"
|
|
||||||
width="6"
|
|
||||||
x="19"
|
|
||||||
y="19"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="20"
|
|
||||||
width="20"
|
|
||||||
x="68"
|
|
||||||
y="12"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
height="12"
|
|
||||||
width="12"
|
|
||||||
x="72"
|
|
||||||
y="16"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#E66926"
|
|
||||||
height="6"
|
|
||||||
width="6"
|
|
||||||
x="75"
|
|
||||||
y="75"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="20"
|
|
||||||
width="20"
|
|
||||||
x="12"
|
|
||||||
y="68"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
height="12"
|
|
||||||
width="12"
|
|
||||||
x="16"
|
|
||||||
y="72"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="6"
|
|
||||||
width="6"
|
|
||||||
x="19"
|
|
||||||
y="75"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 中間隨機小方塊 -->
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="8"
|
|
||||||
width="8"
|
|
||||||
x="40"
|
|
||||||
y="20"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#E66926"
|
|
||||||
height="14"
|
|
||||||
width="6"
|
|
||||||
x="52"
|
|
||||||
y="12"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="6"
|
|
||||||
width="16"
|
|
||||||
x="42"
|
|
||||||
y="36"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#47C2A6"
|
|
||||||
height="10"
|
|
||||||
width="28"
|
|
||||||
x="36"
|
|
||||||
y="48"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="12"
|
|
||||||
width="14"
|
|
||||||
x="68"
|
|
||||||
y="42"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#E66926"
|
|
||||||
height="16"
|
|
||||||
width="16"
|
|
||||||
x="72"
|
|
||||||
y="60"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#1D7063"
|
|
||||||
height="12"
|
|
||||||
width="18"
|
|
||||||
x="44"
|
|
||||||
y="68"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
fill="#47C2A6"
|
|
||||||
height="6"
|
|
||||||
width="24"
|
|
||||||
x="40"
|
|
||||||
y="82"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<!-- 紅色掃描雷射線 -->
|
|
||||||
<div class="laser-line" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||||||
@@ -359,6 +216,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import QRCode from '@/components/QRCode.vue'
|
||||||
import { type Appointment, useAppStore } from '@/stores/app'
|
import { type Appointment, useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
@@ -374,6 +232,14 @@ const filteredAppointments = computed(() => {
|
|||||||
return store.appointments
|
return store.appointments
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedCheckinPayload = computed(() => {
|
||||||
|
if (!selectedApt.value) {
|
||||||
|
return 'https://hp.local/c'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://hp.local/c/${selectedApt.value.id}`
|
||||||
|
})
|
||||||
|
|
||||||
function getStatusColor (status: string) {
|
function getStatusColor (status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case '未報名': {
|
case '未報名': {
|
||||||
@@ -422,36 +288,9 @@ function simulateOnSiteCheckin () {
|
|||||||
|
|
||||||
.qrcode-wrapper {
|
.qrcode-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 204px;
|
width: 244px;
|
||||||
height: 204px;
|
height: 244px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrcode-svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 掃描雷射線效果 */
|
|
||||||
.laser-line {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 3px;
|
|
||||||
background-color: #ff3b30;
|
|
||||||
box-shadow: 0 0 8px #ff3b30;
|
|
||||||
animation: scan 2.5s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scan {
|
|
||||||
0% {
|
|
||||||
top: 0%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
top: 100%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
top: 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -180,23 +180,24 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<BrandAvatar class="elevation-3 mb-4" :icon-size="42" :size="72" />
|
<BrandAvatar class="elevation-2" :icon-size="42" :size="72" />
|
||||||
|
|
||||||
<p class="text-body-medium text-grey-darken-1 mb-0">
|
|
||||||
不登入也可以先完成線上健康自評,<br />登入後可預約普測與查看量測紀錄。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card class="rounded-xl elevation-2 mb-4" color="surface">
|
<v-card class="rounded-xl elevation-2 mb-12 py-4" color="surface">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="d-flex align-center mb-4">
|
<div class="d-flex align-center mb-4">
|
||||||
<v-avatar class="mr-3 text-white" color="accent" size="36">
|
<v-avatar class="mr-4 text-white" color="accent" size="36">
|
||||||
<v-icon icon="mdi-clipboard-text-outline" />
|
<v-icon icon="mdi-clipboard-text-outline" />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
|
|
||||||
<div class="text-left">
|
<div class="text-left">
|
||||||
<div class="text-title-medium font-weight-bold text-primary">先做線上健康自評</div>
|
<div class="text-title-medium font-weight-bold text-primary">
|
||||||
<div class="text-title-small text-grey">約 3 分鐘完成,取得初步健康風險分級。</div>
|
無須登入,先做線上健康自評
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-title-small text-grey mt-2">
|
||||||
|
約 3 分鐘完成,取得初步健康風險分級。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,9 +232,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
|
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
|
||||||
|
|
||||||
<div class="text-title-small text-grey">
|
<div class="text-title-small text-grey">普測預約、現場報到、量測紀錄與運動處方。</div>
|
||||||
普測預約、現場報到、量測紀錄與運動處方需要登入狀態。
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
@@ -71,51 +71,55 @@ function createDefaultSelfAssessment (): SelfAssessment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultAppointments (): Appointment[] {
|
function createDefaultAppointments (): Appointment[] {
|
||||||
|
const measurementItems = [
|
||||||
|
'血壓',
|
||||||
|
'體脂',
|
||||||
|
'握力',
|
||||||
|
'5次坐站',
|
||||||
|
'30秒椅子坐立',
|
||||||
|
'原地抬膝踏步',
|
||||||
|
'手臂屈舉',
|
||||||
|
'抓背測驗',
|
||||||
|
'坐姿體前彎',
|
||||||
|
'2.44米繞物',
|
||||||
|
'開眼單足立',
|
||||||
|
]
|
||||||
|
const location = '信義區運動中心 3 樓多功能教室'
|
||||||
|
const organizer = '臺北市政府體育局'
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'apt-01',
|
id: 'apt-01',
|
||||||
title: '信義區銀髮活力公益普測',
|
title: '銀髮活力公益普測 A 時段',
|
||||||
date: '2026-06-20',
|
date: '2026-07-04',
|
||||||
time: '09:00 - 11:30',
|
time: '09:00 - 10:00',
|
||||||
location: '信義區運動中心 3 樓多功能教室',
|
location,
|
||||||
status: '已報名',
|
status: '已報名',
|
||||||
slots: 12,
|
slots: 12,
|
||||||
organizer: '臺北市政府體育局',
|
organizer,
|
||||||
items: ['血壓', '體脂', '握力', '30秒椅子坐立', '原地抬膝踏步', '抓背測驗', '開眼單足立'],
|
items: measurementItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apt-02',
|
id: 'apt-02',
|
||||||
title: '中山區樂齡健康普測與運動處方體驗站',
|
title: '銀髮活力公益普測 B 時段',
|
||||||
date: '2026-06-25',
|
date: '2026-07-04',
|
||||||
time: '14:00 - 16:30',
|
time: '10:30 - 11:30',
|
||||||
location: '中山區民生社區活動中心 4 樓',
|
location,
|
||||||
status: '已報到',
|
status: '已報到',
|
||||||
slots: 5,
|
slots: 5,
|
||||||
organizer: '中山區健康服務中心',
|
organizer,
|
||||||
items: [
|
items: measurementItems,
|
||||||
'血壓',
|
|
||||||
'體脂',
|
|
||||||
'握力',
|
|
||||||
'5次坐站',
|
|
||||||
'30秒椅子坐立',
|
|
||||||
'原地抬膝踏步',
|
|
||||||
'手臂屈舉',
|
|
||||||
'抓背測驗',
|
|
||||||
'坐姿體前彎',
|
|
||||||
'2.44米繞物',
|
|
||||||
'開眼單足立',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apt-03',
|
id: 'apt-03',
|
||||||
title: '大安區樂齡體適能檢測活動',
|
title: '銀髮活力公益普測 C 時段',
|
||||||
date: '2026-07-02',
|
date: '2026-07-04',
|
||||||
time: '09:00 - 12:00',
|
time: '13:30 - 14:30',
|
||||||
location: '大安運動中心 2 樓籃球場',
|
location,
|
||||||
status: '未報名',
|
status: '未報名',
|
||||||
slots: 45,
|
slots: 45,
|
||||||
organizer: '大安區公所',
|
organizer,
|
||||||
items: ['血壓', '體脂', '握力', '5次坐站', '原地抬膝踏步', '坐姿體前彎', '開眼單足立'],
|
items: measurementItems,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
type Cell = boolean | null
|
||||||
|
|
||||||
|
interface BlockGroup {
|
||||||
|
count: number
|
||||||
|
dataCodewords: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionSpec {
|
||||||
|
version: number
|
||||||
|
size: number
|
||||||
|
dataCodewords: number
|
||||||
|
eccCodewords: number
|
||||||
|
blockGroups: BlockGroup[]
|
||||||
|
capacity: number
|
||||||
|
alignment: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrCodeMatrix {
|
||||||
|
size: number
|
||||||
|
modules: boolean[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION_SPECS: VersionSpec[] = [
|
||||||
|
{ version: 1, size: 21, dataCodewords: 16, eccCodewords: 10, blockGroups: [{ count: 1, dataCodewords: 16 }], capacity: 14, alignment: [] },
|
||||||
|
{ version: 2, size: 25, dataCodewords: 28, eccCodewords: 16, blockGroups: [{ count: 1, dataCodewords: 28 }], capacity: 26, alignment: [6, 18] },
|
||||||
|
{ version: 3, size: 29, dataCodewords: 44, eccCodewords: 26, blockGroups: [{ count: 1, dataCodewords: 44 }], capacity: 42, alignment: [6, 22] },
|
||||||
|
{ version: 4, size: 33, dataCodewords: 64, eccCodewords: 18, blockGroups: [{ count: 2, dataCodewords: 32 }], capacity: 62, alignment: [6, 26] },
|
||||||
|
{ version: 5, size: 37, dataCodewords: 86, eccCodewords: 24, blockGroups: [{ count: 2, dataCodewords: 43 }], capacity: 84, alignment: [6, 30] },
|
||||||
|
{ version: 6, size: 41, dataCodewords: 108, eccCodewords: 16, blockGroups: [{ count: 4, dataCodewords: 27 }], capacity: 106, alignment: [6, 34] },
|
||||||
|
{ version: 7, size: 45, dataCodewords: 124, eccCodewords: 18, blockGroups: [{ count: 4, dataCodewords: 31 }], capacity: 122, alignment: [6, 22, 38] },
|
||||||
|
{ version: 8, size: 49, dataCodewords: 154, eccCodewords: 22, blockGroups: [{ count: 2, dataCodewords: 38 }, { count: 2, dataCodewords: 39 }], capacity: 152, alignment: [6, 24, 42] },
|
||||||
|
{ version: 9, size: 53, dataCodewords: 182, eccCodewords: 22, blockGroups: [{ count: 3, dataCodewords: 36 }, { count: 2, dataCodewords: 37 }], capacity: 180, alignment: [6, 26, 46] },
|
||||||
|
{ version: 10, size: 57, dataCodewords: 216, eccCodewords: 26, blockGroups: [{ count: 4, dataCodewords: 43 }, { count: 1, dataCodewords: 44 }], capacity: 213, alignment: [6, 28, 50] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FORMAT_XOR = 0x54_12
|
||||||
|
const FORMAT_POLY = 0x5_37
|
||||||
|
const VERSION_POLY = 0x1f_25
|
||||||
|
|
||||||
|
const gfExp = Array.from({ length: 512 }, () => 0)
|
||||||
|
const gfLog = Array.from({ length: 256 }, () => 0)
|
||||||
|
let x = 1
|
||||||
|
for (let i = 0; i < 255; i += 1) {
|
||||||
|
gfExp[i] = x
|
||||||
|
gfLog[x] = i
|
||||||
|
x <<= 1
|
||||||
|
if (x & 0x1_00) {
|
||||||
|
x ^= 0x1_1d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 255; i < 512; i += 1) {
|
||||||
|
gfExp[i] = gfExp[i - 255]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQrCodeMatrix (value: string): QrCodeMatrix {
|
||||||
|
const data = [...new TextEncoder().encode(value)]
|
||||||
|
const spec = VERSION_SPECS.find(item => data.length <= item.capacity)
|
||||||
|
if (!spec) {
|
||||||
|
throw new Error('QR Code 內容過長,請將 payload 控制在 213 bytes 以內')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataCodewords = createDataCodewords(data, spec)
|
||||||
|
const codewords = createFinalCodewords(dataCodewords, spec)
|
||||||
|
const dataBits = codewords.flatMap(codeword => byteToBits(codeword))
|
||||||
|
const base = createBaseMatrix(spec)
|
||||||
|
|
||||||
|
let bestModules = base.modules
|
||||||
|
let bestScore = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
|
for (let mask = 0; mask < 8; mask += 1) {
|
||||||
|
const modules = cloneMatrix(base.modules)
|
||||||
|
placeDataBits(modules, base.reserved, dataBits, mask)
|
||||||
|
drawFormatBits(modules, spec.size, mask)
|
||||||
|
|
||||||
|
if (spec.version >= 7) {
|
||||||
|
drawVersionBits(modules, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = getPenaltyScore(modules)
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestModules = modules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: spec.size,
|
||||||
|
modules: bestModules.map(row => row.map(Boolean)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQrCodeSvg (value: string, options: { scale?: number, quietZone?: number } = {}) {
|
||||||
|
const { size, modules } = createQrCodeMatrix(value)
|
||||||
|
const quietZone = options.quietZone ?? 4
|
||||||
|
const scale = options.scale ?? 6
|
||||||
|
const viewSize = size + quietZone * 2
|
||||||
|
const rects: string[] = []
|
||||||
|
|
||||||
|
for (const [y, row] of modules.entries()) {
|
||||||
|
for (const [x, dark] of row.entries()) {
|
||||||
|
if (dark) {
|
||||||
|
rects.push(`<rect x="${x + quietZone}" y="${y + quietZone}" width="1" height="1"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${viewSize} ${viewSize}" width="${viewSize * scale}" height="${viewSize * scale}" shape-rendering="crispEdges">`,
|
||||||
|
'<rect width="100%" height="100%" fill="#fff"/>',
|
||||||
|
'<g fill="#000">',
|
||||||
|
...rects,
|
||||||
|
'</g>',
|
||||||
|
'</svg>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDataCodewords (data: number[], spec: VersionSpec) {
|
||||||
|
const bits: number[] = []
|
||||||
|
appendBits(bits, 0b0100, 4)
|
||||||
|
appendBits(bits, data.length, spec.version < 10 ? 8 : 16)
|
||||||
|
for (const byte of data) {
|
||||||
|
appendBits(bits, byte, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacityBits = spec.dataCodewords * 8
|
||||||
|
appendBits(bits, 0, Math.min(4, capacityBits - bits.length))
|
||||||
|
|
||||||
|
while (bits.length % 8 !== 0) {
|
||||||
|
bits.push(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const codewords: number[] = []
|
||||||
|
for (let i = 0; i < bits.length; i += 8) {
|
||||||
|
codewords.push(bitsToByte(bits.slice(i, i + 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pad = 0xec; codewords.length < spec.dataCodewords; pad ^= 0xec ^ 0x11) {
|
||||||
|
codewords.push(pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
return codewords
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFinalCodewords (dataCodewords: number[], spec: VersionSpec) {
|
||||||
|
const blocks: { data: number[], ecc: number[] }[] = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (const group of spec.blockGroups) {
|
||||||
|
for (let i = 0; i < group.count; i += 1) {
|
||||||
|
const data = dataCodewords.slice(offset, offset + group.dataCodewords)
|
||||||
|
offset += group.dataCodewords
|
||||||
|
blocks.push({ data, ecc: createErrorCorrectionCodewords(data, spec.eccCodewords) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: number[] = []
|
||||||
|
const maxDataLength = Math.max(...blocks.map(block => block.data.length))
|
||||||
|
|
||||||
|
for (let i = 0; i < maxDataLength; i += 1) {
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (i < block.data.length) {
|
||||||
|
result.push(block.data[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < spec.eccCodewords; i += 1) {
|
||||||
|
for (const block of blocks) {
|
||||||
|
result.push(block.ecc[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorCorrectionCodewords (data: number[], degree: number) {
|
||||||
|
const generator = createGeneratorPolynomial(degree)
|
||||||
|
const result = Array.from({ length: degree }, () => 0)
|
||||||
|
|
||||||
|
for (const byte of data) {
|
||||||
|
const factor = byte ^ result.shift()!
|
||||||
|
result.push(0)
|
||||||
|
|
||||||
|
for (const [index, coefficient] of generator.entries()) {
|
||||||
|
result[index] ^= gfMultiply(coefficient, factor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGeneratorPolynomial (degree: number) {
|
||||||
|
let result = [1]
|
||||||
|
|
||||||
|
for (let i = 0; i < degree; i += 1) {
|
||||||
|
const next = Array.from({ length: result.length + 1 }, () => 0)
|
||||||
|
for (const [index, coefficient] of result.entries()) {
|
||||||
|
next[index] ^= gfMultiply(coefficient, 1)
|
||||||
|
next[index + 1] ^= gfMultiply(coefficient, gfExp[i])
|
||||||
|
}
|
||||||
|
result = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBaseMatrix (spec: VersionSpec) {
|
||||||
|
const modules = Array.from({ length: spec.size }, (): Cell[] => {
|
||||||
|
return Array.from({ length: spec.size }, (): Cell => null)
|
||||||
|
})
|
||||||
|
const reserved = Array.from({ length: spec.size }, (): boolean[] => {
|
||||||
|
return Array.from({ length: spec.size }, () => false)
|
||||||
|
})
|
||||||
|
|
||||||
|
drawFinderPattern(modules, reserved, 3, 3)
|
||||||
|
drawFinderPattern(modules, reserved, spec.size - 4, 3)
|
||||||
|
drawFinderPattern(modules, reserved, 3, spec.size - 4)
|
||||||
|
drawTimingPatterns(modules, reserved)
|
||||||
|
drawAlignmentPatterns(modules, reserved, spec.alignment)
|
||||||
|
drawDarkModule(modules, reserved, spec.size)
|
||||||
|
reserveFormatAreas(reserved, spec.size)
|
||||||
|
|
||||||
|
if (spec.version >= 7) {
|
||||||
|
reserveVersionAreas(reserved, spec.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modules, reserved }
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFinderPattern (modules: Cell[][], reserved: boolean[][], centerX: number, centerY: number) {
|
||||||
|
for (let y = -4; y <= 4; y += 1) {
|
||||||
|
for (let x = -4; x <= 4; x += 1) {
|
||||||
|
const xx = centerX + x
|
||||||
|
const yy = centerY + y
|
||||||
|
if (!isInBounds(modules.length, xx, yy)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Math.max(Math.abs(x), Math.abs(y))
|
||||||
|
modules[yy][xx] = distance !== 2 && distance !== 4
|
||||||
|
reserved[yy][xx] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTimingPatterns (modules: Cell[][], reserved: boolean[][]) {
|
||||||
|
for (let i = 8; i < modules.length - 8; i += 1) {
|
||||||
|
const dark = i % 2 === 0
|
||||||
|
modules[6][i] = dark
|
||||||
|
modules[i][6] = dark
|
||||||
|
reserved[6][i] = true
|
||||||
|
reserved[i][6] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAlignmentPatterns (modules: Cell[][], reserved: boolean[][], positions: number[]) {
|
||||||
|
for (const y of positions) {
|
||||||
|
for (const x of positions) {
|
||||||
|
if (reserved[y][x]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let dy = -2; dy <= 2; dy += 1) {
|
||||||
|
for (let dx = -2; dx <= 2; dx += 1) {
|
||||||
|
const distance = Math.max(Math.abs(dx), Math.abs(dy))
|
||||||
|
modules[y + dy][x + dx] = distance !== 1
|
||||||
|
reserved[y + dy][x + dx] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDarkModule (modules: Cell[][], reserved: boolean[][], size: number) {
|
||||||
|
modules[size - 8][8] = true
|
||||||
|
reserved[size - 8][8] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveFormatAreas (reserved: boolean[][], size: number) {
|
||||||
|
for (let i = 0; i <= 5; i += 1) {
|
||||||
|
reserved[8][i] = true
|
||||||
|
reserved[i][8] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
reserved[8][7] = true
|
||||||
|
reserved[8][8] = true
|
||||||
|
reserved[7][8] = true
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
reserved[8][size - 1 - i] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 8; i < 15; i += 1) {
|
||||||
|
reserved[size - 15 + i][8] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveVersionAreas (reserved: boolean[][], size: number) {
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
for (let j = 0; j < 3; j += 1) {
|
||||||
|
reserved[size - 11 + j][i] = true
|
||||||
|
reserved[i][size - 11 + j] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeDataBits (modules: Cell[][], reserved: boolean[][], bits: number[], mask: number) {
|
||||||
|
const size = modules.length
|
||||||
|
let bitIndex = 0
|
||||||
|
let direction = -1
|
||||||
|
let y = size - 1
|
||||||
|
|
||||||
|
for (let right = size - 1; right >= 1; right -= 2) {
|
||||||
|
if (right === 6) {
|
||||||
|
right -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while (y >= 0 && y < size) {
|
||||||
|
for (let column = 0; column < 2; column += 1) {
|
||||||
|
const x = right - column
|
||||||
|
if (!reserved[y][x]) {
|
||||||
|
const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false
|
||||||
|
modules[y][x] = bit !== getMaskBit(mask, x, y)
|
||||||
|
bitIndex += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y += direction
|
||||||
|
}
|
||||||
|
|
||||||
|
y -= direction
|
||||||
|
direction = -direction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFormatBits (modules: Cell[][], size: number, mask: number) {
|
||||||
|
const bits = getFormatBits(mask)
|
||||||
|
|
||||||
|
for (let i = 0; i <= 5; i += 1) {
|
||||||
|
setModule(modules, 8, i, getBit(bits, i))
|
||||||
|
}
|
||||||
|
setModule(modules, 8, 7, getBit(bits, 6))
|
||||||
|
setModule(modules, 8, 8, getBit(bits, 7))
|
||||||
|
setModule(modules, 7, 8, getBit(bits, 8))
|
||||||
|
for (let i = 9; i < 15; i += 1) {
|
||||||
|
setModule(modules, 14 - i, 8, getBit(bits, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
setModule(modules, size - 1 - i, 8, getBit(bits, i))
|
||||||
|
}
|
||||||
|
for (let i = 8; i < 15; i += 1) {
|
||||||
|
setModule(modules, 8, size - 15 + i, getBit(bits, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawVersionBits (modules: Cell[][], spec: VersionSpec) {
|
||||||
|
const bits = getVersionBits(spec.version)
|
||||||
|
const size = spec.size
|
||||||
|
|
||||||
|
for (let i = 0; i < 18; i += 1) {
|
||||||
|
const bit = getBit(bits, i)
|
||||||
|
const x = i % 3
|
||||||
|
const y = Math.floor(i / 3)
|
||||||
|
setModule(modules, size - 11 + x, y, bit)
|
||||||
|
setModule(modules, y, size - 11 + x, bit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormatBits (mask: number) {
|
||||||
|
let data = mask
|
||||||
|
let bits = data << 10
|
||||||
|
|
||||||
|
for (let i = 4; i >= 0; i -= 1) {
|
||||||
|
if (((bits >>> (i + 10)) & 1) === 1) {
|
||||||
|
bits ^= FORMAT_POLY << i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = (data << 10) | (bits & 0x03_ff)
|
||||||
|
return data ^ FORMAT_XOR
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionBits (version: number) {
|
||||||
|
let bits = version << 12
|
||||||
|
|
||||||
|
for (let i = 5; i >= 0; i -= 1) {
|
||||||
|
if (((bits >>> (i + 12)) & 1) === 1) {
|
||||||
|
bits ^= VERSION_POLY << i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (version << 12) | (bits & 0x0f_ff)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPenaltyScore (modules: Cell[][]) {
|
||||||
|
const size = modules.length
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
score += getRunPenalty(modules[y].map(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
score += getRunPenalty(modules.map(row => Boolean(row[x])))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < size - 1; y += 1) {
|
||||||
|
for (let x = 0; x < size - 1; x += 1) {
|
||||||
|
const dark = modules[y][x]
|
||||||
|
if (modules[y][x + 1] === dark && modules[y + 1][x] === dark && modules[y + 1][x + 1] === dark) {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score += getFinderLikePenalty(modules)
|
||||||
|
|
||||||
|
const darkCount = modules.flat().filter(Boolean).length
|
||||||
|
const percent = (darkCount * 100) / (size * size)
|
||||||
|
score += Math.floor(Math.abs(percent - 50) / 5) * 10
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunPenalty (line: boolean[]) {
|
||||||
|
let score = 0
|
||||||
|
let runColor = line[0]
|
||||||
|
let runLength = 1
|
||||||
|
|
||||||
|
for (let i = 1; i < line.length; i += 1) {
|
||||||
|
if (line[i] === runColor) {
|
||||||
|
runLength += 1
|
||||||
|
} else {
|
||||||
|
if (runLength >= 5) {
|
||||||
|
score += runLength - 2
|
||||||
|
}
|
||||||
|
runColor = line[i]
|
||||||
|
runLength = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return runLength >= 5 ? score + runLength - 2 : score
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFinderLikePenalty (modules: Cell[][]) {
|
||||||
|
const pattern = '10111010000'
|
||||||
|
const reversePattern = '00001011101'
|
||||||
|
const size = modules.length
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
const scan = (line: boolean[]) => {
|
||||||
|
const text = line.map(bit => (bit ? '1' : '0')).join('')
|
||||||
|
for (let i = 0; i <= text.length - pattern.length; i += 1) {
|
||||||
|
const chunk = text.slice(i, i + pattern.length)
|
||||||
|
if (chunk === pattern || chunk === reversePattern) {
|
||||||
|
score += 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of modules) {
|
||||||
|
scan(row.map(Boolean))
|
||||||
|
}
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
scan(modules.map(row => Boolean(row[x])))
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaskBit (mask: number, x: number, y: number) {
|
||||||
|
switch (mask) {
|
||||||
|
case 0: {
|
||||||
|
return (x + y) % 2 === 0
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
return y % 2 === 0
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
return x % 3 === 0
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
return (x + y) % 3 === 0
|
||||||
|
}
|
||||||
|
case 4: {
|
||||||
|
return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
return ((x * y) % 2) + ((x * y) % 3) === 0
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0
|
||||||
|
}
|
||||||
|
case 7: {
|
||||||
|
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBits (target: number[], value: number, length: number) {
|
||||||
|
for (let i = length - 1; i >= 0; i -= 1) {
|
||||||
|
target.push((value >>> i) & 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteToBits (value: number) {
|
||||||
|
const bits: number[] = []
|
||||||
|
appendBits(bits, value, 8)
|
||||||
|
return bits
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitsToByte (bits: number[]) {
|
||||||
|
return bits.reduce((value, bit) => (value << 1) | bit, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function gfMultiply (a: number, b: number) {
|
||||||
|
if (a === 0 || b === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return gfExp[gfLog[a] + gfLog[b]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBit (value: number, index: number) {
|
||||||
|
return ((value >>> index) & 1) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMatrix (modules: Cell[][]) {
|
||||||
|
return modules.map(row => [...row])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModule (modules: Cell[][], x: number, y: number, value: boolean) {
|
||||||
|
modules[y][x] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInBounds (size: number, x: number, y: number) {
|
||||||
|
return x >= 0 && y >= 0 && x < size && y < size
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user