From 1831d5d8230dab044f289e6553e84ab6fc6014ca Mon Sep 17 00:00:00 2001 From: Bori-github Date: Sun, 22 Mar 2026 23:29:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(react-zpl):=20barcodeQR(^BQ)=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?QrCode=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/react-zpl/src/commands/barcodeQR.ts | 43 +++++++++++++++ apps/react-zpl/src/commands/index.ts | 1 + apps/react-zpl/src/components/QrCode.tsx | 68 ++++++++++++++++++++++++ apps/react-zpl/src/components/index.ts | 1 + 4 files changed, 113 insertions(+) create mode 100644 apps/react-zpl/src/commands/barcodeQR.ts create mode 100644 apps/react-zpl/src/components/QrCode.tsx 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'; From 81a9f8e87c3ad5cd1c7373043cfc371fe5bddba9 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Sun, 22 Mar 2026 23:31:47 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(react-zpl):=20printChild=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=90=EC=8B=9D=20=EB=85=B8=EB=93=9C=EC=97=90=20?= =?UTF-8?q?element.props=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 루프 내 비 ZPL 요소 처리 시 node 대신 element 기준으로 children 참조 --- apps/react-zpl/src/utils/print.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)); } } From 9d982ee0241a154d57f430b16d947a2ace3984bb Mon Sep 17 00:00:00 2001 From: Bori-github Date: Mon, 23 Mar 2026 00:11:54 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test(tests):=20QrCode=C2=B7barcodeQR=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=C2=B7TS=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/AGENTS.md 추가 - @types/react, customConditions·types 배열 정리 - unit/fixtures/context 및 QrCode·barcodeQR 테스트 --- pnpm-lock.yaml | 3 + tests/AGENTS.md | 199 +++++++++++++++++++++++++++++++++ tests/package.json | 1 + tests/tsconfig.json | 3 +- tests/unit/QrCode.test.tsx | 50 +++++++++ tests/unit/barcodeQR.test.ts | 51 +++++++++ tests/unit/fixtures/context.ts | 9 ++ 7 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 tests/AGENTS.md create mode 100644 tests/unit/QrCode.test.tsx create mode 100644 tests/unit/barcodeQR.test.ts create mode 100644 tests/unit/fixtures/context.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c712f8..1a07d7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.1 + '@types/react': + specifier: ^18.3.0 + version: 18.3.27 typescript: specifier: ^5.7.0 version: 5.9.3 diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 0000000..4542df5 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,199 @@ +# AGENTS.md — tests 작업 가이드 + +## 환경 + +| 항목 | 값 | +|------|----| +| 러너 | Vitest (`tests/vitest.config.ts`) | +| 실행 | `tests/` 디렉터리에서 `pnpm test` | +| 환경 | `node` (기본값, jsdom/happy-dom 없음) | +| globals | `true` — `describe`, `it`, `expect` import 필요 없음 (하지만 명시적 import 권장) | + +--- + +## 디렉터리 구조 + +- **`tests/unit/`** — 컴포넌트·명령 등 단위 테스트 (`*.test.ts`, `*.test.tsx`) +- **`tests/unit/fixtures/`** (선택) — 여러 테스트에서 공유하는 픽스처 +- 루트에 `package.json`, `tsconfig.json`, `vitest.config.ts`, `AGENTS.md` + +새 테스트는 `tests/unit/` 아래에 두며, 이름 규칙은 아래 표를 따른다. + +--- + +## 파일 명명 규칙 + +| 대상 | 규칙 | 예시 | +|------|------|------| +| 컴포넌트 테스트 | `{ComponentName}.test.tsx` | `Barcode.test.tsx` | +| command 테스트 | `{commandName}.test.tsx` | `barcode128.test.tsx` | +| 유틸 테스트 | `{utilName}.test.tsx` | `imageToZpl.test.tsx` | + +> `.tsx` 확장자는 JSX 유무와 관계없이 통일해서 사용한다. + +--- + +## Import 규칙 + +```ts +import { createElement } from 'react'; +import { describe, it, expect } from 'vitest'; + +import { ComponentName, type ZplElementContext } from '@zpl-kit/react-zpl'; +``` + +- 컴포넌트·타입은 `@zpl-kit/react-zpl`에서 import하는 것을 기본으로 한다 +- 패키지 진입점에 없는 심볼(예: `commands/barcodeQR`)은 `../../apps/react-zpl/src/...`로만 검증 가능하다 + +--- + +## `ZplElementContext` 픽스처 + +모든 테스트 파일 상단에 동일한 `defaultContext`를 선언한다. + +```ts +const defaultContext: ZplElementContext = { + labelOrientation: 'N', + defaultFontName: 'J', + defaultFontWidth: 30, + defaultFontHeight: 30, +}; +``` + +--- + +## 테스트 구조 + +각 컴포넌트 테스트는 아래 세 블록으로 구성한다. + +```ts +describe('ComponentName', () => { + describe('print', () => { + // 정상 동작: 기본값, 각 prop 조합 + }); + + describe('print - 검증', () => { + // 잘못된 입력 → throw 검증 + }); + + describe('displayName', () => { + // displayName 고정값 확인 + }); +}); +``` + +--- + +## 단언 규칙 + +### 전체 ZPL 문자열 비교 (기본) + +ZPL 출력은 결정적(deterministic)이므로 `toBe()`로 전체 문자열을 검증한다. + +```ts +expect(Line.print(el, defaultContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); +``` + +### 부분 포함 검증 (보조) + +출력이 길거나 일부만 확인할 때만 `toContain()`을 사용한다. + +```ts +expect(Circle.print(el, defaultContext)).toContain('^GC60,1,W^FS'); +``` + +### 에러 검증 + +잘못된 입력은 `throw`를 기대하며, 에러 메시지를 한국어로 정확히 명시한다. + +```ts +expect(() => Line.print(el, defaultContext)).toThrow('length는 1 이상이어야 합니다'); +``` + +### 금지 사항 + +- `toMatchSnapshot()` 사용 금지 — 외부 스냅샷 파일 생성을 피하고 `toBe()`로 인라인 검증 +- `toMatchInlineSnapshot()` 은 ZPL 문자열이 너무 길어 `toBe()` 가독성이 떨어질 때만 허용 + +--- + +## 테스트 작성 패턴 + +### 컴포넌트 테스트 + +```ts +import { createElement } from 'react'; +import { describe, it, expect } from 'vitest'; + +import { Barcode, type ZplElementContext } from '@zpl-kit/react-zpl'; + +const defaultContext: ZplElementContext = { + labelOrientation: 'N', + defaultFontName: 'J', + defaultFontWidth: 30, + defaultFontHeight: 30, +}; + +describe('Barcode', () => { + describe('print', () => { + it('code128 기본값 — ^FO, ^BC, ^FD, ^FS 순서로 생성', () => { + const el = createElement(Barcode, { + type: 'code128', + value: 'ABC', + fieldOriginX: 10, + fieldOriginY: 20, + height: 60, + }); + expect(Barcode.print(el, defaultContext)).toBe('^FO10,20\\&^BCN,60,Y,N,N^FDABC^FS'); + }); + + it('qr — ^BQ 명령어와 ^FD에 QA, 프리픽스 포함', () => { + const el = createElement(Barcode, { + type: 'qr', + value: 'https://example.com', + fieldOriginX: 0, + fieldOriginY: 0, + magnification: 3, + }); + const zpl = Barcode.print(el, defaultContext); + expect(zpl).toContain('^BQ'); + expect(zpl).toContain('^FDQA,https://example.com^FS'); + }); + }); + + describe('print - 검증', () => { + it('value가 빈 문자열이면 에러 throw', () => { + const el = createElement(Barcode, { type: 'code128', value: '' }); + expect(() => Barcode.print(el, defaultContext)).toThrow('value는 비어있을 수 없습니다'); + }); + + it('ean13에 13자리가 아닌 값을 전달하면 에러 throw', () => { + const el = createElement(Barcode, { type: 'ean13', value: '123' }); + expect(() => Barcode.print(el, defaultContext)).toThrow('EAN-13은 13자리 숫자여야 합니다'); + }); + }); + + describe('displayName', () => { + it('Barcode으로 설정됨', () => { + expect(Barcode.displayName).toBe('Barcode'); + }); + }); +}); +``` + +### 케이스 이름 규칙 + +- 정상 케이스: `'{prop} {값} 시 {기대 동작}'` 또는 `'기본값으로 {기대 ZPL} 생성'` +- 에러 케이스: `'{조건} 시 에러 throw'` +- 한국어로 작성 + +--- + +## 실행 + +```bash +# tests/ 디렉터리에서 +pnpm test # watch 모드 +pnpm test:ui # UI 모드 +pnpm test:coverage # 커버리지 +``` diff --git a/tests/package.json b/tests/package.json index e8e79ba..ce448c4 100644 --- a/tests/package.json +++ b/tests/package.json @@ -14,6 +14,7 @@ "react-dom": "^18.3.0" }, "devDependencies": { + "@types/react": "^18.3.0", "@types/node": "^22.0.0", "typescript": "^5.7.0", "vitest": "^2.1.0" diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 5beee7d..42af0cf 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["vitest/globals", "node"] + "customConditions": ["development"], + "types": ["vitest/globals", "node", "react"] }, "include": ["**/*.ts", "**/*.tsx"] } diff --git a/tests/unit/QrCode.test.tsx b/tests/unit/QrCode.test.tsx new file mode 100644 index 0000000..b8175aa --- /dev/null +++ b/tests/unit/QrCode.test.tsx @@ -0,0 +1,50 @@ +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { QrCode } from '@zpl-kit/react-zpl'; +import { testLabelContext } from './fixtures/context'; + +describe('QrCode.print', () => { + it('emits ^FO, ^BQ, and ^FD with matching error correction (QA, + value)', () => { + const el = createElement( + QrCode, + { + fieldOriginX: 40, + fieldOriginY: 60, + magnification: 4, + }, + 'https://example.com' + ); + const zpl = QrCode.print(el, testLabelContext()); + expect(zpl).toBe( + '^FO40,60\\&^BQN,2,4,Q,7\\&^FDQA,https://example.com\\&^FS' + ); + }); + + it('throws on empty children', () => { + const el = createElement(QrCode, {}, ''); + expect(() => QrCode.print(el, testLabelContext())).toThrow(/빈 문자열/); + }); + + it('throws when children is not a string', () => { + const el = createElement(QrCode, {}, ); + expect(() => QrCode.print(el, testLabelContext())).toThrow( + /문자열만 허용/ + ); + }); + + it('uses fieldOrientation for ^BQ when set', () => { + const el = createElement( + QrCode, + { + fieldOrientation: 'R', + magnification: 2, + errorCorrectionLevel: 'M', + }, + 'x' + ); + const zpl = QrCode.print(el, testLabelContext()); + expect(zpl).toContain('^BQR,2,2,M,7'); + expect(zpl).toContain('^FDMA,x'); + }); +}); diff --git a/tests/unit/barcodeQR.test.ts b/tests/unit/barcodeQR.test.ts new file mode 100644 index 0000000..c28a078 --- /dev/null +++ b/tests/unit/barcodeQR.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { barcodeQR } from '../../apps/react-zpl/src/commands/barcodeQR'; + +describe('barcodeQR', () => { + it('emits ^BQ with explicit orientation (Zebra-style)', () => { + expect( + barcodeQR({ + orientation: 'N', + model: 2, + magnification: 5, + errorCorrection: 'Q', + maskValue: 7, + }) + ).toBe('^BQN,2,5,Q,7'); + }); + + it('rejects magnification out of range', () => { + expect(() => + barcodeQR({ + orientation: 'N', + model: 2, + magnification: 0, + errorCorrection: 'M', + maskValue: 7, + }) + ).toThrow(/magnification/); + + expect(() => + barcodeQR({ + orientation: 'N', + model: 2, + magnification: 101, + errorCorrection: 'M', + maskValue: 7, + }) + ).toThrow(/magnification/); + }); + + it('rejects invalid maskValue', () => { + expect(() => + barcodeQR({ + orientation: 'N', + model: 2, + magnification: 3, + errorCorrection: 'H', + maskValue: 8, + }) + ).toThrow(/maskValue/); + }); +}); diff --git a/tests/unit/fixtures/context.ts b/tests/unit/fixtures/context.ts new file mode 100644 index 0000000..ad97413 --- /dev/null +++ b/tests/unit/fixtures/context.ts @@ -0,0 +1,9 @@ +import { ORIENTATION } from '../../../apps/react-zpl/src/constants/orientation'; +import type { ZplElementContext } from '../../../apps/react-zpl/src/types/element'; + +export const testLabelContext = (): ZplElementContext => ({ + labelOrientation: ORIENTATION.NO_ROTATION, + defaultFontName: 'J', + defaultFontWidth: 30, + defaultFontHeight: 30, +}); From d02af5221b2e562005404a6cebbf4177427f3b30 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Mon, 23 Mar 2026 00:25:55 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor(tests):=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=94=BD=EC=8A=A4?= =?UTF-8?q?=EC=B2=98=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - defaultLabelContext 공유 - 컴포넌트 테스트 및 AGENTS 예시 정리 --- tests/AGENTS.md | 38 ++++++++++++++------------------ tests/unit/Circle.test.tsx | 25 +++++++++------------ tests/unit/DiagonalLine.test.tsx | 23 ++++++++----------- tests/unit/Ellipse.test.tsx | 25 +++++++++------------ tests/unit/Line.test.tsx | 25 +++++++++------------ tests/unit/QrCode.test.tsx | 10 ++++----- tests/unit/fixtures/context.ts | 4 ++-- 7 files changed, 63 insertions(+), 87 deletions(-) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 4542df5..1f24b2f 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -49,15 +49,10 @@ import { ComponentName, type ZplElementContext } from '@zpl-kit/react-zpl'; ## `ZplElementContext` 픽스처 -모든 테스트 파일 상단에 동일한 `defaultContext`를 선언한다. +공통 컨텍스트는 `tests/unit/fixtures/context`에서 import한다. ```ts -const defaultContext: ZplElementContext = { - labelOrientation: 'N', - defaultFontName: 'J', - defaultFontWidth: 30, - defaultFontHeight: 30, -}; +import { defaultLabelContext } from './fixtures/context'; ``` --- @@ -91,7 +86,7 @@ describe('ComponentName', () => { ZPL 출력은 결정적(deterministic)이므로 `toBe()`로 전체 문자열을 검증한다. ```ts -expect(Line.print(el, defaultContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); +expect(Line.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); ``` ### 부분 포함 검증 (보조) @@ -99,7 +94,7 @@ expect(Line.print(el, defaultContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); 출력이 길거나 일부만 확인할 때만 `toContain()`을 사용한다. ```ts -expect(Circle.print(el, defaultContext)).toContain('^GC60,1,W^FS'); +expect(Circle.print(el, defaultLabelContext)).toContain('^GC60,1,W^FS'); ``` ### 에러 검증 @@ -107,7 +102,7 @@ expect(Circle.print(el, defaultContext)).toContain('^GC60,1,W^FS'); 잘못된 입력은 `throw`를 기대하며, 에러 메시지를 한국어로 정확히 명시한다. ```ts -expect(() => Line.print(el, defaultContext)).toThrow('length는 1 이상이어야 합니다'); +expect(() => Line.print(el, defaultLabelContext)).toThrow('length는 1 이상이어야 합니다'); ``` ### 금지 사항 @@ -125,14 +120,9 @@ expect(() => Line.print(el, defaultContext)).toThrow('length는 1 이상이어 import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; -import { Barcode, type ZplElementContext } from '@zpl-kit/react-zpl'; +import { Barcode } from '@zpl-kit/react-zpl'; -const defaultContext: ZplElementContext = { - labelOrientation: 'N', - defaultFontName: 'J', - defaultFontWidth: 30, - defaultFontHeight: 30, -}; +import { defaultLabelContext } from './fixtures/context'; describe('Barcode', () => { describe('print', () => { @@ -144,7 +134,9 @@ describe('Barcode', () => { fieldOriginY: 20, height: 60, }); - expect(Barcode.print(el, defaultContext)).toBe('^FO10,20\\&^BCN,60,Y,N,N^FDABC^FS'); + expect(Barcode.print(el, defaultLabelContext)).toBe( + '^FO10,20\\&^BCN,60,Y,N,N^FDABC^FS' + ); }); it('qr — ^BQ 명령어와 ^FD에 QA, 프리픽스 포함', () => { @@ -155,7 +147,7 @@ describe('Barcode', () => { fieldOriginY: 0, magnification: 3, }); - const zpl = Barcode.print(el, defaultContext); + const zpl = Barcode.print(el, defaultLabelContext); expect(zpl).toContain('^BQ'); expect(zpl).toContain('^FDQA,https://example.com^FS'); }); @@ -164,12 +156,16 @@ describe('Barcode', () => { describe('print - 검증', () => { it('value가 빈 문자열이면 에러 throw', () => { const el = createElement(Barcode, { type: 'code128', value: '' }); - expect(() => Barcode.print(el, defaultContext)).toThrow('value는 비어있을 수 없습니다'); + expect(() => Barcode.print(el, defaultLabelContext)).toThrow( + 'value는 비어있을 수 없습니다' + ); }); it('ean13에 13자리가 아닌 값을 전달하면 에러 throw', () => { const el = createElement(Barcode, { type: 'ean13', value: '123' }); - expect(() => Barcode.print(el, defaultContext)).toThrow('EAN-13은 13자리 숫자여야 합니다'); + expect(() => Barcode.print(el, defaultLabelContext)).toThrow( + 'EAN-13은 13자리 숫자여야 합니다' + ); }); }); diff --git a/tests/unit/Circle.test.tsx b/tests/unit/Circle.test.tsx index 2bde39e..71a0667 100644 --- a/tests/unit/Circle.test.tsx +++ b/tests/unit/Circle.test.tsx @@ -1,20 +1,15 @@ import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; -import { Circle, type ZplElementContext } from '@zpl-kit/react-zpl'; +import { Circle } from '@zpl-kit/react-zpl'; -const defaultContext: ZplElementContext = { - labelOrientation: 'N', - defaultFontName: 'J', - defaultFontWidth: 30, - defaultFontHeight: 30, -}; +import { defaultLabelContext } from './fixtures/context'; describe('Circle', () => { describe('print', () => { it('기본값으로 ^FO0,0\\&^GC{diameter},1,B^FS 생성', () => { const el = createElement(Circle, { diameter: 50 }); - expect(Circle.print(el, defaultContext)).toBe('^FO0,0\\&^GC50,1,B^FS'); + expect(Circle.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GC50,1,B^FS'); }); it('fieldOrigin, thickness, lineColor 지정 시 올바른 ZPL 생성', () => { @@ -25,45 +20,45 @@ describe('Circle', () => { thickness: 5, lineColor: 'B', }); - expect(Circle.print(el, defaultContext)).toBe('^FO10,20\\&^GC100,5,B^FS'); + expect(Circle.print(el, defaultLabelContext)).toBe('^FO10,20\\&^GC100,5,B^FS'); }); it('흰색 원(lineColor=W) 생성', () => { const el = createElement(Circle, { diameter: 60, lineColor: 'W' }); - expect(Circle.print(el, defaultContext)).toContain('^GC60,1,W^FS'); + expect(Circle.print(el, defaultLabelContext)).toContain('^GC60,1,W^FS'); }); it('채워진 원 (thickness >= diameter) 허용', () => { const el = createElement(Circle, { diameter: 50, thickness: 50 }); - expect(Circle.print(el, defaultContext)).toBe('^FO0,0\\&^GC50,50,B^FS'); + expect(Circle.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GC50,50,B^FS'); }); }); describe('print - 검증', () => { it('diameter < 3 시 에러 throw', () => { const el = createElement(Circle, { diameter: 2 }); - expect(() => Circle.print(el, defaultContext)).toThrow( + expect(() => Circle.print(el, defaultLabelContext)).toThrow( 'diameter는 3~4095 사이여야 합니다' ); }); it('diameter > 4095 시 에러 throw', () => { const el = createElement(Circle, { diameter: 4096 }); - expect(() => Circle.print(el, defaultContext)).toThrow( + expect(() => Circle.print(el, defaultLabelContext)).toThrow( 'diameter는 3~4095 사이여야 합니다' ); }); it('thickness < 1 시 에러 throw', () => { const el = createElement(Circle, { diameter: 50, thickness: 0 }); - expect(() => Circle.print(el, defaultContext)).toThrow( + expect(() => Circle.print(el, defaultLabelContext)).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); it('thickness > 4095 시 에러 throw', () => { const el = createElement(Circle, { diameter: 50, thickness: 4096 }); - expect(() => Circle.print(el, defaultContext)).toThrow( + expect(() => Circle.print(el, defaultLabelContext)).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); diff --git a/tests/unit/DiagonalLine.test.tsx b/tests/unit/DiagonalLine.test.tsx index 9abd6b8..bbfb44a 100644 --- a/tests/unit/DiagonalLine.test.tsx +++ b/tests/unit/DiagonalLine.test.tsx @@ -1,14 +1,9 @@ import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; -import { DiagonalLine, type ZplElementContext } from '@zpl-kit/react-zpl'; +import { DiagonalLine } from '@zpl-kit/react-zpl'; -const defaultContext: ZplElementContext = { - labelOrientation: 'N', - defaultFontName: 'J', - defaultFontWidth: 30, - defaultFontHeight: 30, -}; +import { defaultLabelContext } from './fixtures/context'; describe('DiagonalLine', () => { describe('print - 대각선', () => { @@ -17,7 +12,7 @@ describe('DiagonalLine', () => { width: 30, height: 30, }); - expect(DiagonalLine.print(el, defaultContext)).toBe( + expect(DiagonalLine.print(el, defaultLabelContext)).toBe( '^FO0,0\\&^GD30,30,1,B,R^FS' ); }); @@ -31,7 +26,7 @@ describe('DiagonalLine', () => { fieldOriginY: 20, thickness: 2, }); - expect(DiagonalLine.print(el, defaultContext)).toBe( + expect(DiagonalLine.print(el, defaultLabelContext)).toBe( '^FO10,20\\&^GD50,40,2,B,L^FS' ); }); @@ -42,7 +37,7 @@ describe('DiagonalLine', () => { height: 20, orientation: 'R', }); - expect(DiagonalLine.print(el, defaultContext)).toContain('^GD20,20,1,B,R^FS'); + expect(DiagonalLine.print(el, defaultLabelContext)).toContain('^GD20,20,1,B,R^FS'); }); it('orientation L (우상향) 생성', () => { @@ -51,21 +46,21 @@ describe('DiagonalLine', () => { height: 20, orientation: 'L', }); - expect(DiagonalLine.print(el, defaultContext)).toContain('^GD20,20,1,B,L^FS'); + expect(DiagonalLine.print(el, defaultLabelContext)).toContain('^GD20,20,1,B,L^FS'); }); }); describe('print - 검증', () => { it('width < 3 시 에러 throw', () => { const el = createElement(DiagonalLine, { width: 2, height: 30 }); - expect(() => DiagonalLine.print(el, defaultContext)).toThrow( + expect(() => DiagonalLine.print(el, defaultLabelContext)).toThrow( 'width와 height는 3 이상이어야 합니다' ); }); it('height < 3 시 에러 throw', () => { const el = createElement(DiagonalLine, { width: 30, height: 2 }); - expect(() => DiagonalLine.print(el, defaultContext)).toThrow( + expect(() => DiagonalLine.print(el, defaultLabelContext)).toThrow( 'width와 height는 3 이상이어야 합니다' ); }); @@ -76,7 +71,7 @@ describe('DiagonalLine', () => { height: 30, thickness: 0, }); - expect(() => DiagonalLine.print(el, defaultContext)).toThrow( + expect(() => DiagonalLine.print(el, defaultLabelContext)).toThrow( 'thickness는 1 이상이어야 합니다' ); }); diff --git a/tests/unit/Ellipse.test.tsx b/tests/unit/Ellipse.test.tsx index 5b57b34..806a93e 100644 --- a/tests/unit/Ellipse.test.tsx +++ b/tests/unit/Ellipse.test.tsx @@ -1,20 +1,15 @@ import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; -import { Ellipse, type ZplElementContext } from '@zpl-kit/react-zpl'; +import { Ellipse } from '@zpl-kit/react-zpl'; -const defaultContext: ZplElementContext = { - labelOrientation: 'N', - defaultFontName: 'J', - defaultFontWidth: 30, - defaultFontHeight: 30, -}; +import { defaultLabelContext } from './fixtures/context'; describe('Ellipse', () => { describe('print', () => { it('기본값으로 ^FO0,0\\&^GE{width},{height},1,B^FS 생성', () => { const el = createElement(Ellipse, { width: 80, height: 40 }); - expect(Ellipse.print(el, defaultContext)).toBe('^FO0,0\\&^GE80,40,1,B^FS'); + expect(Ellipse.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GE80,40,1,B^FS'); }); it('fieldOrigin, thickness, lineColor 지정 시 올바른 ZPL 생성', () => { @@ -26,47 +21,47 @@ describe('Ellipse', () => { thickness: 3, lineColor: 'B', }); - expect(Ellipse.print(el, defaultContext)).toBe('^FO10,20\\&^GE100,60,3,B^FS'); + expect(Ellipse.print(el, defaultLabelContext)).toBe('^FO10,20\\&^GE100,60,3,B^FS'); }); it('흰색 타원(lineColor=W) 생성', () => { const el = createElement(Ellipse, { width: 80, height: 40, lineColor: 'W' }); - expect(Ellipse.print(el, defaultContext)).toContain('^GE80,40,1,W^FS'); + expect(Ellipse.print(el, defaultLabelContext)).toContain('^GE80,40,1,W^FS'); }); }); describe('print - 검증', () => { it('width < 3 시 에러 throw', () => { const el = createElement(Ellipse, { width: 2, height: 40 }); - expect(() => Ellipse.print(el, defaultContext)).toThrow( + expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( 'width와 height는 3~4095 사이여야 합니다' ); }); it('height < 3 시 에러 throw', () => { const el = createElement(Ellipse, { width: 80, height: 2 }); - expect(() => Ellipse.print(el, defaultContext)).toThrow( + expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( 'width와 height는 3~4095 사이여야 합니다' ); }); it('width > 4095 시 에러 throw', () => { const el = createElement(Ellipse, { width: 4096, height: 40 }); - expect(() => Ellipse.print(el, defaultContext)).toThrow( + expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( 'width와 height는 3~4095 사이여야 합니다' ); }); it('thickness < 1 시 에러 throw', () => { const el = createElement(Ellipse, { width: 80, height: 40, thickness: 0 }); - expect(() => Ellipse.print(el, defaultContext)).toThrow( + expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); it('thickness > 4095 시 에러 throw', () => { const el = createElement(Ellipse, { width: 80, height: 40, thickness: 4096 }); - expect(() => Ellipse.print(el, defaultContext)).toThrow( + expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); diff --git a/tests/unit/Line.test.tsx b/tests/unit/Line.test.tsx index eacd58a..70f04f1 100644 --- a/tests/unit/Line.test.tsx +++ b/tests/unit/Line.test.tsx @@ -1,14 +1,9 @@ import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; -import { Line, type ZplElementContext } from '@zpl-kit/react-zpl'; +import { Line } from '@zpl-kit/react-zpl'; -const defaultContext: ZplElementContext = { - labelOrientation: 'N', - defaultFontName: 'J', - defaultFontWidth: 30, - defaultFontHeight: 30, -}; +import { defaultLabelContext } from './fixtures/context'; describe('Line', () => { describe('print - 수평선', () => { @@ -17,7 +12,7 @@ describe('Line', () => { length: 100, direction: 'horizontal', }); - expect(Line.print(el, defaultContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); + expect(Line.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); }); it('fieldOrigin, thickness, lineColor 지정 시 올바른 ZPL 생성', () => { @@ -29,7 +24,7 @@ describe('Line', () => { thickness: 2, lineColor: 'B', }); - expect(Line.print(el, defaultContext)).toBe( + expect(Line.print(el, defaultLabelContext)).toBe( '^FO10,20\\&^GB100,2,2,B,0^FS' ); }); @@ -40,7 +35,7 @@ describe('Line', () => { direction: 'horizontal', lineColor: 'W', }); - expect(Line.print(el, defaultContext)).toContain('^GB50,1,1,W,0^FS'); + expect(Line.print(el, defaultLabelContext)).toContain('^GB50,1,1,W,0^FS'); }); }); @@ -50,7 +45,7 @@ describe('Line', () => { length: 50, direction: 'vertical', }); - expect(Line.print(el, defaultContext)).toBe('^FO0,0\\&^GB1,50,1,B,0^FS'); + expect(Line.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GB1,50,1,B,0^FS'); }); it('fieldOrigin, thickness 지정 시 올바른 ZPL 생성', () => { @@ -61,21 +56,21 @@ describe('Line', () => { fieldOriginY: 40, thickness: 3, }); - expect(Line.print(el, defaultContext)).toBe('^FO30,40\\&^GB3,80,3,B,0^FS'); + expect(Line.print(el, defaultLabelContext)).toBe('^FO30,40\\&^GB3,80,3,B,0^FS'); }); }); describe('print - 검증', () => { it('length < 1 시 에러 throw', () => { const el = createElement(Line, { length: 0, direction: 'horizontal' }); - expect(() => Line.print(el, defaultContext)).toThrow( + expect(() => Line.print(el, defaultLabelContext)).toThrow( 'length는 1 이상이어야 합니다' ); }); it('length가 음수일 때 에러 throw', () => { const el = createElement(Line, { length: -10, direction: 'vertical' }); - expect(() => Line.print(el, defaultContext)).toThrow( + expect(() => Line.print(el, defaultLabelContext)).toThrow( 'length는 1 이상이어야 합니다' ); }); @@ -86,7 +81,7 @@ describe('Line', () => { direction: 'horizontal', thickness: 0, }); - expect(() => Line.print(el, defaultContext)).toThrow( + expect(() => Line.print(el, defaultLabelContext)).toThrow( 'thickness는 1 이상이어야 합니다' ); }); diff --git a/tests/unit/QrCode.test.tsx b/tests/unit/QrCode.test.tsx index b8175aa..c786ebc 100644 --- a/tests/unit/QrCode.test.tsx +++ b/tests/unit/QrCode.test.tsx @@ -2,7 +2,7 @@ import { createElement } from 'react'; import { describe, expect, it } from 'vitest'; import { QrCode } from '@zpl-kit/react-zpl'; -import { testLabelContext } from './fixtures/context'; +import { defaultLabelContext } from './fixtures/context'; describe('QrCode.print', () => { it('emits ^FO, ^BQ, and ^FD with matching error correction (QA, + value)', () => { @@ -15,7 +15,7 @@ describe('QrCode.print', () => { }, 'https://example.com' ); - const zpl = QrCode.print(el, testLabelContext()); + const zpl = QrCode.print(el, defaultLabelContext); expect(zpl).toBe( '^FO40,60\\&^BQN,2,4,Q,7\\&^FDQA,https://example.com\\&^FS' ); @@ -23,12 +23,12 @@ describe('QrCode.print', () => { it('throws on empty children', () => { const el = createElement(QrCode, {}, ''); - expect(() => QrCode.print(el, testLabelContext())).toThrow(/빈 문자열/); + expect(() => QrCode.print(el, defaultLabelContext)).toThrow(/빈 문자열/); }); it('throws when children is not a string', () => { const el = createElement(QrCode, {}, ); - expect(() => QrCode.print(el, testLabelContext())).toThrow( + expect(() => QrCode.print(el, defaultLabelContext)).toThrow( /문자열만 허용/ ); }); @@ -43,7 +43,7 @@ describe('QrCode.print', () => { }, 'x' ); - const zpl = QrCode.print(el, testLabelContext()); + const zpl = QrCode.print(el, defaultLabelContext); expect(zpl).toContain('^BQR,2,2,M,7'); expect(zpl).toContain('^FDMA,x'); }); diff --git a/tests/unit/fixtures/context.ts b/tests/unit/fixtures/context.ts index ad97413..2e2ef43 100644 --- a/tests/unit/fixtures/context.ts +++ b/tests/unit/fixtures/context.ts @@ -1,9 +1,9 @@ import { ORIENTATION } from '../../../apps/react-zpl/src/constants/orientation'; import type { ZplElementContext } from '../../../apps/react-zpl/src/types/element'; -export const testLabelContext = (): ZplElementContext => ({ +export const defaultLabelContext: ZplElementContext = { labelOrientation: ORIENTATION.NO_ROTATION, defaultFontName: 'J', defaultFontWidth: 30, defaultFontHeight: 30, -}); +}; From bb2abd8d19eab6326906ae20b8d0741aff74e507 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Mon, 23 Mar 2026 00:30:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(demos/web):=20ZPL=20=EC=98=88=EC=A0=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=ED=99=94=20=EB=B0=8F=20Labelary=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배송/가격표/그래픽/QR 예제를 examples로 분리 - ZPL 편집, 라벨 mm·dpmm, 외부 전송 안내·오류 UI --- demos/web/src/App.css | 29 ++ demos/web/src/App.tsx | 284 ++++++++++++++------ demos/web/src/examples/GraphicsShowcase.tsx | 98 +++++++ demos/web/src/examples/PriceTag.tsx | 198 ++++++++++++++ demos/web/src/examples/QrDemo.tsx | 26 ++ demos/web/src/examples/ShippingLabel.tsx | 88 ++++++ demos/web/src/examples/index.ts | 4 + 7 files changed, 641 insertions(+), 86 deletions(-) create mode 100644 demos/web/src/examples/GraphicsShowcase.tsx create mode 100644 demos/web/src/examples/PriceTag.tsx create mode 100644 demos/web/src/examples/QrDemo.tsx create mode 100644 demos/web/src/examples/ShippingLabel.tsx create mode 100644 demos/web/src/examples/index.ts diff --git a/demos/web/src/App.css b/demos/web/src/App.css index 1483a15..2fa521d 100644 --- a/demos/web/src/App.css +++ b/demos/web/src/App.css @@ -80,3 +80,32 @@ font-size: 0.9em; color: #d63384; } + +.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/web/src/App.tsx b/demos/web/src/App.tsx index bff0169..26a3876 100644 --- a/demos/web/src/App.tsx +++ b/demos/web/src/App.tsx @@ -1,89 +1,101 @@ import { useState } from 'react'; import { - Circle, - DiagonalLine, - Ellipse, - Line, - Text, - ZplLabel, -} from '@zpl-kit/react-zpl'; + graphicsShowcaseZpl, + priceTagZpl, + qrDemoZpl, + shippingLabelZpl, +} from './examples'; import './App.css'; -const TestLabel = ({ text }: { text: string }) => { - return ( - - - {text} - - 텍스트 확인 - - - - - - - +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() { - 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 ( @@ -94,18 +106,118 @@ function App() {
-

ZPL Kit 컴포넌트

-

여기서 ZPL Kit 기능을 테스트할 수 있습니다.

-
-

ZPL Kit 컴포넌트가 정상적으로 로드되었습니다.

-

- 이 컴포넌트는 @zpl-kit/react-zpl에서 가져왔습니다. -

+

예제

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

ZPL 직접 입력

+

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

+
+ + + +
+