diff --git a/apps/react-zpl/src/commands/barcodeQR.ts b/apps/react-zpl/src/commands/barcodeQR.ts new file mode 100644 index 0000000..e24032d --- /dev/null +++ b/apps/react-zpl/src/commands/barcodeQR.ts @@ -0,0 +1,43 @@ +import { defineCommand } from './base'; +import { ORIENTATION } from '../constants'; +import type { ObjectValues } from '../types'; + +/** ZPL `^BQ`의 `d`(에러 정정). `^FD` QR 데이터 첫 문자와 반드시 일치해야 함 */ +export type QrErrorCorrection = 'H' | 'Q' | 'M' | 'L'; + +export interface BarcodeQRParams { + orientation: ObjectValues; + model: 1 | 2; + magnification: number; + errorCorrection: QrErrorCorrection; + maskValue: number; +} + +const MIN_MAGNIFICATION = 1; +const MAX_MAGNIFICATION = 100; + +/** + * ZPL `^BQ` — QR 코드 필드 명령. `^FD`/`^FS`는 포함하지 않으며 {@link fieldData}와 함께 사용해야 함. + * + * @see https://docs.zebra.com/us/en/printers/software/zpl-pg/c-zpl-zpl-commands/r-zpl-bq.html + */ +export const barcodeQR = defineCommand((params) => { + const { orientation, model, magnification, errorCorrection, maskValue } = + params; + + if (magnification < MIN_MAGNIFICATION || magnification > MAX_MAGNIFICATION) { + throw new Error( + `barcodeQR: magnification은 ${MIN_MAGNIFICATION}~${MAX_MAGNIFICATION}이어야 합니다. (magnification=${magnification})` + ); + } + if (!Number.isInteger(maskValue) || maskValue < 0 || maskValue > 7) { + throw new Error( + `barcodeQR: maskValue는 0~7 정수여야 합니다. (maskValue=${maskValue})` + ); + } + if (model !== 1 && model !== 2) { + throw new Error(`barcodeQR: model은 1 또는 2여야 합니다. (model=${model})`); + } + + return `^BQ${orientation},${model},${magnification},${errorCorrection},${maskValue}`; +}); diff --git a/apps/react-zpl/src/commands/index.ts b/apps/react-zpl/src/commands/index.ts index 127e51f..d5d54fa 100644 --- a/apps/react-zpl/src/commands/index.ts +++ b/apps/react-zpl/src/commands/index.ts @@ -15,3 +15,4 @@ export * from './graphicBox'; export * from './graphicDiagonal'; export * from './graphicCircle'; export * from './graphicEllipse'; +export * from './barcodeQR'; diff --git a/apps/react-zpl/src/components/QrCode.tsx b/apps/react-zpl/src/components/QrCode.tsx new file mode 100644 index 0000000..9b43570 --- /dev/null +++ b/apps/react-zpl/src/components/QrCode.tsx @@ -0,0 +1,68 @@ +import { PropsWithChildren } from 'react'; + +import { + barcodeQR, + fieldData, + fieldOrigin, + newLine, + type QrErrorCorrection, +} from '../commands'; +import { ORIENTATION } from '../constants'; +import type { ObjectValues, ZplElement, ZplElementContext } from '../types'; + +export interface QrCodeProps extends PropsWithChildren { + fieldOriginX?: number; + fieldOriginY?: number; + fieldOrientation?: ObjectValues; + model?: 1 | 2; + magnification?: number; + /** `^BQ`의 `d`와 `^FD` 스위치 첫 글자가 동일해야 함 */ + errorCorrectionLevel?: QrErrorCorrection; + maskValue?: number; +} + +export const QrCode: ZplElement = ({ children }) => ( + {children} +); + +QrCode.displayName = 'QrCode'; + +QrCode.print = (element, context: ZplElementContext) => { + const { + children, + fieldOriginX = 0, + fieldOriginY = 0, + fieldOrientation, + model = 2, + magnification = 5, + errorCorrectionLevel = 'Q', + maskValue = 7, + } = element.props; + + if (typeof children !== 'string') { + throw new Error('QrCode 컴포넌트는 children에 문자열만 허용합니다.'); + } + if (children.length === 0) { + throw new Error('QrCode: children은 빈 문자열일 수 없습니다.'); + } + + const orientation: ObjectValues = + fieldOrientation ?? context.labelOrientation; + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push( + barcodeQR({ + orientation, + model, + magnification, + errorCorrection: errorCorrectionLevel, + maskValue, + }) + ); + /** 자동 데이터 입력 모드 `A` — Zebra QR `^FD` 규칙에 따라 페이로드는 `"{ECC}A,{payload}"` 형식 */ + output.push(fieldData(`${errorCorrectionLevel}A,${children}`)); + + return output.join(newLine()); +}; diff --git a/apps/react-zpl/src/components/index.ts b/apps/react-zpl/src/components/index.ts index fde5df3..871edd5 100644 --- a/apps/react-zpl/src/components/index.ts +++ b/apps/react-zpl/src/components/index.ts @@ -4,3 +4,4 @@ export * from './Line'; export * from './Text'; export * from './Circle'; export * from './Ellipse'; +export * from './QrCode'; diff --git a/apps/react-zpl/src/utils/print.ts b/apps/react-zpl/src/utils/print.ts index 5b40b65..4f308ab 100644 --- a/apps/react-zpl/src/utils/print.ts +++ b/apps/react-zpl/src/utils/print.ts @@ -24,8 +24,8 @@ export const printChild = ( // TODO: print element with context if (isZplElement(element.type)) { children.push(element.type.print(element, context)); - } else if (node.props.children) { - children.push(printChild(node.props.children, context)); + } else if (element.props.children) { + children.push(printChild(element.props.children, context)); } } diff --git a/demos/electron/src/renderer/index.html b/demos/electron/src/renderer/index.html index e198e05..4c4f58a 100644 --- a/demos/electron/src/renderer/index.html +++ b/demos/electron/src/renderer/index.html @@ -6,7 +6,7 @@ diff --git a/demos/electron/src/renderer/src/App.css b/demos/electron/src/renderer/src/App.css new file mode 100644 index 0000000..81ce729 --- /dev/null +++ b/demos/electron/src/renderer/src/App.css @@ -0,0 +1,85 @@ +.app { + display: flex; + flex-direction: column; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.app-header { + background: rgba(255, 255, 255, 0.95); + padding: 2rem; + text-align: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.app-header h1 { + font-size: 2.5rem; + color: #333; + margin-bottom: 0.5rem; + font-weight: 700; +} + +.app-header p { + color: #666; + font-size: 1.1rem; +} + +.app-main { + flex: 1; + padding: 2rem; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.demo-content { + background: white; + border-radius: 12px; + padding: 2.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-width: 900px; + width: 100%; +} + +.demo-content h2 { + color: #333; + margin-bottom: 1rem; + font-size: 1.8rem; + font-weight: 600; +} + +.demo-content p { + color: #666; + line-height: 1.8; + margin-bottom: 1rem; + font-size: 1rem; +} + +.privacy-notice { + font-size: 0.9rem; + color: #856404; + background: #fff3cd; + padding: 0.5rem 0.75rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.error-message { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 0.95rem; + line-height: 1.5; +} + +.error-message::before { + content: '⚠'; + flex-shrink: 0; + font-size: 1.1rem; +} diff --git a/demos/electron/src/renderer/src/App.tsx b/demos/electron/src/renderer/src/App.tsx index afbac4b..e720d44 100644 --- a/demos/electron/src/renderer/src/App.tsx +++ b/demos/electron/src/renderer/src/App.tsx @@ -1,65 +1,225 @@ import { useState } from 'react' -import { Circle, DiagonalLine, Ellipse, Line, Text, ZplLabel } from '@zpl-kit/react-zpl' +import { + graphicsShowcaseZpl, + priceTagZpl, + qrDemoZpl, + shippingLabelZpl, +} from './examples' -const TestLabel = ({ text }: { text: string }) => { - return ( - - - {text} - - 텍스트 확인 - - - - - - - +import './App.css' + +const mmToInch = (mm: number) => mm / 25.4 + +const DPMM_OPTIONS = [6, 8, 12, 24] as const + +async function zplToPng( + zpl: string, + options: { widthMm: number; heightMm: number; dpmm?: 6 | 8 | 12 | 24 } +): Promise { + const { widthMm, heightMm, dpmm = 8 } = options + const width = mmToInch(widthMm) + const height = mmToInch(heightMm) + + const res = await fetch( + `https://api.labelary.com/v1/printers/${dpmm}dpmm/labels/${width}x${height}/0/`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: zpl, + } ) + + if (!res.ok) { + const body = await res.text() + const message = body?.trim() || `HTTP ${res.status}` + throw new Error(message) + } + return res.blob() } +const EXAMPLES = [ + { label: '배송 라벨', generate: shippingLabelZpl, widthMm: 75, heightMm: 50 }, + { label: '가격표', generate: priceTagZpl, widthMm: 50, heightMm: 37.5 }, + { + label: '그래픽 쇼케이스', + generate: graphicsShowcaseZpl, + widthMm: 75, + heightMm: 62.5, + }, + { label: 'QR 코드', generate: qrDemoZpl, widthMm: 50, heightMm: 50 }, +] as const + +const PLACEHOLDER_ZPL = `^XA +^FO50,50^A0N,50,50^FDHello World^FS +^XZ` + function App(): React.JSX.Element { - const [zplOutput, setZplOutput] = useState('') + const [zplInput, setZplInput] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + const [imageUrl, setImageUrl] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [widthMm, setWidthMm] = useState(75) + const [heightMm, setHeightMm] = useState(50) + const [dpmm, setDpmm] = useState<6 | 8 | 12 | 24>(8) - const handlePrint = () => { - setZplOutput(ZplLabel.print(TestLabel({ text: 'Test' }))) + const handleLoadExample = (index: number) => { + const zpl = EXAMPLES[index].generate() + setZplInput(zpl) + setWidthMm(EXAMPLES[index].widthMm) + setHeightMm(EXAMPLES[index].heightMm) + setError(null) + setActiveIndex(index) + } + + const handlePreview = async () => { + const zpl = zplInput.trim() + if (!zpl) { + setError('ZPL을 입력해 주세요.') + return + } + + setLoading(true) + setError(null) + + try { + const blob = await zplToPng(zpl, { widthMm, heightMm, dpmm }) + const url = URL.createObjectURL(blob) + setImageUrl((prev: string | null) => { + if (prev) URL.revokeObjectURL(prev) + return url + }) + } catch (err) { + const msg = err instanceof Error ? err.message : '미리보기 실패' + setError(msg) + } finally { + setLoading(false) + } } return ( -
- - {zplOutput &&
{zplOutput}
} +
+
+

ZPL Kit - Electron Demo

+

React ZPL 라이브러리 Electron 데모

+
+
+
+

예제

+
+ {EXAMPLES.map((example, index) => ( + + ))} +
+ + +

ZPL 직접 입력

+

+ ⚠️ 미리보기는 Labelary API(외부 서버)를 사용합니다. ZPL 내용이 + api.labelary.com으로 전송됩니다. +

+
+ + + +
+