diff --git a/.github/workflows/test-react-zpl.yml b/.github/workflows/test-react-zpl.yml index a599a54..f83186c 100644 --- a/.github/workflows/test-react-zpl.yml +++ b/.github/workflows/test-react-zpl.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - 'apps/react-zpl/**' + - 'apps/zpl-core/**' - 'tests/**' - '.github/workflows/test-react-zpl.yml' @@ -25,6 +26,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build zpl-core + run: pnpm --filter @zpl-kit/zpl-core build + - name: Build react-zpl run: pnpm --filter @zpl-kit/react-zpl build diff --git a/README.md b/README.md index 4f22e1c..fc46504 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # zpl-kit -React로 ZPL 라벨을 작성합니다. React 컴포넌트로 ZPL 라벨을 선언적으로 작성하면 `ZplLabel.print()`가 컴포넌트 트리를 ZPL 문자열로 변환합니다. +ZPL 라벨을 생성하는 모노레포입니다. React 컴포넌트로 선언적으로 작성하거나, Node.js 환경에서 순수 함수로 직접 ZPL 문자열을 생성할 수 있습니다. + +## 패키지 + +| 패키지 | 설명 | 환경 | +| --- | --- | --- | +| `@zpl-kit/react-zpl` | React 컴포넌트로 ZPL 생성 | React (브라우저 / SSR) | +| `@zpl-kit/zpl-core` | 순수 함수로 ZPL 생성 | Node.js / 브라우저 | + +## @zpl-kit/react-zpl + +React 컴포넌트로 ZPL 라벨을 선언적으로 작성하면 `ZplLabel.print()`가 컴포넌트 트리를 ZPL 문자열로 변환합니다. ```tsx import { ZplLabel, Text, Line } from '@zpl-kit/react-zpl'; @@ -36,7 +47,26 @@ const zpl = ZplLabel.print( // ^XZ ``` -## 컴포넌트 +## @zpl-kit/zpl-core + +React 없이 순수 함수로 ZPL 문자열을 생성합니다. Node.js 서버, CLI 도구 등 React가 없는 환경에서 사용할 수 있습니다. + +```ts +import { renderLabel, renderText, renderLine, createLabelContext } from '@zpl-kit/zpl-core'; + +const context = createLabelContext({ width: 800, height: 400 }); + +const zpl = renderLabel({ + type: 'label', + props: { width: 800, height: 400 }, + children: [ + { type: 'text', props: { text: 'Hello, ZPL!', fieldOriginX: 50, fieldOriginY: 50 } }, + { type: 'line', props: { direction: 'horizontal', length: 700, fieldOriginX: 50, fieldOriginY: 120, thickness: 3 } }, + ], +}); +``` + +## react-zpl 컴포넌트 ### `` @@ -147,7 +177,8 @@ const zpl = ZplLabel.print( ``` zpl-kit/ ├── apps/ -│ ├── react-zpl/ # 코어 라이브러리 (@zpl-kit/react-zpl) +│ ├── react-zpl/ # React 어댑터 (@zpl-kit/react-zpl) +│ ├── zpl-core/ # 순수 함수 코어 (@zpl-kit/zpl-core) │ └── zpl-viewer/ # ZPL 뷰어 웹 │ ├── demos/ @@ -190,7 +221,6 @@ pnpm docs:preview # 문서 빌드 미리보기 - **ZPL 출력 줄바꿈**: 현재는 명령 사이에 줄바꿈(`\n`)을 넣습니다. 추후 제거하거나 옵션으로 제어할 예정입니다. - **React 19**: 모노레포 전반(코어·뷰어·데모·문서)을 React 19 기준으로 맞출 계획입니다. -- **Node 환경**: 브라우저뿐 아니라 Node에서도 `ZplLabel.print()` 등으로 동일하게 ZPL 문자열을 얻을 수 있도록 지원을 추가할 계획입니다. ## ZPL 미리보기 diff --git a/apps/react-zpl/package.json b/apps/react-zpl/package.json index 0fe9bda..fa6b218 100644 --- a/apps/react-zpl/package.json +++ b/apps/react-zpl/package.json @@ -19,6 +19,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@zpl-kit/zpl-core": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/apps/react-zpl/src/components/Circle.tsx b/apps/react-zpl/src/components/Circle.tsx index 8d7a42d..2f42d3a 100644 --- a/apps/react-zpl/src/components/Circle.tsx +++ b/apps/react-zpl/src/components/Circle.tsx @@ -1,13 +1,7 @@ -import { fieldOrigin, graphicCircle, newLine } from '../commands'; +import { ObjectValues } from '../types'; import { COLOR } from '../constants'; -import { ObjectValues, ZplElement } from '../types'; -const MIN_DIAMETER = 3; -const MAX_DIAMETER = 4095; -const MIN_THICKNESS = 1; -const MAX_THICKNESS = 4095; - -interface CircleProps { +export interface CircleProps { diameter: number; fieldOriginX?: number; fieldOriginY?: number; @@ -15,34 +9,6 @@ interface CircleProps { lineColor?: ObjectValues; } -export const Circle: ZplElement = () => ; +export const Circle = (_props: CircleProps) => ; Circle.displayName = 'Circle'; - -Circle.print = (element, _context) => { - const { - diameter, - fieldOriginX = 0, - fieldOriginY = 0, - thickness = MIN_THICKNESS, - lineColor = COLOR.BLACK, - } = element.props; - - if (diameter < MIN_DIAMETER || diameter > MAX_DIAMETER) { - throw new Error( - `Circle: diameter는 ${MIN_DIAMETER}~${MAX_DIAMETER} 사이여야 합니다. (diameter=${diameter})` - ); - } - if (thickness < MIN_THICKNESS || thickness > MAX_THICKNESS) { - throw new Error( - `Circle: thickness는 ${MIN_THICKNESS}~${MAX_THICKNESS} 사이여야 합니다. (thickness=${thickness})` - ); - } - - const output: string[] = []; - - output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); - output.push(graphicCircle({ diameter, thickness, lineColor })); - - return output.flat().join(newLine()); -}; diff --git a/apps/react-zpl/src/components/DiagonalLine.tsx b/apps/react-zpl/src/components/DiagonalLine.tsx index 42e51ca..8e00fbc 100644 --- a/apps/react-zpl/src/components/DiagonalLine.tsx +++ b/apps/react-zpl/src/components/DiagonalLine.tsx @@ -1,11 +1,7 @@ -import { fieldOrigin, graphicDiagonal, newLine } from '../commands'; +import { ObjectValues } from '../types'; import { COLOR, DIAGONAL_ORIENTATION } from '../constants'; -import { ObjectValues, ZplElement } from '../types'; -const MIN_THICKNESS = 1; -const DIAGONAL_MIN = 3; - -interface DiagonalLineProps { +export interface DiagonalLineProps { width: number; height: number; orientation?: ObjectValues; @@ -15,45 +11,6 @@ interface DiagonalLineProps { lineColor?: ObjectValues; } -export const DiagonalLine: ZplElement = () => ; +export const DiagonalLine = (_props: DiagonalLineProps) => ; DiagonalLine.displayName = 'DiagonalLine'; - -DiagonalLine.print = (element, _context) => { - const { - width, - height, - orientation = DIAGONAL_ORIENTATION.RIGHT_DOWN, - fieldOriginX = 0, - fieldOriginY = 0, - thickness = MIN_THICKNESS, - lineColor = COLOR.BLACK, - } = element.props; - - if (width < DIAGONAL_MIN || height < DIAGONAL_MIN) { - throw new Error( - `DiagonalLine: width와 height는 ${DIAGONAL_MIN} 이상이어야 합니다. (width=${width}, height=${height})` - ); - } - - if (thickness < MIN_THICKNESS) { - throw new Error( - `DiagonalLine: thickness는 ${MIN_THICKNESS} 이상이어야 합니다. (thickness=${thickness})` - ); - } - - const graphic = graphicDiagonal({ - width, - height, - thickness, - lineColor, - orientation, - }); - - const output: string[] = []; - - output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); - output.push(graphic); - - return output.flat().join(newLine()); -}; diff --git a/apps/react-zpl/src/components/Ellipse.tsx b/apps/react-zpl/src/components/Ellipse.tsx index 01f2faa..28d928a 100644 --- a/apps/react-zpl/src/components/Ellipse.tsx +++ b/apps/react-zpl/src/components/Ellipse.tsx @@ -1,13 +1,7 @@ -import { fieldOrigin, graphicEllipse, newLine } from '../commands'; +import { ObjectValues } from '../types'; import { COLOR } from '../constants'; -import { ObjectValues, ZplElement } from '../types'; -const MIN_SIZE = 3; -const MAX_SIZE = 4095; -const MIN_THICKNESS = 1; -const MAX_THICKNESS = 4095; - -interface EllipseProps { +export interface EllipseProps { width: number; height: number; fieldOriginX?: number; @@ -16,35 +10,6 @@ interface EllipseProps { lineColor?: ObjectValues; } -export const Ellipse: ZplElement = () => ; +export const Ellipse = (_props: EllipseProps) => ; Ellipse.displayName = 'Ellipse'; - -Ellipse.print = (element, _context) => { - const { - width, - height, - fieldOriginX = 0, - fieldOriginY = 0, - thickness = MIN_THICKNESS, - lineColor = COLOR.BLACK, - } = element.props; - - if (width < MIN_SIZE || width > MAX_SIZE || height < MIN_SIZE || height > MAX_SIZE) { - throw new Error( - `Ellipse: width와 height는 ${MIN_SIZE}~${MAX_SIZE} 사이여야 합니다. (width=${width}, height=${height})` - ); - } - if (thickness < MIN_THICKNESS || thickness > MAX_THICKNESS) { - throw new Error( - `Ellipse: thickness는 ${MIN_THICKNESS}~${MAX_THICKNESS} 사이여야 합니다. (thickness=${thickness})` - ); - } - - const output: string[] = []; - - output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); - output.push(graphicEllipse({ width, height, thickness, lineColor })); - - return output.flat().join(newLine()); -}; diff --git a/apps/react-zpl/src/components/Line.tsx b/apps/react-zpl/src/components/Line.tsx index e8b3a5a..05b2c06 100644 --- a/apps/react-zpl/src/components/Line.tsx +++ b/apps/react-zpl/src/components/Line.tsx @@ -1,11 +1,7 @@ -import { fieldOrigin, graphicBox, newLine } from '../commands'; +import { ObjectValues } from '../types'; import { COLOR } from '../constants'; -import { ObjectValues, ZplElement } from '../types'; -const MIN_LENGTH = 1; -const MIN_THICKNESS = 1; - -interface LineProps { +export interface LineProps { direction: 'horizontal' | 'vertical'; length: number; fieldOriginX?: number; @@ -14,50 +10,6 @@ interface LineProps { lineColor?: ObjectValues; } -export const Line: ZplElement = () => ; +export const Line = (_props: LineProps) => ; Line.displayName = 'Line'; - -Line.print = (element, _context) => { - const { - direction, - length, - fieldOriginX = 0, - fieldOriginY = 0, - thickness = MIN_THICKNESS, - lineColor = COLOR.BLACK, - } = element.props; - - if (length < MIN_LENGTH) { - throw new Error( - `Line: length는 ${MIN_LENGTH} 이상이어야 합니다. (length=${length})` - ); - } - if (thickness < MIN_THICKNESS) { - throw new Error( - `Line: thickness는 ${MIN_THICKNESS} 이상이어야 합니다. (thickness=${thickness})` - ); - } - - const graphic = - direction === 'horizontal' - ? graphicBox({ - width: length, - height: thickness, - borderThickness: thickness, - lineColor, - }) - : graphicBox({ - width: thickness, - height: length, - borderThickness: thickness, - lineColor, - }); - - const output: string[] = []; - - output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); - output.push(graphic); - - return output.flat().join(newLine()); -}; diff --git a/apps/react-zpl/src/components/QrCode.tsx b/apps/react-zpl/src/components/QrCode.tsx index 9b43570..4fad7c5 100644 --- a/apps/react-zpl/src/components/QrCode.tsx +++ b/apps/react-zpl/src/components/QrCode.tsx @@ -1,14 +1,8 @@ import { PropsWithChildren } from 'react'; -import { - barcodeQR, - fieldData, - fieldOrigin, - newLine, - type QrErrorCorrection, -} from '../commands'; +import { type QrErrorCorrection } from '@zpl-kit/zpl-core'; +import { ObjectValues } from '../types'; import { ORIENTATION } from '../constants'; -import type { ObjectValues, ZplElement, ZplElementContext } from '../types'; export interface QrCodeProps extends PropsWithChildren { fieldOriginX?: number; @@ -16,53 +10,12 @@ export interface QrCodeProps extends PropsWithChildren { fieldOrientation?: ObjectValues; model?: 1 | 2; magnification?: number; - /** `^BQ`의 `d`와 `^FD` 스위치 첫 글자가 동일해야 함 */ errorCorrectionLevel?: QrErrorCorrection; maskValue?: number; } -export const QrCode: ZplElement = ({ children }) => ( +export const QrCode = ({ children }: QrCodeProps) => ( {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/Text.tsx b/apps/react-zpl/src/components/Text.tsx index 6a92822..a95b521 100644 --- a/apps/react-zpl/src/components/Text.tsx +++ b/apps/react-zpl/src/components/Text.tsx @@ -1,8 +1,7 @@ import { PropsWithChildren } from 'react'; -import { ObjectValues, ZplElement } from '../types'; +import { ObjectValues } from '../types'; import { ORIENTATION } from '../constants'; -import { fieldData, fieldFont, fieldOrigin, newLine } from '../commands'; interface BaseTextProps extends PropsWithChildren { fieldOriginX?: number; @@ -24,63 +23,10 @@ interface TextNotFontInheritProps extends BaseTextProps { fontHeight: number; } -type TextProps = TextFontInheritProps | TextNotFontInheritProps; +export type TextProps = TextFontInheritProps | TextNotFontInheritProps; -export const Text: ZplElement = ({ children }) => { +export const Text = ({ children }: TextProps) => { return {children}; }; Text.displayName = 'Text'; - -Text.print = (element, context) => { - const { - children, - fieldOriginX = 0, - fieldOriginY = 0, - fieldOrientation, - fontInherit, - } = element.props; - - if (typeof children !== 'string') { - throw new Error('Text 컴포넌트는 children에 문자열만 허용합니다.'); - } - - const { - defaultFontName, - defaultFontWidth, - defaultFontHeight, - labelOrientation, - } = context; - - const { fontName, width, height } = - fontInherit === false - ? { - fontName: element.props.fontName, - width: element.props.fontWidth, - height: element.props.fontHeight, - } - : { - fontName: defaultFontName, - width: defaultFontWidth, - height: defaultFontHeight, - }; - const _fieldOrientation = fieldOrientation ?? labelOrientation; - - const output: string[] = []; - - // Set field origin - output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); - // Set field font - output.push( - fieldFont({ - fontName, - fieldOrientation: _fieldOrientation, - width, - height, - }) - ); - // Set print text - output.push(fieldData(children)); - - return output.flat().join(newLine()); -}; diff --git a/apps/react-zpl/src/components/ZplLabel.tsx b/apps/react-zpl/src/components/ZplLabel.tsx index d75fbd9..5510a07 100644 --- a/apps/react-zpl/src/components/ZplLabel.tsx +++ b/apps/react-zpl/src/components/ZplLabel.tsx @@ -1,19 +1,9 @@ import { PropsWithChildren, ReactElement } from 'react'; -import { - changeDefaultFont, - changeInternationalEncoding, - endFormat, - fieldOrientation, - labelHome, - labelLength, - newLine, - printWidth, - startFormat, -} from '../commands'; -import { printChildren } from '../utils'; -import { ObjectValues, ZplElement, ZplElementContext } from '../types'; -import { ORIENTATION, UTF8_ENCODING } from '../constants'; +import { renderLabel } from '@zpl-kit/zpl-core'; +import { toLabelNode } from '../utils'; +import { ObjectValues, ZplElement } from '../types'; +import { ORIENTATION } from '../constants'; interface ZplLabelProps extends PropsWithChildren { width: number; // dots @@ -38,53 +28,6 @@ export const ZplLabel: ZplLabelComponent = ({ children }) => { ZplLabel.displayName = 'ZplLabel'; ZplLabel.print = (element) => { - const { - width, - height, - offsetX = 0, - offsetY = 0, - labelOrientation = ORIENTATION.NO_ROTATION, - encoding = [UTF8_ENCODING], - defaultFontName = 'J', // default korean - defaultFontWidth = 30, - defaultFontHeight = 30, - } = element.props; - - const context: ZplElementContext = { - labelOrientation, - defaultFontName, - defaultFontWidth, - defaultFontHeight, - }; - - const output = []; - - // Start format - output.push(startFormat()); - - // Set label size, orientation, home position - output.push(printWidth(width)); - output.push(labelLength(height)); - output.push(fieldOrientation({ orientation: labelOrientation })); - output.push(labelHome({ offsetX, offsetY })); - - // TODO: add zpl commands - - // Set encoding - output.push(changeInternationalEncoding(encoding)); - // Set default fonts - output.push( - changeDefaultFont({ - fontName: defaultFontName, - width: defaultFontWidth, - height: defaultFontHeight, - }) - ); - - // Add print children - output.push(printChildren(element, context)); - // End format - output.push(endFormat()); - - return output.flat(Infinity).join(newLine()); + const node = toLabelNode(element); // ReactElement → LabelRootNode + return renderLabel(node); // LabelRootNode → ZPL }; diff --git a/apps/react-zpl/src/types/element.ts b/apps/react-zpl/src/types/element.ts index 0ee4cf6..e69676c 100644 --- a/apps/react-zpl/src/types/element.ts +++ b/apps/react-zpl/src/types/element.ts @@ -1,63 +1,16 @@ import { ReactElement } from 'react'; -import { ObjectValues } from './common'; -import { ORIENTATION } from '../constants'; +import { ZplElementContext } from '@zpl-kit/zpl-core'; -/** - * ZPL 명령어 생성에 필요한 컨텍스트 정보 - * - * 이 인터페이스는 ZPL 라벨 생성 과정에서 컴포넌트 간 공유되는 컨텍스트 정보를 담는 용도입니다. - * - * - 라벨 전역 설정 (폰트, 위치, 정렬 등) - * - 재귀적 렌더링 시 상위 컴포넌트의 설정 정보 - * - ZPL 명령어 생성에 필요한 메타데이터 - * - * @see {@link ZplElement} - 이 컨텍스트를 사용하는 인터페이스 - */ -export interface ZplElementContext { - labelOrientation: ObjectValues; - defaultFontName: string; - defaultFontWidth: number; - defaultFontHeight: number; -} +export type { ZplElementContext }; /** - * ZPL 명령어를 생성할 수 있는 React 컴포넌트를 정의하는 인터페이스 + * ZPL 라벨 루트 컴포넌트(ZplLabel)의 인터페이스 + * print()는 ZplLabel에서만 사용되며, ReactElement 트리를 ZPL 문자열로 변환한다. */ export interface ZplElement { - /** - * 컴포넌트 식별자 - */ displayName: string; - /** - * React 컴포넌트 함수 시그니처 - * - * @param props - 컴포넌트가 받는 props - * @returns 렌더링된 JSX 엘리먼트 - */ (props: Props): JSX.Element; - /** - * ReactElement를 ZPL 명령어 문자열로 변환하는 메서드 - * - * @param element - 변환할 React 엘리먼트 (자신의 props를 포함) - * @param context - ZPL 명령어 생성에 필요한 컨텍스트 정보 - * @returns ZPL 명령어 문자열 - */ - print: (element: ReactElement, context: ZplElementContext) => string; -} - -/** - * 주어진 요소가 ZplElement인지 확인하는 타입 가드 함수 - * - * @param element - 검증할 요소 - * @returns 요소가 ZplElement면 `true`, 그렇지 않으면 `false` - * - * @see {@link ZplElement} - 검증 대상 인터페이스 - */ -export function isZplElement(element: unknown): element is ZplElement { - return ( - typeof element === 'function' && - typeof (element as ZplElement).print === 'function' - ); + print: (element: ReactElement) => string; } diff --git a/apps/react-zpl/src/utils/index.ts b/apps/react-zpl/src/utils/index.ts index 47560fe..58aaf5c 100644 --- a/apps/react-zpl/src/utils/index.ts +++ b/apps/react-zpl/src/utils/index.ts @@ -1 +1 @@ -export * from './print'; +export * from './toNode'; diff --git a/apps/react-zpl/src/utils/print.ts b/apps/react-zpl/src/utils/print.ts deleted file mode 100644 index 4f308ab..0000000 --- a/apps/react-zpl/src/utils/print.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { isValidElement, ReactNode } from 'react'; - -import { renderReactElement } from './render'; -import { isZplElement, ZplElementContext } from '../types'; - -/** - * 단일 React 노드를 재귀적으로 순회하면서 자식 노드를 ZPL 렌더링 파이프라인에 맞게 처리하는 헬퍼 - * - * @param node - 처리할 React 노드 (ReactNode 타입) - * @param context - ZPL 명령어 생성에 필요한 컨텍스트 정보 - * @returns 렌더링된 ReactNode 배열. 유효하지 않은 노드인 경우 `undefined`를 반환합니다. - */ -export const printChild = ( - node: ReactNode, - context: ZplElementContext -): ReactNode[] | undefined => { - if (!isValidElement(node)) return; - - const children: ReactNode[] = []; - - for (const element of renderReactElement(node)) { - if (!isValidElement(element)) return; - - // TODO: print element with context - if (isZplElement(element.type)) { - children.push(element.type.print(element, context)); - } else if (element.props.children) { - children.push(printChild(element.props.children, context)); - } - } - - return children; -}; - -/** - * React 노드의 자식 요소들을 렌더링하여 배열로 반환하는 함수 - * - * 이 함수는 ZPL 라벨 생성 과정에서 React 컴포넌트 트리를 순회하며 - * 각 노드의 children을 추출하고 렌더링하는 역할을 합니다. - * - * @param node - 처리할 React 노드 (ReactNode 타입) - * @param context - ZPL 명령어 생성에 필요한 컨텍스트 정보 - * @returns 렌더링된 ReactNode 배열. 유효하지 않은 노드인 경우 `undefined`를 반환합니다. - * - * @see {@link renderReactElement} - 내부적으로 사용하는 렌더링 함수 - */ -export const printChildren = ( - node: ReactNode, - context: ZplElementContext -): ReactNode[] | undefined => { - if (!isValidElement(node)) return; - - const children: ReactNode[] = []; - - for (const child of renderReactElement(node)) { - children.push(printChild(child, context)); - } - - return children; -}; diff --git a/apps/react-zpl/src/utils/toNode.ts b/apps/react-zpl/src/utils/toNode.ts new file mode 100644 index 0000000..346d8b9 --- /dev/null +++ b/apps/react-zpl/src/utils/toNode.ts @@ -0,0 +1,76 @@ +import { Children, isValidElement, ReactElement, ReactNode } from 'react'; + +import { + ChildLabelNode, + LabelRootNode, + type LabelCoreProps, +} from '@zpl-kit/zpl-core'; + +import { renderReactElement } from './render'; + +/** + * ReactElement 트리를 LabelRootNode로 변환한다. + * + * ZplLabel element를 받아 자식 컴포넌트들을 재귀적으로 순회하며 + * zpl-core가 이해하는 순수 데이터 트리(LabelNode)를 생성한다. + */ +export function toLabelNode(element: ReactElement): LabelRootNode { + return { + type: 'label', + props: element.props as LabelCoreProps, + children: collectChildNodes(element), + }; +} + +function collectChildNodes(element: ReactElement): ChildLabelNode[] { + const nodes: ChildLabelNode[] = []; + for (const child of renderReactElement(element)) { + nodes.push(...extractNodes(child)); + } + return nodes; +} + +function extractNodes(node: ReactNode): ChildLabelNode[] { + if (!isValidElement(node)) return []; + + const displayName = (node.type as { displayName?: string }).displayName; + const childNode = toChildNode(node, displayName); + if (childNode) return [childNode]; + + // ZPL 컴포넌트가 아닌 경우 (div 등) children을 재귀 탐색 + const children = node.props?.children; + if (children) { + return Children.toArray(children).flatMap(extractNodes); + } + + return []; +} + +function toChildNode( + node: ReactElement, + displayName: string | undefined +): ChildLabelNode | undefined { + switch (displayName) { + case 'Text': { + const { children, ...rest } = node.props; + return { type: 'text', props: { text: children, ...rest } }; + } + case 'Line': + return { type: 'line', props: node.props }; + case 'DiagonalLine': + return { type: 'diagonalLine', props: node.props }; + case 'Circle': + return { type: 'circle', props: node.props }; + case 'Ellipse': + return { type: 'ellipse', props: node.props }; + case 'QrCode': { + const { children, errorCorrectionLevel, ...rest } = node.props; + return { + type: 'qrCode', + props: { text: children, errorCorrectionLevel, ...rest }, + }; + } + default: + return undefined; + } +} diff --git a/apps/zpl-core/package.json b/apps/zpl-core/package.json new file mode 100644 index 0000000..e2d25c0 --- /dev/null +++ b/apps/zpl-core/package.json @@ -0,0 +1,25 @@ +{ + "name": "@zpl-kit/zpl-core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "development": "./src/index.ts", + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "dev": "rolldown --watch -c rolldown.config.ts", + "build": "rolldown -c rolldown.config.ts && tsc --emitDeclarationOnly --noEmit false", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "rolldown": "^1.0.0-beta.51", + "typescript": "^5.7.0" + } +} diff --git a/apps/zpl-core/rolldown.config.ts b/apps/zpl-core/rolldown.config.ts new file mode 100644 index 0000000..ca277ae --- /dev/null +++ b/apps/zpl-core/rolldown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'rolldown'; + +export default defineConfig({ + input: 'src/index.ts', + output: { + format: 'es', + dir: 'dist', + }, +}); diff --git a/apps/zpl-core/src/commands/barcodeQR.ts b/apps/zpl-core/src/commands/barcodeQR.ts new file mode 100644 index 0000000..e24032d --- /dev/null +++ b/apps/zpl-core/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/zpl-core/src/commands/base.ts b/apps/zpl-core/src/commands/base.ts new file mode 100644 index 0000000..7afd439 --- /dev/null +++ b/apps/zpl-core/src/commands/base.ts @@ -0,0 +1,24 @@ +/** + * ZPL 명령어를 문자열로 생성하는 함수 시그니처 + * + * - `P`가 `void`이면 사용자가 전달할 매개변수가 없으므로 단순히 문자열을 반환하는 함수가 됩니다. + * - `P`가 객체 타입이면 `params`로 해당 속성을 전달하여 명령 문자열을 동적으로 구성합니다. + */ +export type Command

= P extends void + ? () => string + : (params: P) => string; + +/** + * 명령어 생성을 담당하는 실제 구현 함수 타입 + */ +export type Resolver

= P extends void ? () => string : (params: P) => string; + +/** + * 명령어 정의 헬퍼 함수 + * + * @param resolver - 실제 명령 문자열을 만드는 함수. 파라미터 유무를 자동으로 처리합니다. + * @returns `Command

` - `resolver`와 동일한 시그니처의 호출 가능한 명령어 함수 + */ +export const defineCommand =

(resolver: Resolver

): Command

=> { + return resolver as Command

; +}; diff --git a/apps/zpl-core/src/commands/changeDefaultFont.ts b/apps/zpl-core/src/commands/changeDefaultFont.ts new file mode 100644 index 0000000..a7d2528 --- /dev/null +++ b/apps/zpl-core/src/commands/changeDefaultFont.ts @@ -0,0 +1,15 @@ +import { defineCommand } from './base'; + +interface ChangeDefaultFontParams { + fontName?: string; // A-Z, 0-9 + height: number; + width: number; +} + +export const changeDefaultFont = defineCommand( + ({ + fontName = 'J', // default korean + height, + width, + }) => `^CF${fontName},${height},${width}` +); diff --git a/apps/zpl-core/src/commands/changeInternationalEncoding.ts b/apps/zpl-core/src/commands/changeInternationalEncoding.ts new file mode 100644 index 0000000..1de0b3c --- /dev/null +++ b/apps/zpl-core/src/commands/changeInternationalEncoding.ts @@ -0,0 +1,14 @@ +import { defineCommand } from './base'; + +import { UTF8_ENCODING } from '../constants'; + +type ChangeInternationalEncodingParams = string[] | undefined; + +export const changeInternationalEncoding = + defineCommand( + (characterSets = [UTF8_ENCODING]) => { + const joinedCharacterSets = characterSets.join(','); + + return `^CI${joinedCharacterSets}`; + } + ); diff --git a/apps/zpl-core/src/commands/endFormat.ts b/apps/zpl-core/src/commands/endFormat.ts new file mode 100644 index 0000000..03836da --- /dev/null +++ b/apps/zpl-core/src/commands/endFormat.ts @@ -0,0 +1,3 @@ +import { defineCommand } from './base'; + +export const endFormat = defineCommand(() => '^XZ'); diff --git a/apps/zpl-core/src/commands/fieldBlock.ts b/apps/zpl-core/src/commands/fieldBlock.ts new file mode 100644 index 0000000..4ab6cdd --- /dev/null +++ b/apps/zpl-core/src/commands/fieldBlock.ts @@ -0,0 +1,17 @@ +import { defineCommand } from './base'; + +import { ALIGN } from '../constants'; +import { ObjectValues } from '../types'; + +interface FieldBlockParams { + blockWidth?: number; + maxLine?: number; + lineSpacing?: number; + align?: ObjectValues; + indent?: number; +} + +export const fieldBlock = defineCommand( + ({ blockWidth = 0, maxLine = 1, lineSpacing = 0, align = 'L', indent = 0 }) => + `^FB${blockWidth},${maxLine},${lineSpacing},${align},${indent}` +); diff --git a/apps/zpl-core/src/commands/fieldData.ts b/apps/zpl-core/src/commands/fieldData.ts new file mode 100644 index 0000000..750834a --- /dev/null +++ b/apps/zpl-core/src/commands/fieldData.ts @@ -0,0 +1,6 @@ +import { defineCommand } from './base'; +import { newLine } from './newLine'; + +export const fieldData = defineCommand((text: string) => { + return `^FD${text}${newLine()}^FS`; +}); diff --git a/apps/zpl-core/src/commands/fieldFont.ts b/apps/zpl-core/src/commands/fieldFont.ts new file mode 100644 index 0000000..f5a1dae --- /dev/null +++ b/apps/zpl-core/src/commands/fieldFont.ts @@ -0,0 +1,16 @@ +import { defineCommand } from './base'; + +import { ORIENTATION } from '../constants'; +import { ObjectValues } from '../types'; + +interface FieldFontParams { + fontName?: string; // A-Z, 0-9, downloaded font + fieldOrientation?: ObjectValues; + height: number; // scalable: 10 - 32000 + width: number; // scalable: 10 - 32000 +} + +export const fieldFont = defineCommand( + ({ fontName = '0', fieldOrientation = 'N', height, width }) => + `^A${fontName}${fieldOrientation},${height},${width}` +); diff --git a/apps/zpl-core/src/commands/fieldOrientation.ts b/apps/zpl-core/src/commands/fieldOrientation.ts new file mode 100644 index 0000000..174ef9d --- /dev/null +++ b/apps/zpl-core/src/commands/fieldOrientation.ts @@ -0,0 +1,12 @@ +import { defineCommand } from './base'; + +import { ORIENTATION } from '../constants'; +import { ObjectValues } from '../types'; + +interface FieldOrientationParams { + orientation?: ObjectValues; +} + +export const fieldOrientation = defineCommand( + ({ orientation = ORIENTATION.NO_ROTATION }) => `^FW${orientation}` +); diff --git a/apps/zpl-core/src/commands/fieldOrigin.ts b/apps/zpl-core/src/commands/fieldOrigin.ts new file mode 100644 index 0000000..51b0cc5 --- /dev/null +++ b/apps/zpl-core/src/commands/fieldOrigin.ts @@ -0,0 +1,10 @@ +import { defineCommand } from './base'; + +interface FieldOriginParams { + offsetX: number; + offsetY: number; +} + +export const fieldOrigin = defineCommand( + ({ offsetX, offsetY }) => `^FO${offsetX},${offsetY}` +); diff --git a/apps/zpl-core/src/commands/graphicBox.ts b/apps/zpl-core/src/commands/graphicBox.ts new file mode 100644 index 0000000..7c18766 --- /dev/null +++ b/apps/zpl-core/src/commands/graphicBox.ts @@ -0,0 +1,17 @@ +import { defineCommand } from './base'; + +import { COLOR } from '../constants'; +import { ObjectValues } from '../types'; + +interface GraphicBoxParams { + width: number; + height: number; + borderThickness?: number; + lineColor?: ObjectValues; + radius?: number; +} + +export const graphicBox = defineCommand( + ({ width, height, borderThickness = 1, lineColor = 'B', radius = 0 }) => + `^GB${width},${height},${borderThickness},${lineColor},${radius}^FS` +); diff --git a/apps/zpl-core/src/commands/graphicCircle.ts b/apps/zpl-core/src/commands/graphicCircle.ts new file mode 100644 index 0000000..7ea12fb --- /dev/null +++ b/apps/zpl-core/src/commands/graphicCircle.ts @@ -0,0 +1,15 @@ +import { defineCommand } from './base'; + +import { COLOR } from '../constants'; +import { ObjectValues } from '../types'; + +interface GraphicCircleParams { + diameter: number; + thickness?: number; + lineColor?: ObjectValues; +} + +export const graphicCircle = defineCommand( + ({ diameter, thickness = 1, lineColor = COLOR.BLACK }) => + `^GC${diameter},${thickness},${lineColor}^FS` +); diff --git a/apps/zpl-core/src/commands/graphicDiagonal.ts b/apps/zpl-core/src/commands/graphicDiagonal.ts new file mode 100644 index 0000000..43fb60e --- /dev/null +++ b/apps/zpl-core/src/commands/graphicDiagonal.ts @@ -0,0 +1,17 @@ +import { defineCommand } from './base'; + +import { COLOR, DIAGONAL_ORIENTATION } from '../constants'; +import { ObjectValues } from '../types'; + +interface GraphicDiagonalParams { + width: number; + height: number; + thickness?: number; + lineColor?: ObjectValues; + orientation?: ObjectValues; +} + +export const graphicDiagonal = defineCommand( + ({ width, height, thickness = 1, lineColor = 'B', orientation = 'R' }) => + `^GD${width},${height},${thickness},${lineColor},${orientation}^FS` +); diff --git a/apps/zpl-core/src/commands/graphicEllipse.ts b/apps/zpl-core/src/commands/graphicEllipse.ts new file mode 100644 index 0000000..12a6dba --- /dev/null +++ b/apps/zpl-core/src/commands/graphicEllipse.ts @@ -0,0 +1,16 @@ +import { defineCommand } from './base'; + +import { COLOR } from '../constants'; +import { ObjectValues } from '../types'; + +interface GraphicEllipseParams { + width: number; + height: number; + thickness?: number; + lineColor?: ObjectValues; +} + +export const graphicEllipse = defineCommand( + ({ width, height, thickness = 1, lineColor = COLOR.BLACK }) => + `^GE${width},${height},${thickness},${lineColor}^FS` +); diff --git a/apps/zpl-core/src/commands/index.ts b/apps/zpl-core/src/commands/index.ts new file mode 100644 index 0000000..d5d54fa --- /dev/null +++ b/apps/zpl-core/src/commands/index.ts @@ -0,0 +1,18 @@ +export * from './startFormat'; +export * from './endFormat'; +export * from './newLine'; +export * from './printWidth'; +export * from './labelLength'; +export * from './labelHome'; +export * from './fieldBlock'; +export * from './fieldData'; +export * from './fieldOrientation'; +export * from './fieldOrigin'; +export * from './fieldFont'; +export * from './changeDefaultFont'; +export * from './changeInternationalEncoding'; +export * from './graphicBox'; +export * from './graphicDiagonal'; +export * from './graphicCircle'; +export * from './graphicEllipse'; +export * from './barcodeQR'; diff --git a/apps/zpl-core/src/commands/labelHome.ts b/apps/zpl-core/src/commands/labelHome.ts new file mode 100644 index 0000000..bcb8018 --- /dev/null +++ b/apps/zpl-core/src/commands/labelHome.ts @@ -0,0 +1,10 @@ +import { defineCommand } from './base'; + +interface LabelHomeParams { + offsetX: number; + offsetY: number; +} + +export const labelHome = defineCommand( + ({ offsetX, offsetY }) => `^LH${offsetX},${offsetY}` +); diff --git a/apps/zpl-core/src/commands/labelLength.ts b/apps/zpl-core/src/commands/labelLength.ts new file mode 100644 index 0000000..43ac19b --- /dev/null +++ b/apps/zpl-core/src/commands/labelLength.ts @@ -0,0 +1,3 @@ +import { defineCommand } from './base'; + +export const labelLength = defineCommand((height: number) => `^LL${height}`); diff --git a/apps/zpl-core/src/commands/newLine.ts b/apps/zpl-core/src/commands/newLine.ts new file mode 100644 index 0000000..0f427b6 --- /dev/null +++ b/apps/zpl-core/src/commands/newLine.ts @@ -0,0 +1,3 @@ +import { defineCommand } from './base'; + +export const newLine = defineCommand(() => '\\&'); diff --git a/apps/zpl-core/src/commands/printWidth.ts b/apps/zpl-core/src/commands/printWidth.ts new file mode 100644 index 0000000..22fb39d --- /dev/null +++ b/apps/zpl-core/src/commands/printWidth.ts @@ -0,0 +1,3 @@ +import { defineCommand } from './base'; + +export const printWidth = defineCommand((width: number) => `^PW${width}`); diff --git a/apps/zpl-core/src/commands/startFormat.ts b/apps/zpl-core/src/commands/startFormat.ts new file mode 100644 index 0000000..707e6cd --- /dev/null +++ b/apps/zpl-core/src/commands/startFormat.ts @@ -0,0 +1,3 @@ +import { defineCommand } from './base'; + +export const startFormat = defineCommand(() => '^XA'); diff --git a/apps/zpl-core/src/constants/align.ts b/apps/zpl-core/src/constants/align.ts new file mode 100644 index 0000000..5682874 --- /dev/null +++ b/apps/zpl-core/src/constants/align.ts @@ -0,0 +1,6 @@ +export const ALIGN = { + LEFT: 'L', + RIGHT: 'R', + CENTER: 'C', + JUSTIFIED: 'J', +} as const; diff --git a/apps/zpl-core/src/constants/color.ts b/apps/zpl-core/src/constants/color.ts new file mode 100644 index 0000000..7e43f10 --- /dev/null +++ b/apps/zpl-core/src/constants/color.ts @@ -0,0 +1,4 @@ +export const COLOR = { + BLACK: 'B', + WHITE: 'W', +} as const; diff --git a/apps/zpl-core/src/constants/encoding.ts b/apps/zpl-core/src/constants/encoding.ts new file mode 100644 index 0000000..8eb0e2d --- /dev/null +++ b/apps/zpl-core/src/constants/encoding.ts @@ -0,0 +1 @@ +export const UTF8_ENCODING = '28' as const; diff --git a/apps/zpl-core/src/constants/index.ts b/apps/zpl-core/src/constants/index.ts new file mode 100644 index 0000000..4f2af61 --- /dev/null +++ b/apps/zpl-core/src/constants/index.ts @@ -0,0 +1,4 @@ +export * from './align'; +export * from './orientation'; +export * from './encoding'; +export * from './color'; diff --git a/apps/zpl-core/src/constants/orientation.ts b/apps/zpl-core/src/constants/orientation.ts new file mode 100644 index 0000000..895a639 --- /dev/null +++ b/apps/zpl-core/src/constants/orientation.ts @@ -0,0 +1,11 @@ +export const ORIENTATION = { + NO_ROTATION: 'N', + ROTATED_90: 'R', // rotated 90 degrees (clockwise) + INVERTED: 'I', // inverted 180 degrees + BOTTOM_UP: 'B', // read from bottom up, 270 degrees +} as const; + +export const DIAGONAL_ORIENTATION = { + RIGHT_DOWN: 'R', + LEFT_UP: 'L', +} as const; diff --git a/apps/zpl-core/src/index.ts b/apps/zpl-core/src/index.ts new file mode 100644 index 0000000..eccd436 --- /dev/null +++ b/apps/zpl-core/src/index.ts @@ -0,0 +1,4 @@ +export * from './commands'; +export * from './constants'; +export * from './types'; +export * from './renderers'; diff --git a/apps/zpl-core/src/renderers/circle.ts b/apps/zpl-core/src/renderers/circle.ts new file mode 100644 index 0000000..03dfeec --- /dev/null +++ b/apps/zpl-core/src/renderers/circle.ts @@ -0,0 +1,44 @@ +import { fieldOrigin, graphicCircle, newLine } from '../commands'; +import { COLOR } from '../constants'; +import { ObjectValues } from '../types'; + +const MIN_DIAMETER = 3; +const MAX_DIAMETER = 4095; +const MIN_THICKNESS = 1; +const MAX_THICKNESS = 4095; + +export interface CircleCoreProps { + diameter: number; + fieldOriginX?: number; + fieldOriginY?: number; + thickness?: number; + lineColor?: ObjectValues; +} + +export function renderCircle(props: CircleCoreProps): string { + const { + diameter, + fieldOriginX = 0, + fieldOriginY = 0, + thickness = MIN_THICKNESS, + lineColor = COLOR.BLACK, + } = props; + + if (diameter < MIN_DIAMETER || diameter > MAX_DIAMETER) { + throw new Error( + `renderCircle: diameter는 ${MIN_DIAMETER}~${MAX_DIAMETER} 사이여야 합니다. (diameter=${diameter})` + ); + } + if (thickness < MIN_THICKNESS || thickness > MAX_THICKNESS) { + throw new Error( + `renderCircle: thickness는 ${MIN_THICKNESS}~${MAX_THICKNESS} 사이여야 합니다. (thickness=${thickness})` + ); + } + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push(graphicCircle({ diameter, thickness, lineColor })); + + return output.join(newLine()); +} diff --git a/apps/zpl-core/src/renderers/diagonalLine.ts b/apps/zpl-core/src/renderers/diagonalLine.ts new file mode 100644 index 0000000..9e23f36 --- /dev/null +++ b/apps/zpl-core/src/renderers/diagonalLine.ts @@ -0,0 +1,46 @@ +import { fieldOrigin, graphicDiagonal, newLine } from '../commands'; +import { COLOR, DIAGONAL_ORIENTATION } from '../constants'; +import { ObjectValues } from '../types'; + +const MIN_THICKNESS = 1; +const DIAGONAL_MIN = 3; + +export interface DiagonalLineCoreProps { + width: number; + height: number; + orientation?: ObjectValues; + fieldOriginX?: number; + fieldOriginY?: number; + thickness?: number; + lineColor?: ObjectValues; +} + +export function renderDiagonalLine(props: DiagonalLineCoreProps): string { + const { + width, + height, + orientation = DIAGONAL_ORIENTATION.RIGHT_DOWN, + fieldOriginX = 0, + fieldOriginY = 0, + thickness = MIN_THICKNESS, + lineColor = COLOR.BLACK, + } = props; + + if (width < DIAGONAL_MIN || height < DIAGONAL_MIN) { + throw new Error( + `renderDiagonalLine: width와 height는 ${DIAGONAL_MIN} 이상이어야 합니다. (width=${width}, height=${height})` + ); + } + if (thickness < MIN_THICKNESS) { + throw new Error( + `renderDiagonalLine: thickness는 ${MIN_THICKNESS} 이상이어야 합니다. (thickness=${thickness})` + ); + } + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push(graphicDiagonal({ width, height, thickness, lineColor, orientation })); + + return output.join(newLine()); +} diff --git a/apps/zpl-core/src/renderers/ellipse.ts b/apps/zpl-core/src/renderers/ellipse.ts new file mode 100644 index 0000000..d82c018 --- /dev/null +++ b/apps/zpl-core/src/renderers/ellipse.ts @@ -0,0 +1,49 @@ +import { fieldOrigin, graphicEllipse, newLine } from '../commands'; +import { COLOR } from '../constants'; +import { ObjectValues } from '../types'; + +const MIN_SIZE = 3; +const MAX_SIZE = 4095; +const MIN_THICKNESS = 1; +const MAX_THICKNESS = 4095; + +export interface EllipseCoreProps { + width: number; + height: number; + fieldOriginX?: number; + fieldOriginY?: number; + thickness?: number; + lineColor?: ObjectValues; +} + +export function renderEllipse(props: EllipseCoreProps): string { + const { + width, + height, + fieldOriginX = 0, + fieldOriginY = 0, + thickness = MIN_THICKNESS, + lineColor = COLOR.BLACK, + } = props; + + if ( + width < MIN_SIZE || width > MAX_SIZE || + height < MIN_SIZE || height > MAX_SIZE + ) { + throw new Error( + `renderEllipse: width와 height는 ${MIN_SIZE}~${MAX_SIZE} 사이여야 합니다. (width=${width}, height=${height})` + ); + } + if (thickness < MIN_THICKNESS || thickness > MAX_THICKNESS) { + throw new Error( + `renderEllipse: thickness는 ${MIN_THICKNESS}~${MAX_THICKNESS} 사이여야 합니다. (thickness=${thickness})` + ); + } + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push(graphicEllipse({ width, height, thickness, lineColor })); + + return output.join(newLine()); +} diff --git a/apps/zpl-core/src/renderers/index.ts b/apps/zpl-core/src/renderers/index.ts new file mode 100644 index 0000000..d0ac15d --- /dev/null +++ b/apps/zpl-core/src/renderers/index.ts @@ -0,0 +1,8 @@ +export * from './label'; +export * from './text'; +export * from './line'; +export * from './diagonalLine'; +export * from './circle'; +export * from './ellipse'; +export * from './qrCode'; +export * from './renderChildNode'; diff --git a/apps/zpl-core/src/renderers/label.ts b/apps/zpl-core/src/renderers/label.ts new file mode 100644 index 0000000..c8576e4 --- /dev/null +++ b/apps/zpl-core/src/renderers/label.ts @@ -0,0 +1,71 @@ +import { + changeDefaultFont, + changeInternationalEncoding, + endFormat, + fieldOrientation, + labelHome, + labelLength, + newLine, + printWidth, + startFormat, +} from '../commands'; +import { ORIENTATION, UTF8_ENCODING } from '../constants'; +import { LabelRootNode, ObjectValues, ZplElementContext } from '../types'; +import { renderChildren } from './renderChildNode'; + +export interface LabelCoreProps { + width: number; + height: number; + offsetX?: number; + offsetY?: number; + labelOrientation?: ObjectValues; + encoding?: string[]; + defaultFontName?: string; + defaultFontWidth?: number; + defaultFontHeight?: number; +} + +export function renderLabel(node: LabelRootNode): string { + const { + width, + height, + offsetX = 0, + offsetY = 0, + labelOrientation = ORIENTATION.NO_ROTATION, + encoding = [UTF8_ENCODING], + defaultFontName = 'J', + defaultFontWidth = 30, + defaultFontHeight = 30, + } = node.props; + + const context = createLabelContext(node.props); + + const output: string[] = []; + + output.push(startFormat()); + output.push(printWidth(width)); + output.push(labelLength(height)); + output.push(fieldOrientation({ orientation: labelOrientation })); + output.push(labelHome({ offsetX, offsetY })); + output.push(changeInternationalEncoding(encoding)); + output.push( + changeDefaultFont({ + fontName: defaultFontName, + width: defaultFontWidth, + height: defaultFontHeight, + }) + ); + output.push(...renderChildren(node.children, context)); + output.push(endFormat()); + + return output.join(newLine()); +} + +export function createLabelContext(props: LabelCoreProps): ZplElementContext { + return { + labelOrientation: props.labelOrientation ?? ORIENTATION.NO_ROTATION, + defaultFontName: props.defaultFontName ?? 'J', + defaultFontWidth: props.defaultFontWidth ?? 30, + defaultFontHeight: props.defaultFontHeight ?? 30, + }; +} diff --git a/apps/zpl-core/src/renderers/line.ts b/apps/zpl-core/src/renderers/line.ts new file mode 100644 index 0000000..bda5ebc --- /dev/null +++ b/apps/zpl-core/src/renderers/line.ts @@ -0,0 +1,49 @@ +import { fieldOrigin, graphicBox, newLine } from '../commands'; +import { COLOR } from '../constants'; +import { ObjectValues } from '../types'; + +const MIN_LENGTH = 1; +const MIN_THICKNESS = 1; + +export interface LineCoreProps { + direction: 'horizontal' | 'vertical'; + length: number; + fieldOriginX?: number; + fieldOriginY?: number; + thickness?: number; + lineColor?: ObjectValues; +} + +export function renderLine(props: LineCoreProps): string { + const { + direction, + length, + fieldOriginX = 0, + fieldOriginY = 0, + thickness = MIN_THICKNESS, + lineColor = COLOR.BLACK, + } = props; + + if (length < MIN_LENGTH) { + throw new Error( + `renderLine: length는 ${MIN_LENGTH} 이상이어야 합니다. (length=${length})` + ); + } + if (thickness < MIN_THICKNESS) { + throw new Error( + `renderLine: thickness는 ${MIN_THICKNESS} 이상이어야 합니다. (thickness=${thickness})` + ); + } + + const graphic = + direction === 'horizontal' + ? graphicBox({ width: length, height: thickness, borderThickness: thickness, lineColor }) + : graphicBox({ width: thickness, height: length, borderThickness: thickness, lineColor }); + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push(graphic); + + return output.join(newLine()); +} diff --git a/apps/zpl-core/src/renderers/qrCode.ts b/apps/zpl-core/src/renderers/qrCode.ts new file mode 100644 index 0000000..afbff2f --- /dev/null +++ b/apps/zpl-core/src/renderers/qrCode.ts @@ -0,0 +1,55 @@ +import { barcodeQR, fieldData, fieldOrigin, newLine, type QrErrorCorrection } from '../commands'; +import { ORIENTATION } from '../constants'; +import { ObjectValues, ZplElementContext } from '../types'; + +export interface QrCodeCoreProps { + text: string; + fieldOriginX?: number; + fieldOriginY?: number; + fieldOrientation?: ObjectValues; + model?: 1 | 2; + magnification?: number; + errorCorrectionLevel?: QrErrorCorrection; + maskValue?: number; +} + +export function renderQrCode( + props: QrCodeCoreProps, + context: ZplElementContext +): string { + const { + text, + fieldOriginX = 0, + fieldOriginY = 0, + fieldOrientation, + model = 2, + magnification = 5, + errorCorrectionLevel = 'Q', + maskValue = 7, + } = props; + + if (typeof text !== 'string') { + throw new Error('renderQrCode: text는 문자열이어야 합니다.'); + } + if (text.length === 0) { + throw new Error('renderQrCode: text는 빈 문자열일 수 없습니다.'); + } + + const orientation = fieldOrientation ?? context.labelOrientation; + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push( + barcodeQR({ + orientation, + model, + magnification, + errorCorrection: errorCorrectionLevel, + maskValue, + }) + ); + output.push(fieldData(`${errorCorrectionLevel}A,${text}`)); + + return output.join(newLine()); +} diff --git a/apps/zpl-core/src/renderers/renderChildNode.ts b/apps/zpl-core/src/renderers/renderChildNode.ts new file mode 100644 index 0000000..df2dc8b --- /dev/null +++ b/apps/zpl-core/src/renderers/renderChildNode.ts @@ -0,0 +1,34 @@ +import { ChildLabelNode, ZplElementContext } from '../types'; +import { renderCircle } from './circle'; +import { renderDiagonalLine } from './diagonalLine'; +import { renderEllipse } from './ellipse'; +import { renderLine } from './line'; +import { renderQrCode } from './qrCode'; +import { renderText } from './text'; + +export function renderChildren( + nodes: ChildLabelNode[], + context: ZplElementContext +): string[] { + return nodes.map((node) => renderChildNode(node, context)); +} + +export function renderChildNode( + node: ChildLabelNode, + context: ZplElementContext +): string { + switch (node.type) { + case 'text': + return renderText(node.props, context); + case 'line': + return renderLine(node.props); + case 'diagonalLine': + return renderDiagonalLine(node.props); + case 'circle': + return renderCircle(node.props); + case 'ellipse': + return renderEllipse(node.props); + case 'qrCode': + return renderQrCode(node.props, context); + } +} diff --git a/apps/zpl-core/src/renderers/text.ts b/apps/zpl-core/src/renderers/text.ts new file mode 100644 index 0000000..88f94a0 --- /dev/null +++ b/apps/zpl-core/src/renderers/text.ts @@ -0,0 +1,60 @@ +import { fieldData, fieldFont, fieldOrigin, newLine } from '../commands'; +import { ORIENTATION } from '../constants'; +import { ObjectValues, ZplElementContext } from '../types'; + +interface TextBaseCoreProps { + text: string; + fieldOriginX?: number; + fieldOriginY?: number; + fieldOrientation?: ObjectValues; +} + +interface TextFontInheritCoreProps extends TextBaseCoreProps { + fontInherit?: true; + fontName?: never; + fontWidth?: never; + fontHeight?: never; +} + +interface TextFontOwnCoreProps extends TextBaseCoreProps { + fontInherit: false; + fontName: string; + fontWidth: number; + fontHeight: number; +} + +export type TextCoreProps = TextFontInheritCoreProps | TextFontOwnCoreProps; + +export function renderText( + props: TextCoreProps, + context: ZplElementContext +): string { + const { + text, + fieldOriginX = 0, + fieldOriginY = 0, + fieldOrientation, + fontInherit, + } = props; + + if (typeof text !== 'string') { + throw new Error('renderText: text는 문자열이어야 합니다.'); + } + + const { defaultFontName, defaultFontWidth, defaultFontHeight, labelOrientation } = context; + + const { fontName, width, height } = + fontInherit === false + ? { fontName: props.fontName, width: props.fontWidth, height: props.fontHeight } + : { fontName: defaultFontName, width: defaultFontWidth, height: defaultFontHeight }; + + const _fieldOrientation = fieldOrientation ?? labelOrientation; + + const output: string[] = []; + + output.push(fieldOrigin({ offsetX: fieldOriginX, offsetY: fieldOriginY })); + output.push(fieldFont({ fontName, fieldOrientation: _fieldOrientation, width, height })); + output.push(fieldData(text)); + + return output.join(newLine()); +} diff --git a/apps/zpl-core/src/types/common.ts b/apps/zpl-core/src/types/common.ts new file mode 100644 index 0000000..63be509 --- /dev/null +++ b/apps/zpl-core/src/types/common.ts @@ -0,0 +1 @@ +export type ObjectValues> = T[keyof T]; diff --git a/apps/zpl-core/src/types/context.ts b/apps/zpl-core/src/types/context.ts new file mode 100644 index 0000000..c077a7d --- /dev/null +++ b/apps/zpl-core/src/types/context.ts @@ -0,0 +1,14 @@ +import { ObjectValues } from './common'; +import { ORIENTATION } from '../constants'; + +/** + * ZPL 명령어 생성에 필요한 컨텍스트 정보 + * + * 라벨 전역 설정(폰트, 방향 등)을 담으며, renderer 함수들 간에 공유된다. + */ +export interface ZplElementContext { + labelOrientation: ObjectValues; + defaultFontName: string; + defaultFontWidth: number; + defaultFontHeight: number; +} diff --git a/apps/zpl-core/src/types/index.ts b/apps/zpl-core/src/types/index.ts new file mode 100644 index 0000000..83c3d50 --- /dev/null +++ b/apps/zpl-core/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './common'; +export * from './context'; +export * from './node'; diff --git a/apps/zpl-core/src/types/node.ts b/apps/zpl-core/src/types/node.ts new file mode 100644 index 0000000..f9f4312 --- /dev/null +++ b/apps/zpl-core/src/types/node.ts @@ -0,0 +1,25 @@ +import { + CircleCoreProps, + DiagonalLineCoreProps, + EllipseCoreProps, + LabelCoreProps, + LineCoreProps, + QrCodeCoreProps, + TextCoreProps, +} from '../renderers'; + +export type ChildLabelNode = + | { type: 'text'; props: TextCoreProps } + | { type: 'line'; props: LineCoreProps } + | { type: 'diagonalLine'; props: DiagonalLineCoreProps } + | { type: 'circle'; props: CircleCoreProps } + | { type: 'ellipse'; props: EllipseCoreProps } + | { type: 'qrCode'; props: QrCodeCoreProps }; + +export interface LabelRootNode { + type: 'label'; + props: LabelCoreProps; + children: ChildLabelNode[]; +} + +export type LabelNode = LabelRootNode | ChildLabelNode; diff --git a/apps/zpl-core/tsconfig.json b/apps/zpl-core/tsconfig.json new file mode 100644 index 0000000..6739842 --- /dev/null +++ b/apps/zpl-core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docs/docs/guide/_meta.json b/docs/docs/guide/_meta.json index 88181a3..bc8a5ef 100644 --- a/docs/docs/guide/_meta.json +++ b/docs/docs/guide/_meta.json @@ -1 +1,25 @@ -["introduction", "getting-started"] +[ + { + "type": "section-header", + "label": "Getting Started" + }, + "introduction", + "quick-start", + { + "type": "divider" + }, + { + "type": "section-header", + "label": "@zpl-kit/zpl-core" + }, + "zpl-core-installation", + "zpl-core-getting-started", + "zpl-core-react-adapter", + { + "type": "dir", + "name": "zpl-core-api", + "label": "API", + "collapsible": true, + "collapsed": true + } +] diff --git a/docs/docs/guide/getting-started.mdx b/docs/docs/guide/quick-start.mdx similarity index 98% rename from docs/docs/guide/getting-started.mdx rename to docs/docs/guide/quick-start.mdx index 8f2e43e..8b6d5c2 100644 --- a/docs/docs/guide/getting-started.mdx +++ b/docs/docs/guide/quick-start.mdx @@ -1,4 +1,4 @@ -# Getting Started +# Quick Start ZPL Kit를 사용하여 React 애플리케이션에서 ZPL 라벨을 생성할 수 있습니다. diff --git a/docs/docs/guide/zpl-core-api/_meta.json b/docs/docs/guide/zpl-core-api/_meta.json new file mode 100644 index 0000000..3f53664 --- /dev/null +++ b/docs/docs/guide/zpl-core-api/_meta.json @@ -0,0 +1,10 @@ +[ + "render-label", + "render-text", + "render-line", + "render-diagonal-line", + "render-circle", + "render-ellipse", + "render-qr-code", + "render-children" +] diff --git a/docs/docs/guide/zpl-core-api/render-children.mdx b/docs/docs/guide/zpl-core-api/render-children.mdx new file mode 100644 index 0000000..e8e4992 --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-children.mdx @@ -0,0 +1,49 @@ +# renderChildren / renderChildNode + +`ChildLabelNode` 배열을 ZPL 문자열로 변환하는 유틸리티 함수입니다. 커스텀 렌더링 파이프라인 구성에 사용합니다. + +--- + +## renderChildren + +여러 `ChildLabelNode`를 한 번에 렌더링합니다. + +```ts +function renderChildren(nodes: ChildLabelNode[], context: ZplElementContext): string[] +``` + +## 사용 예시 + +```ts +import { renderChildren, createLabelContext } from '@zpl-kit/zpl-core'; + +const context = createLabelContext({ width: 400, height: 200 }); + +const results = renderChildren( + [ + { type: 'text', props: { text: 'Item 1', fieldOriginX: 10, fieldOriginY: 10 } }, + { type: 'line', props: { direction: 'horizontal', length: 200, fieldOriginX: 10, fieldOriginY: 40 } }, + ], + context +); +// results: string[] +``` + +--- + +## renderChildNode + +단일 `ChildLabelNode`를 ZPL 문자열로 변환합니다. `node.type`에 따라 적절한 renderer로 dispatch됩니다. + +```ts +function renderChildNode(node: ChildLabelNode, context: ZplElementContext): string +``` + +| node.type | 호출되는 renderer | +|-----------|-------------------| +| `'text'` | `renderText` | +| `'line'` | `renderLine` | +| `'diagonalLine'` | `renderDiagonalLine` | +| `'circle'` | `renderCircle` | +| `'ellipse'` | `renderEllipse` | +| `'qrCode'` | `renderQrCode` | diff --git a/docs/docs/guide/zpl-core-api/render-circle.mdx b/docs/docs/guide/zpl-core-api/render-circle.mdx new file mode 100644 index 0000000..8ee82a8 --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-circle.mdx @@ -0,0 +1,30 @@ +# renderCircle + +원을 그립니다. `^GC` 명령어를 사용합니다. + +```ts +function renderCircle(props: CircleCoreProps): string +``` + +## 사용 예시 + +```ts +import { renderCircle } from '@zpl-kit/zpl-core'; + +const zpl = renderCircle({ + diameter: 80, + fieldOriginX: 100, + fieldOriginY: 50, + thickness: 2, +}); +``` + +## Props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `diameter` | `number` | 필수 | 지름 (3~4095) | +| `fieldOriginX` | `number` | `0` | X 좌표 | +| `fieldOriginY` | `number` | `0` | Y 좌표 | +| `thickness` | `number` | `1` | 두께 (1~4095) | +| `lineColor` | `'B' \| 'W'` | `'B'` | 색상 | diff --git a/docs/docs/guide/zpl-core-api/render-diagonal-line.mdx b/docs/docs/guide/zpl-core-api/render-diagonal-line.mdx new file mode 100644 index 0000000..a4b26ed --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-diagonal-line.mdx @@ -0,0 +1,43 @@ +# renderDiagonalLine + +대각선을 그립니다. `^GD` 명령어를 사용합니다. + +```ts +function renderDiagonalLine(props: DiagonalLineCoreProps): string +``` + +## 사용 예시 + +```ts +import { renderDiagonalLine } from '@zpl-kit/zpl-core'; + +// 우하향 대각선 (기본) +const zpl = renderDiagonalLine({ + width: 100, + height: 100, + fieldOriginX: 10, + fieldOriginY: 10, +}); + +// 우상향 대각선 +const zpl2 = renderDiagonalLine({ + width: 100, + height: 100, + orientation: 'L', + thickness: 2, + fieldOriginX: 120, + fieldOriginY: 10, +}); +``` + +## Props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `width` | `number` | 필수 | 너비 (최소 3) | +| `height` | `number` | 필수 | 높이 (최소 3) | +| `orientation` | `'R' \| 'L'` | `'R'` | `R`: 우하향, `L`: 우상향 | +| `fieldOriginX` | `number` | `0` | X 좌표 | +| `fieldOriginY` | `number` | `0` | Y 좌표 | +| `thickness` | `number` | `1` | 두께 | +| `lineColor` | `'B' \| 'W'` | `'B'` | 색상 | diff --git a/docs/docs/guide/zpl-core-api/render-ellipse.mdx b/docs/docs/guide/zpl-core-api/render-ellipse.mdx new file mode 100644 index 0000000..6ed84a3 --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-ellipse.mdx @@ -0,0 +1,32 @@ +# renderEllipse + +타원을 그립니다. `^GE` 명령어를 사용합니다. + +```ts +function renderEllipse(props: EllipseCoreProps): string +``` + +## 사용 예시 + +```ts +import { renderEllipse } from '@zpl-kit/zpl-core'; + +const zpl = renderEllipse({ + width: 150, + height: 80, + fieldOriginX: 50, + fieldOriginY: 50, + thickness: 2, +}); +``` + +## Props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `width` | `number` | 필수 | 너비 (3~4095) | +| `height` | `number` | 필수 | 높이 (3~4095) | +| `fieldOriginX` | `number` | `0` | X 좌표 | +| `fieldOriginY` | `number` | `0` | Y 좌표 | +| `thickness` | `number` | `1` | 두께 (1~4095) | +| `lineColor` | `'B' \| 'W'` | `'B'` | 색상 | diff --git a/docs/docs/guide/zpl-core-api/render-label.mdx b/docs/docs/guide/zpl-core-api/render-label.mdx new file mode 100644 index 0000000..2114c61 --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-label.mdx @@ -0,0 +1,47 @@ +# renderLabel + +`LabelRootNode`를 받아 완성된 ZPL 문자열을 반환합니다. + +```ts +function renderLabel(node: LabelRootNode): string +``` + +## 사용 예시 + +```ts +import { renderLabel } from '@zpl-kit/zpl-core'; + +const zpl = renderLabel({ + type: 'label', + props: { width: 400, height: 200 }, + children: [ + { type: 'text', props: { text: 'Hello', fieldOriginX: 10, fieldOriginY: 10 } }, + ], +}); +``` + +## LabelRootNode props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `width` | `number` | 필수 | 라벨 너비 (dots) | +| `height` | `number` | 필수 | 라벨 높이 (dots) | +| `offsetX` | `number` | `0` | X 오프셋 | +| `offsetY` | `number` | `0` | Y 오프셋 | +| `labelOrientation` | `'N' \| 'R' \| 'I' \| 'B'` | `'N'` | 라벨 방향 | +| `encoding` | `string[]` | `['28']` | 문자 인코딩 (UTF-8) | +| `defaultFontName` | `string` | `'J'` | 기본 폰트 | +| `defaultFontWidth` | `number` | `30` | 기본 폰트 너비 | +| `defaultFontHeight` | `number` | `30` | 기본 폰트 높이 | + +--- + +# createLabelContext + +`LabelCoreProps`로부터 `ZplElementContext`를 생성합니다. `renderText`, `renderQrCode`처럼 context가 필요한 renderer를 단독으로 사용할 때 전달합니다. + +```ts +function createLabelContext(props: LabelCoreProps): ZplElementContext +``` + +`renderLabel` 내부에서 자동으로 호출되므로, 개별 renderer를 단독 사용하는 경우에만 필요합니다. diff --git a/docs/docs/guide/zpl-core-api/render-line.mdx b/docs/docs/guide/zpl-core-api/render-line.mdx new file mode 100644 index 0000000..6971b6d --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-line.mdx @@ -0,0 +1,41 @@ +# renderLine + +수평 또는 수직 직선을 그립니다. 내부적으로 `^GB` (graphicBox) 명령어를 사용합니다. + +```ts +function renderLine(props: LineCoreProps): string +``` + +## 사용 예시 + +```ts +import { renderLine } from '@zpl-kit/zpl-core'; + +// 수평선 +const h = renderLine({ + direction: 'horizontal', + length: 300, + fieldOriginX: 10, + fieldOriginY: 50, +}); + +// 수직선 +const v = renderLine({ + direction: 'vertical', + length: 100, + thickness: 3, + fieldOriginX: 200, + fieldOriginY: 10, +}); +``` + +## Props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `direction` | `'horizontal' \| 'vertical'` | 필수 | 방향 | +| `length` | `number` | 필수 | 길이 (dots, 최소 1) | +| `fieldOriginX` | `number` | `0` | X 좌표 | +| `fieldOriginY` | `number` | `0` | Y 좌표 | +| `thickness` | `number` | `1` | 두께 | +| `lineColor` | `'B' \| 'W'` | `'B'` | 색상 | diff --git a/docs/docs/guide/zpl-core-api/render-qr-code.mdx b/docs/docs/guide/zpl-core-api/render-qr-code.mdx new file mode 100644 index 0000000..f7a1f7a --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-qr-code.mdx @@ -0,0 +1,38 @@ +# renderQrCode + +QR 코드를 생성합니다. `^BQ` 명령어를 사용합니다. + +```ts +function renderQrCode(props: QrCodeCoreProps, context: ZplElementContext): string +``` + +## 사용 예시 + +```ts +import { renderQrCode, createLabelContext } from '@zpl-kit/zpl-core'; + +const context = createLabelContext({ width: 400, height: 200 }); + +const zpl = renderQrCode( + { + text: 'https://example.com', + fieldOriginX: 10, + fieldOriginY: 10, + magnification: 5, + }, + context +); +``` + +## Props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `text` | `string` | 필수 | QR 코드에 담을 데이터 | +| `fieldOriginX` | `number` | `0` | X 좌표 | +| `fieldOriginY` | `number` | `0` | Y 좌표 | +| `fieldOrientation` | `'N' \| 'R' \| 'I' \| 'B'` | context 상속 | 방향 | +| `model` | `1 \| 2` | `2` | QR 모델 | +| `magnification` | `number` | `5` | 확대 배율 (1~100) | +| `errorCorrectionLevel` | `'H' \| 'Q' \| 'M' \| 'L'` | `'Q'` | 오류 복원 수준 | +| `maskValue` | `number` | `7` | 마스크 값 (0~7) | diff --git a/docs/docs/guide/zpl-core-api/render-text.mdx b/docs/docs/guide/zpl-core-api/render-text.mdx new file mode 100644 index 0000000..3cfb0c9 --- /dev/null +++ b/docs/docs/guide/zpl-core-api/render-text.mdx @@ -0,0 +1,48 @@ +# renderText + +텍스트를 ZPL 필드 명령어로 변환합니다. 라벨의 기본 폰트를 상속하거나 직접 지정할 수 있습니다. + +```ts +function renderText(props: TextCoreProps, context: ZplElementContext): string +``` + +## 사용 예시 + +```ts +import { renderText, createLabelContext } from '@zpl-kit/zpl-core'; + +const context = createLabelContext({ width: 400, height: 200 }); + +// 기본 폰트 상속 +const zpl1 = renderText( + { text: '기본 폰트', fieldOriginX: 10, fieldOriginY: 10 }, + context +); + +// 폰트 직접 지정 +const zpl2 = renderText( + { + text: '커스텀 폰트', + fontInherit: false, + fontName: 'B', + fontWidth: 30, + fontHeight: 30, + fieldOriginX: 10, + fieldOriginY: 50, + }, + context +); +``` + +## Props + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `text` | `string` | 필수 | 출력할 텍스트 | +| `fieldOriginX` | `number` | `0` | X 좌표 | +| `fieldOriginY` | `number` | `0` | Y 좌표 | +| `fieldOrientation` | `'N' \| 'R' \| 'I' \| 'B'` | context 상속 | 필드 방향 | +| `fontInherit` | `boolean` | `true` | context 폰트 상속 여부 | +| `fontName` | `string` | — | 폰트명 (`fontInherit: false` 시 필수) | +| `fontWidth` | `number` | — | 폰트 너비 (`fontInherit: false` 시 필수) | +| `fontHeight` | `number` | — | 폰트 높이 (`fontInherit: false` 시 필수) | diff --git a/docs/docs/guide/zpl-core-getting-started.mdx b/docs/docs/guide/zpl-core-getting-started.mdx new file mode 100644 index 0000000..b44b684 --- /dev/null +++ b/docs/docs/guide/zpl-core-getting-started.mdx @@ -0,0 +1,61 @@ +# 시작하기 + +`LabelRootNode` 트리를 구성하고 `renderLabel`에 넘기면 ZPL 문자열이 반환됩니다. + +## 기본 사용법 + +```ts +import { renderLabel } from '@zpl-kit/zpl-core'; + +const zpl = renderLabel({ + type: 'label', + props: { width: 400, height: 200 }, + children: [ + { + type: 'text', + props: { text: 'Hello, ZPL!', fieldOriginX: 10, fieldOriginY: 10 }, + }, + { + type: 'line', + props: { direction: 'horizontal', length: 300, fieldOriginX: 10, fieldOriginY: 50 }, + }, + ], +}); + +console.log(zpl); +// ^XA^PW400^LL200...^XZ +``` + +## context가 필요한 경우 + +`renderText`, `renderQrCode`처럼 라벨의 기본 폰트나 방향을 상속받는 경우, `createLabelContext`로 context를 생성해 전달할 수 있습니다. `renderLabel` 내부에서 자동으로 처리되므로 개별 renderer를 단독으로 사용할 때만 필요합니다. + +```ts +import { renderText, createLabelContext } from '@zpl-kit/zpl-core'; + +const context = createLabelContext({ + width: 400, + height: 200, + defaultFontName: 'A', + defaultFontWidth: 20, + defaultFontHeight: 20, +}); + +const zpl = renderText( + { text: '기본 폰트 상속', fieldOriginX: 10, fieldOriginY: 10 }, + context +); +``` + +## 지원하는 요소 + +| type | 설명 | renderer | +|------|------|----------| +| `text` | 텍스트 | `renderText` | +| `line` | 수평/수직 직선 | `renderLine` | +| `diagonalLine` | 대각선 | `renderDiagonalLine` | +| `circle` | 원 | `renderCircle` | +| `ellipse` | 타원 | `renderEllipse` | +| `qrCode` | QR 코드 | `renderQrCode` | + +전체 props는 [API Reference](/guide/zpl-core-api/render-label)를 참고하세요. diff --git a/docs/docs/guide/zpl-core-installation.mdx b/docs/docs/guide/zpl-core-installation.mdx new file mode 100644 index 0000000..749db26 --- /dev/null +++ b/docs/docs/guide/zpl-core-installation.mdx @@ -0,0 +1,19 @@ +# 설치하기 + +`@zpl-kit/zpl-core`는 React 없이 Node.js 환경에서 ZPL을 생성할 수 있는 순수 함수 라이브러리입니다. + +## 언제 사용하나요? + +- 서버에서 라벨을 생성해 프린터로 직접 전송하는 경우 +- CLI 도구나 스크립트에서 ZPL을 생성하는 경우 +- React 없이 순수 TypeScript/JavaScript 환경에서 사용하는 경우 + +## 설치 + +```bash +pnpm add @zpl-kit/zpl-core +``` + +## react-zpl과 함께 사용하는 경우 + +`@zpl-kit/react-zpl`을 이미 사용 중이라면 `zpl-core`는 자동으로 포함됩니다. 별도 설치가 필요하지 않습니다. diff --git a/docs/docs/guide/zpl-core-react-adapter.mdx b/docs/docs/guide/zpl-core-react-adapter.mdx new file mode 100644 index 0000000..c315e61 --- /dev/null +++ b/docs/docs/guide/zpl-core-react-adapter.mdx @@ -0,0 +1,60 @@ +# @zpl-kit/react-zpl과 함께 사용하기 + +`@zpl-kit/react-zpl`은 내부적으로 `@zpl-kit/zpl-core`를 사용합니다. JSX 트리를 `LabelRootNode`로 변환한 뒤 `renderLabel`을 호출하는 방식입니다. + +## 파이프라인 + +``` +React 환경 Node.js 환경 + + + LabelRootNode + Hello → ├── type: 'label' + └── children + └── { type: 'line', props: { direction: 'horizontal', ... } } + + ↓ + renderLabel() + ↓ + ^XA...^XZ +``` + +`react-zpl`의 `ZplLabel.print()`는 두 단계로 동작합니다. + +```ts +ZplLabel.print = (element) => { + const node = toLabelNode(element); // 1. ReactElement → LabelRootNode + return renderLabel(node); // 2. LabelRootNode → ZPL (zpl-core) +}; +``` + +## 어댑터 역할 + +`@zpl-kit/react-zpl`은 React 어댑터입니다. ZPL 생성 로직은 모두 `zpl-core`에 있으며, `react-zpl`은 JSX 트리를 `LabelRootNode`로 변환하는 역할만 합니다. + +| 패키지 | 역할 | +| -------------------- | ---------------------------------------------- | +| `@zpl-kit/zpl-core` | ZPL 생성 엔진 (순수 함수, React 없음) | +| `@zpl-kit/react-zpl` | React 어댑터 (JSX → LabelNode → zpl-core 호출) | + +## LabelNode 직접 구성하기 + +Node.js 환경에서는 JSX 변환 없이 `LabelRootNode`를 직접 만들어 `renderLabel`을 호출하면 됩니다. + +```ts +import { renderLabel } from '@zpl-kit/zpl-core'; + +const node = { + type: 'label' as const, + props: { width: 400, height: 200 }, + children: [ + { + type: 'text' as const, + props: { text: 'Server Label', fieldOriginX: 10, fieldOriginY: 10 }, + }, + ], +}; + +const zpl = renderLabel(node); +``` diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 5ac2fdb..cffd500 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -53,4 +53,4 @@ function App() { ## 문서 -더 자세한 내용은 [시작하기 가이드](/guide/getting-started)를 참고하세요. +더 자세한 내용은 [시작하기 가이드](/guide/quick-start)를 참고하세요. diff --git a/docs/docs/zpl-core/_meta.json b/docs/docs/zpl-core/_meta.json new file mode 100644 index 0000000..be2df14 --- /dev/null +++ b/docs/docs/zpl-core/_meta.json @@ -0,0 +1 @@ +["overview"] diff --git a/docs/docs/zpl-core/overview.mdx b/docs/docs/zpl-core/overview.mdx new file mode 100644 index 0000000..9b002ae --- /dev/null +++ b/docs/docs/zpl-core/overview.mdx @@ -0,0 +1,72 @@ +# zpl-core + +`@zpl-kit/zpl-core`는 React 없이 Node.js 환경에서 ZPL을 생성할 수 있는 순수 함수 라이브러리입니다. + +## 왜 zpl-core인가? + +`@zpl-kit/react-zpl`은 React 컴포넌트 트리로 ZPL을 선언적으로 작성할 수 있게 해주지만, React에 의존하기 때문에 서버사이드나 CLI 환경에서는 사용이 어렵습니다. + +`@zpl-kit/zpl-core`는 이 문제를 해결합니다. + +| | `@zpl-kit/react-zpl` | `@zpl-kit/zpl-core` | +| ----------------- | -------------------- | ------------------- | +| React 필요 | ✓ | ✗ | +| Node.js 단독 사용 | ✗ | ✓ | +| 선언적 JSX 문법 | ✓ | ✗ | +| 순수 함수 API | ✗ | ✓ | + +## 설치 + +```bash +pnpm add @zpl-kit/zpl-core +``` + +## 기본 사용법 + +`LabelRootNode` 트리를 구성한 뒤 `renderLabel`에 넘기면 ZPL 문자열이 반환됩니다. + +```ts +import { renderLabel, createLabelContext } from '@zpl-kit/zpl-core'; + +const node = { + type: 'label', + props: { width: 400, height: 200 }, + children: [ + { + type: 'text', + props: { text: 'Hello, ZPL!', fieldOriginX: 10, fieldOriginY: 10 }, + }, + { + type: 'line', + props: { + direction: 'horizontal', + length: 300, + fieldOriginX: 10, + fieldOriginY: 50, + }, + }, + ], +}; + +const zpl = renderLabel(node); +console.log(zpl); +// ^XA^PW400^LL200...^XZ +``` + +## 아키텍처 + +`@zpl-kit/react-zpl`은 내부적으로 `@zpl-kit/zpl-core`를 사용합니다. + +``` +React 환경 Node.js 환경 + + LabelRootNode + → → → ├── TextNode + toLabelNode └── LineNode + ↓ + renderLabel() + ↓ + ZPL 문자열 +``` + +`react-zpl`의 `ZplLabel.print()`는 JSX 트리를 `LabelRootNode`로 변환한 뒤 `renderLabel`을 호출합니다. Node.js 환경에서는 이 변환 없이 `LabelRootNode`를 직접 구성할 수 있습니다. diff --git a/package.json b/package.json index 1d71a6c..c92558d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "pnpm --filter './apps/*' dev", "demo:web": "pnpm --filter './demos/web' dev", "demo:electron": "pnpm --filter './demos/electron' dev", - "build": "pnpm --filter './apps/*' --filter './demos/*' build", + "build": "pnpm --filter @zpl-kit/zpl-core build && pnpm --filter './apps/*' --filter '!@zpl-kit/zpl-core' build && pnpm --filter './demos/*' build", "test": "vitest", "lint": "oxlint", "format": "prettier --write .", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a07d7d..1f213ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: apps/react-zpl: dependencies: + '@zpl-kit/zpl-core': + specifier: workspace:* + version: link:../zpl-core react: specifier: ^18.3.0 version: 18.3.1 @@ -46,6 +49,15 @@ importers: specifier: ^5.7.0 version: 5.9.3 + apps/zpl-core: + devDependencies: + rolldown: + specifier: ^1.0.0-beta.51 + version: 1.0.0-beta.51 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + apps/zpl-viewer: dependencies: '@tanstack/react-query': @@ -211,6 +223,9 @@ importers: '@zpl-kit/react-zpl': specifier: workspace:* version: link:../apps/react-zpl + '@zpl-kit/zpl-core': + specifier: workspace:* + version: link:../apps/zpl-core react: specifier: ^18.3.0 version: 18.3.1 diff --git a/tests/package.json b/tests/package.json index ce448c4..bd5e89a 100644 --- a/tests/package.json +++ b/tests/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@zpl-kit/react-zpl": "workspace:*", + "@zpl-kit/zpl-core": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/tests/unit/Circle.test.tsx b/tests/unit/Circle.test.tsx index 71a0667..260f77a 100644 --- a/tests/unit/Circle.test.tsx +++ b/tests/unit/Circle.test.tsx @@ -1,64 +1,59 @@ -import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; - +import { renderCircle } from '@zpl-kit/zpl-core'; import { Circle } from '@zpl-kit/react-zpl'; -import { defaultLabelContext } from './fixtures/context'; - describe('Circle', () => { - describe('print', () => { + describe('renderCircle', () => { it('기본값으로 ^FO0,0\\&^GC{diameter},1,B^FS 생성', () => { - const el = createElement(Circle, { diameter: 50 }); - expect(Circle.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GC50,1,B^FS'); + expect(renderCircle({ diameter: 50 })).toBe('^FO0,0\\&^GC50,1,B^FS'); }); it('fieldOrigin, thickness, lineColor 지정 시 올바른 ZPL 생성', () => { - const el = createElement(Circle, { - diameter: 100, - fieldOriginX: 10, - fieldOriginY: 20, - thickness: 5, - lineColor: 'B', - }); - expect(Circle.print(el, defaultLabelContext)).toBe('^FO10,20\\&^GC100,5,B^FS'); + expect( + renderCircle({ + diameter: 100, + fieldOriginX: 10, + fieldOriginY: 20, + thickness: 5, + lineColor: 'B', + }) + ).toBe('^FO10,20\\&^GC100,5,B^FS'); }); it('흰색 원(lineColor=W) 생성', () => { - const el = createElement(Circle, { diameter: 60, lineColor: 'W' }); - expect(Circle.print(el, defaultLabelContext)).toContain('^GC60,1,W^FS'); + expect(renderCircle({ diameter: 60, lineColor: 'W' })).toContain( + '^GC60,1,W^FS' + ); }); it('채워진 원 (thickness >= diameter) 허용', () => { - const el = createElement(Circle, { diameter: 50, thickness: 50 }); - expect(Circle.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GC50,50,B^FS'); + expect(renderCircle({ diameter: 50, thickness: 50 })).toBe( + '^FO0,0\\&^GC50,50,B^FS' + ); }); }); - describe('print - 검증', () => { + describe('renderCircle - 검증', () => { it('diameter < 3 시 에러 throw', () => { - const el = createElement(Circle, { diameter: 2 }); - expect(() => Circle.print(el, defaultLabelContext)).toThrow( + expect(() => renderCircle({ diameter: 2 })).toThrow( 'diameter는 3~4095 사이여야 합니다' ); }); it('diameter > 4095 시 에러 throw', () => { - const el = createElement(Circle, { diameter: 4096 }); - expect(() => Circle.print(el, defaultLabelContext)).toThrow( + expect(() => renderCircle({ diameter: 4096 })).toThrow( 'diameter는 3~4095 사이여야 합니다' ); }); it('thickness < 1 시 에러 throw', () => { - const el = createElement(Circle, { diameter: 50, thickness: 0 }); - expect(() => Circle.print(el, defaultLabelContext)).toThrow( + expect(() => renderCircle({ diameter: 50, thickness: 0 })).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); it('thickness > 4095 시 에러 throw', () => { - const el = createElement(Circle, { diameter: 50, thickness: 4096 }); - expect(() => Circle.print(el, defaultLabelContext)).toThrow( + expect(() => renderCircle({ diameter: 50, thickness: 4096 })).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); diff --git a/tests/unit/DiagonalLine.test.tsx b/tests/unit/DiagonalLine.test.tsx index bbfb44a..8d0c9a7 100644 --- a/tests/unit/DiagonalLine.test.tsx +++ b/tests/unit/DiagonalLine.test.tsx @@ -1,79 +1,58 @@ -import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; - +import { renderDiagonalLine } from '@zpl-kit/zpl-core'; import { DiagonalLine } from '@zpl-kit/react-zpl'; -import { defaultLabelContext } from './fixtures/context'; - describe('DiagonalLine', () => { - describe('print - 대각선', () => { + describe('renderDiagonalLine - 대각선', () => { it('기본값으로 ^FO{x},{y}^GD{w},{h},{t},{c},{o}^FS 생성', () => { - const el = createElement(DiagonalLine, { - width: 30, - height: 30, - }); - expect(DiagonalLine.print(el, defaultLabelContext)).toBe( + expect(renderDiagonalLine({ width: 30, height: 30 })).toBe( '^FO0,0\\&^GD30,30,1,B,R^FS' ); }); it('fieldOrigin, thickness, orientation 지정 시 올바른 ZPL 생성', () => { - const el = createElement(DiagonalLine, { - width: 50, - height: 40, - orientation: 'L', - fieldOriginX: 10, - fieldOriginY: 20, - thickness: 2, - }); - expect(DiagonalLine.print(el, defaultLabelContext)).toBe( - '^FO10,20\\&^GD50,40,2,B,L^FS' - ); + expect( + renderDiagonalLine({ + width: 50, + height: 40, + orientation: 'L', + fieldOriginX: 10, + fieldOriginY: 20, + thickness: 2, + }) + ).toBe('^FO10,20\\&^GD50,40,2,B,L^FS'); }); it('orientation R (우하향) 생성', () => { - const el = createElement(DiagonalLine, { - width: 20, - height: 20, - orientation: 'R', - }); - expect(DiagonalLine.print(el, defaultLabelContext)).toContain('^GD20,20,1,B,R^FS'); + expect( + renderDiagonalLine({ width: 20, height: 20, orientation: 'R' }) + ).toContain('^GD20,20,1,B,R^FS'); }); it('orientation L (우상향) 생성', () => { - const el = createElement(DiagonalLine, { - width: 20, - height: 20, - orientation: 'L', - }); - expect(DiagonalLine.print(el, defaultLabelContext)).toContain('^GD20,20,1,B,L^FS'); + expect( + renderDiagonalLine({ width: 20, height: 20, orientation: 'L' }) + ).toContain('^GD20,20,1,B,L^FS'); }); }); - describe('print - 검증', () => { + describe('renderDiagonalLine - 검증', () => { it('width < 3 시 에러 throw', () => { - const el = createElement(DiagonalLine, { width: 2, height: 30 }); - expect(() => DiagonalLine.print(el, defaultLabelContext)).toThrow( + expect(() => renderDiagonalLine({ width: 2, height: 30 })).toThrow( 'width와 height는 3 이상이어야 합니다' ); }); it('height < 3 시 에러 throw', () => { - const el = createElement(DiagonalLine, { width: 30, height: 2 }); - expect(() => DiagonalLine.print(el, defaultLabelContext)).toThrow( + expect(() => renderDiagonalLine({ width: 30, height: 2 })).toThrow( 'width와 height는 3 이상이어야 합니다' ); }); it('thickness < 1 시 에러 throw', () => { - const el = createElement(DiagonalLine, { - width: 30, - height: 30, - thickness: 0, - }); - expect(() => DiagonalLine.print(el, defaultLabelContext)).toThrow( - 'thickness는 1 이상이어야 합니다' - ); + expect(() => + renderDiagonalLine({ width: 30, height: 30, thickness: 0 }) + ).toThrow('thickness는 1 이상이어야 합니다'); }); }); diff --git a/tests/unit/Ellipse.test.tsx b/tests/unit/Ellipse.test.tsx index 806a93e..ed38731 100644 --- a/tests/unit/Ellipse.test.tsx +++ b/tests/unit/Ellipse.test.tsx @@ -1,69 +1,64 @@ -import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; - +import { renderEllipse } from '@zpl-kit/zpl-core'; import { Ellipse } from '@zpl-kit/react-zpl'; -import { defaultLabelContext } from './fixtures/context'; - describe('Ellipse', () => { - describe('print', () => { + describe('renderEllipse', () => { it('기본값으로 ^FO0,0\\&^GE{width},{height},1,B^FS 생성', () => { - const el = createElement(Ellipse, { width: 80, height: 40 }); - expect(Ellipse.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GE80,40,1,B^FS'); + expect(renderEllipse({ width: 80, height: 40 })).toBe( + '^FO0,0\\&^GE80,40,1,B^FS' + ); }); it('fieldOrigin, thickness, lineColor 지정 시 올바른 ZPL 생성', () => { - const el = createElement(Ellipse, { - width: 100, - height: 60, - fieldOriginX: 10, - fieldOriginY: 20, - thickness: 3, - lineColor: 'B', - }); - expect(Ellipse.print(el, defaultLabelContext)).toBe('^FO10,20\\&^GE100,60,3,B^FS'); + expect( + renderEllipse({ + width: 100, + height: 60, + fieldOriginX: 10, + fieldOriginY: 20, + thickness: 3, + lineColor: 'B', + }) + ).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, defaultLabelContext)).toContain('^GE80,40,1,W^FS'); + expect(renderEllipse({ width: 80, height: 40, lineColor: 'W' })).toContain( + '^GE80,40,1,W^FS' + ); }); }); - describe('print - 검증', () => { + describe('renderEllipse - 검증', () => { it('width < 3 시 에러 throw', () => { - const el = createElement(Ellipse, { width: 2, height: 40 }); - expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( + expect(() => renderEllipse({ width: 2, height: 40 })).toThrow( 'width와 height는 3~4095 사이여야 합니다' ); }); it('height < 3 시 에러 throw', () => { - const el = createElement(Ellipse, { width: 80, height: 2 }); - expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( + expect(() => renderEllipse({ width: 80, height: 2 })).toThrow( 'width와 height는 3~4095 사이여야 합니다' ); }); it('width > 4095 시 에러 throw', () => { - const el = createElement(Ellipse, { width: 4096, height: 40 }); - expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( + expect(() => renderEllipse({ width: 4096, height: 40 })).toThrow( 'width와 height는 3~4095 사이여야 합니다' ); }); it('thickness < 1 시 에러 throw', () => { - const el = createElement(Ellipse, { width: 80, height: 40, thickness: 0 }); - expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( + expect(() => renderEllipse({ width: 80, height: 40, thickness: 0 })).toThrow( 'thickness는 1~4095 사이여야 합니다' ); }); it('thickness > 4095 시 에러 throw', () => { - const el = createElement(Ellipse, { width: 80, height: 40, thickness: 4096 }); - expect(() => Ellipse.print(el, defaultLabelContext)).toThrow( - 'thickness는 1~4095 사이여야 합니다' - ); + expect(() => + renderEllipse({ width: 80, height: 40, thickness: 4096 }) + ).toThrow('thickness는 1~4095 사이여야 합니다'); }); }); diff --git a/tests/unit/Line.test.tsx b/tests/unit/Line.test.tsx index 70f04f1..5f3e0a7 100644 --- a/tests/unit/Line.test.tsx +++ b/tests/unit/Line.test.tsx @@ -1,89 +1,72 @@ -import { createElement } from 'react'; import { describe, it, expect } from 'vitest'; - +import { renderLine } from '@zpl-kit/zpl-core'; import { Line } from '@zpl-kit/react-zpl'; -import { defaultLabelContext } from './fixtures/context'; - describe('Line', () => { - describe('print - 수평선', () => { + describe('renderLine - 수평선', () => { it('기본값으로 ^FO{x},{y}^GB{length},0,1,B,0^FS 생성', () => { - const el = createElement(Line, { - length: 100, - direction: 'horizontal', - }); - expect(Line.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GB100,1,1,B,0^FS'); + expect(renderLine({ length: 100, direction: 'horizontal' })).toBe( + '^FO0,0\\&^GB100,1,1,B,0^FS' + ); }); it('fieldOrigin, thickness, lineColor 지정 시 올바른 ZPL 생성', () => { - const el = createElement(Line, { - length: 100, - direction: 'horizontal', - fieldOriginX: 10, - fieldOriginY: 20, - thickness: 2, - lineColor: 'B', - }); - expect(Line.print(el, defaultLabelContext)).toBe( - '^FO10,20\\&^GB100,2,2,B,0^FS' - ); + expect( + renderLine({ + length: 100, + direction: 'horizontal', + fieldOriginX: 10, + fieldOriginY: 20, + thickness: 2, + lineColor: 'B', + }) + ).toBe('^FO10,20\\&^GB100,2,2,B,0^FS'); }); it('흰색 라인(lineColor=W) 생성', () => { - const el = createElement(Line, { - length: 50, - direction: 'horizontal', - lineColor: 'W', - }); - expect(Line.print(el, defaultLabelContext)).toContain('^GB50,1,1,W,0^FS'); + expect( + renderLine({ length: 50, direction: 'horizontal', lineColor: 'W' }) + ).toContain('^GB50,1,1,W,0^FS'); }); }); - describe('print - 수직선', () => { + describe('renderLine - 수직선', () => { it('기본값으로 ^FO{x},{y}^GB0,{length},1,B,0^FS 생성', () => { - const el = createElement(Line, { - length: 50, - direction: 'vertical', - }); - expect(Line.print(el, defaultLabelContext)).toBe('^FO0,0\\&^GB1,50,1,B,0^FS'); + expect(renderLine({ length: 50, direction: 'vertical' })).toBe( + '^FO0,0\\&^GB1,50,1,B,0^FS' + ); }); it('fieldOrigin, thickness 지정 시 올바른 ZPL 생성', () => { - const el = createElement(Line, { - length: 80, - direction: 'vertical', - fieldOriginX: 30, - fieldOriginY: 40, - thickness: 3, - }); - expect(Line.print(el, defaultLabelContext)).toBe('^FO30,40\\&^GB3,80,3,B,0^FS'); + expect( + renderLine({ + length: 80, + direction: 'vertical', + fieldOriginX: 30, + fieldOriginY: 40, + thickness: 3, + }) + ).toBe('^FO30,40\\&^GB3,80,3,B,0^FS'); }); }); - describe('print - 검증', () => { + describe('renderLine - 검증', () => { it('length < 1 시 에러 throw', () => { - const el = createElement(Line, { length: 0, direction: 'horizontal' }); - expect(() => Line.print(el, defaultLabelContext)).toThrow( + expect(() => renderLine({ length: 0, direction: 'horizontal' })).toThrow( 'length는 1 이상이어야 합니다' ); }); it('length가 음수일 때 에러 throw', () => { - const el = createElement(Line, { length: -10, direction: 'vertical' }); - expect(() => Line.print(el, defaultLabelContext)).toThrow( + expect(() => renderLine({ length: -10, direction: 'vertical' })).toThrow( 'length는 1 이상이어야 합니다' ); }); it('thickness < 1 시 에러 throw', () => { - const el = createElement(Line, { - length: 100, - direction: 'horizontal', - thickness: 0, - }); - expect(() => Line.print(el, defaultLabelContext)).toThrow( - 'thickness는 1 이상이어야 합니다' - ); + expect(() => + renderLine({ length: 100, direction: 'horizontal', thickness: 0 }) + ).toThrow('thickness는 1 이상이어야 합니다'); }); }); diff --git a/tests/unit/QrCode.test.tsx b/tests/unit/QrCode.test.tsx index c786ebc..a7f7f02 100644 --- a/tests/unit/QrCode.test.tsx +++ b/tests/unit/QrCode.test.tsx @@ -1,50 +1,48 @@ -import { createElement } from 'react'; import { describe, expect, it } from 'vitest'; - +import { renderQrCode } from '@zpl-kit/zpl-core'; import { QrCode } from '@zpl-kit/react-zpl'; import { defaultLabelContext } 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, defaultLabelContext); - expect(zpl).toBe( - '^FO40,60\\&^BQN,2,4,Q,7\\&^FDQA,https://example.com\\&^FS' - ); - }); +describe('QrCode', () => { + describe('renderQrCode', () => { + it('^FO, ^BQ, ^FD 순서로 올바른 ZPL 생성', () => { + expect( + renderQrCode( + { + text: 'https://example.com', + fieldOriginX: 40, + fieldOriginY: 60, + magnification: 4, + }, + defaultLabelContext + ) + ).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, defaultLabelContext)).toThrow(/빈 문자열/); - }); + it('빈 문자열 시 에러 throw', () => { + expect(() => + renderQrCode({ text: '' }, defaultLabelContext) + ).toThrow(/빈 문자열/); + }); - it('throws when children is not a string', () => { - const el = createElement(QrCode, {}, ); - expect(() => QrCode.print(el, defaultLabelContext)).toThrow( - /문자열만 허용/ - ); + it('fieldOrientation 지정 시 ^BQ에 반영', () => { + const zpl = renderQrCode( + { + text: 'x', + fieldOrientation: 'R', + magnification: 2, + errorCorrectionLevel: 'M', + }, + defaultLabelContext + ); + expect(zpl).toContain('^BQR,2,2,M,7'); + expect(zpl).toContain('^FDMA,x'); + }); }); - it('uses fieldOrientation for ^BQ when set', () => { - const el = createElement( - QrCode, - { - fieldOrientation: 'R', - magnification: 2, - errorCorrectionLevel: 'M', - }, - 'x' - ); - const zpl = QrCode.print(el, defaultLabelContext); - expect(zpl).toContain('^BQR,2,2,M,7'); - expect(zpl).toContain('^FDMA,x'); + describe('displayName', () => { + it('QrCode으로 설정됨', () => { + expect(QrCode.displayName).toBe('QrCode'); + }); }); }); diff --git a/tests/unit/fixtures/context.ts b/tests/unit/fixtures/context.ts index 2e2ef43..a68c2db 100644 --- a/tests/unit/fixtures/context.ts +++ b/tests/unit/fixtures/context.ts @@ -1,5 +1,4 @@ -import { ORIENTATION } from '../../../apps/react-zpl/src/constants/orientation'; -import type { ZplElementContext } from '../../../apps/react-zpl/src/types/element'; +import { ORIENTATION, type ZplElementContext } from '@zpl-kit/zpl-core'; export const defaultLabelContext: ZplElementContext = { labelOrientation: ORIENTATION.NO_ROTATION,