hwpkit-dev 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import type { Encoder } from '../../contract/encoder';
2
+ import type { DocRoot, ParaNode, SpanNode, GridNode, ContentNode, ImgNode } from '../../model/doc-tree';
3
+ import type { Outcome } from '../../contract/result';
4
+ import { succeed, fail } from '../../contract/result';
5
+ import { TextKit } from '../../toolkit/TextKit';
6
+ import { registry } from '../../pipeline/registry';
7
+
8
+ export class MdEncoder implements Encoder {
9
+ readonly format = 'md';
10
+
11
+ async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
12
+ try {
13
+ const warns: string[] = [];
14
+ const parts: string[] = [];
15
+ for (const sheet of doc.kids) {
16
+ // Warn about header/footer loss
17
+ if (sheet.header && sheet.header.length > 0) warns.push('[SHIELD] MD: 머리글(header) 표현 불가 — 손실됨');
18
+ if (sheet.footer && sheet.footer.length > 0) warns.push('[SHIELD] MD: 바닥글(footer) 표현 불가 — 손실됨');
19
+
20
+ for (const kid of sheet.kids) parts.push(encodeContent(kid, warns));
21
+ }
22
+ return succeed(TextKit.encode(parts.join('\n\n')), warns);
23
+ } catch (e: any) {
24
+ return fail(`MD encode error: ${e?.message ?? String(e)}`);
25
+ }
26
+ }
27
+ }
28
+
29
+ function encodeContent(node: ContentNode, warns: string[]): string {
30
+ return node.tag === 'grid' ? encodeGrid(node, warns) : encodePara(node, warns);
31
+ }
32
+
33
+ function encodePara(para: ParaNode, warns: string[]): string {
34
+ const text = para.kids.map(k => {
35
+ if (k.tag === 'span') return encodeSpan(k, warns);
36
+ if (k.tag === 'img') return encodeImage(k);
37
+ return '';
38
+ }).join('');
39
+
40
+ if (para.props.heading) return `${'#'.repeat(para.props.heading)} ${text}`;
41
+
42
+ if (para.props.listOrd !== undefined) {
43
+ const indent = ' '.repeat(para.props.listLv ?? 0);
44
+ return `${indent}${para.props.listOrd ? '1.' : '-'} ${text}`;
45
+ }
46
+
47
+ // Alignment: use HTML fallback for non-left
48
+ if (para.props.align && para.props.align !== 'left' && para.props.align !== 'justify') {
49
+ return `<div align="${para.props.align}">${text}</div>`;
50
+ }
51
+
52
+ return text;
53
+ }
54
+
55
+ function encodeSpan(span: SpanNode, warns: string[]): string {
56
+ // Warn about properties that can't be represented in MD
57
+ if (span.props.font) warns.push(`[SHIELD] MD: 글꼴(${span.props.font}) 표현 불가 — 손실됨`);
58
+ if (span.props.pt) warns.push(`[SHIELD] MD: 글자 크기(${span.props.pt}pt) 표현 불가 — 손실됨`);
59
+ if (span.props.color) warns.push(`[SHIELD] MD: 글자 색상(#${span.props.color}) 표현 불가 — 손실됨`);
60
+ if (span.props.bg) warns.push(`[SHIELD] MD: 배경 색상(#${span.props.bg}) 표현 불가 — 손실됨`);
61
+
62
+ let hasPageNum = false;
63
+ const textParts: string[] = [];
64
+ for (const kid of span.kids) {
65
+ if (kid.tag === 'txt') textParts.push(kid.content);
66
+ else if (kid.tag === 'pagenum') {
67
+ hasPageNum = true;
68
+ warns.push('[SHIELD] MD: 페이지 번호 표현 불가 — 손실됨');
69
+ }
70
+ }
71
+
72
+ let r = textParts.join('');
73
+ if (hasPageNum && r === '') r = '[페이지 번호]';
74
+
75
+ if (span.props.b && span.props.i) r = `***${r}***`;
76
+ else if (span.props.b) r = `**${r}**`;
77
+ else if (span.props.i) r = `*${r}*`;
78
+ if (span.props.s) r = `~~${r}~~`;
79
+ if (span.props.u) r = `<u>${r}</u>`;
80
+ if (span.props.sup) r = `<sup>${r}</sup>`;
81
+ if (span.props.sub) r = `<sub>${r}</sub>`;
82
+
83
+ return r;
84
+ }
85
+
86
+ function encodeImage(img: ImgNode): string {
87
+ return `![${img.alt ?? ''}](data:${img.mime};base64,${img.b64})`;
88
+ }
89
+
90
+ function encodeGrid(grid: GridNode, warns: string[]): string {
91
+ if (grid.kids.length === 0) return '';
92
+
93
+ // Warn about table style loss
94
+ if (grid.props.look) warns.push('[SHIELD] MD: 표 스타일(색상, 테두리, 머리행 강조) 표현 불가 — 손실됨');
95
+
96
+ const rows = grid.kids.map(row =>
97
+ `| ${row.kids.map(cell => cell.kids.map(p => encodePara(p, warns)).join(' ')).join(' | ')} |`,
98
+ );
99
+
100
+ if (rows.length > 0) {
101
+ const cols = grid.kids[0].kids.length;
102
+ rows.splice(1, 0, `| ${Array(cols).fill('---').join(' | ')} |`);
103
+ }
104
+
105
+ return rows.join('\n');
106
+ }
107
+
108
+ registry.registerEncoder(new MdEncoder());
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ // ─── 공개 API ───────────────────────────────────────────────
2
+
3
+ // Pipeline
4
+ export { Pipeline } from './pipeline/Pipeline';
5
+ export { registry } from './pipeline/registry';
6
+
7
+ // Side-effect imports: register all decoders/encoders
8
+ import './decoders/md/MdDecoder';
9
+ import './decoders/hwpx/HwpxDecoder';
10
+ import './decoders/docx/DocxDecoder';
11
+ import './decoders/hwp/HwpScanner';
12
+ import './encoders/md/MdEncoder';
13
+ import './encoders/hwpx/HwpxEncoder';
14
+ import './encoders/docx/DocxEncoder';
15
+ import './encoders/hwp/HwpEncoder';
16
+
17
+ // Model
18
+ export type {
19
+ DocRoot, SheetNode, ParaNode, SpanNode, GridNode, RowNode, CellNode,
20
+ ImgNode, LinkNode, TxtNode, BrNode, PbNode, PageNumNode, ContentNode, AnyNode, BlockTag,
21
+ } from './model/doc-tree';
22
+ export type {
23
+ TextProps, ParaProps, CellProps, GridProps, TableLook, PageDims, DocMeta,
24
+ Align, VAlign, Heading, StrokeKind, Stroke,
25
+ } from './model/doc-props';
26
+ export { A4, A4_LANDSCAPE, DEFAULT_STROKE, normalizeDims } from './model/doc-props';
27
+ export { buildRoot, buildSheet, buildPara, buildSpan, buildImg, buildGrid, buildRow, buildCell, buildPageNum, buildBr, buildPb } from './model/builders';
28
+
29
+ // Contract
30
+ export type { Decoder } from './contract/decoder';
31
+ export type { Encoder } from './contract/encoder';
32
+ export type { Outcome, Ok, Fail } from './contract/result';
33
+ export { succeed, fail } from './contract/result';
34
+
35
+ // Safety
36
+ export { ShieldedParser } from './safety/ShieldedParser';
37
+ export { Metric, safeHex, safeAlign, safeFont, safeFontToKr, safeStrokeHwpx, safeStrokeDocx } from './safety/StyleBridge';
38
+
39
+ // Walk
40
+ export { TreeWalker, walkNode } from './walk/TreeWalker';
41
+ export { countNodes, validateRoot } from './walk/tree-ops';
42
+
43
+ // Toolkit
44
+ export { XmlKit } from './toolkit/XmlKit';
45
+ export { ArchiveKit } from './toolkit/ArchiveKit';
46
+ export { BinaryKit } from './toolkit/BinaryKit';
47
+ export { TextKit } from './toolkit/TextKit';
@@ -0,0 +1,66 @@
1
+ import type {
2
+ DocRoot, SheetNode, ParaNode, SpanNode, ImgNode,
3
+ GridNode, RowNode, CellNode, ContentNode, TxtNode, PageNumNode, BrNode, PbNode,
4
+ } from './doc-tree';
5
+ import type { TextProps, ParaProps, CellProps, GridProps, DocMeta, PageDims, ImgLayout } from './doc-props';
6
+ import { A4 } from './doc-props';
7
+
8
+ export function buildRoot(meta: DocMeta = {}, kids: SheetNode[] = []): DocRoot {
9
+ return { tag: 'root', meta, kids };
10
+ }
11
+
12
+ export function buildSheet(
13
+ kids: ContentNode[] = [],
14
+ dims: PageDims = A4,
15
+ opts?: { header?: ParaNode[]; footer?: ParaNode[] },
16
+ ): SheetNode {
17
+ const node: SheetNode = { tag: 'sheet', dims, kids };
18
+ if (opts?.header) node.header = opts.header;
19
+ if (opts?.footer) node.footer = opts.footer;
20
+ return node;
21
+ }
22
+
23
+ export function buildPageNum(format?: PageNumNode['format']): PageNumNode {
24
+ return { tag: 'pagenum', format };
25
+ }
26
+
27
+ export function buildBr(): BrNode { return { tag: 'br' }; }
28
+ export function buildPb(): PbNode { return { tag: 'pb' }; }
29
+
30
+ export function buildPara(kids: ParaNode['kids'] = [], props: ParaProps = {}): ParaNode {
31
+ return { tag: 'para', props, kids };
32
+ }
33
+
34
+ export function buildSpan(content: string, props: TextProps = {}): SpanNode {
35
+ const txt: TxtNode = { tag: 'txt', content };
36
+ return { tag: 'span', props, kids: [txt] };
37
+ }
38
+
39
+ export function buildImg(
40
+ b64: string,
41
+ mime: ImgNode['mime'],
42
+ w: number,
43
+ h: number,
44
+ alt?: string,
45
+ layout?: ImgLayout,
46
+ ): ImgNode {
47
+ const node: ImgNode = { tag: 'img', b64, mime, w, h };
48
+ if (alt) node.alt = alt;
49
+ if (layout) node.layout = layout;
50
+ return node;
51
+ }
52
+
53
+ export function buildGrid(kids: RowNode[], props: GridProps = {}): GridNode {
54
+ return { tag: 'grid', props, kids };
55
+ }
56
+
57
+ export function buildRow(kids: CellNode[]): RowNode {
58
+ return { tag: 'row', kids };
59
+ }
60
+
61
+ export function buildCell(
62
+ kids: ParaNode[],
63
+ opts: { cs?: number; rs?: number; props?: CellProps } = {},
64
+ ): CellNode {
65
+ return { tag: 'cell', cs: opts.cs ?? 1, rs: opts.rs ?? 1, props: opts.props ?? {}, kids };
66
+ }
@@ -0,0 +1,138 @@
1
+ export type Align = 'left' | 'center' | 'right' | 'justify';
2
+
3
+ // ─── 이미지 배치 ────────────────────────────────────────────
4
+ export type ImgWrap = 'inline' | 'square' | 'tight' | 'through' | 'none' | 'behind' | 'front';
5
+ export type ImgHorzAlign = 'left' | 'center' | 'right';
6
+ export type ImgVertAlign = 'top' | 'center' | 'bottom';
7
+ export type ImgHorzRelTo = 'margin' | 'column' | 'page' | 'para';
8
+ export type ImgVertRelTo = 'margin' | 'line' | 'page' | 'para';
9
+
10
+ export interface ImgLayout {
11
+ wrap: ImgWrap;
12
+ horzAlign?: ImgHorzAlign; // 정렬 기준 (xPt 없을 때)
13
+ vertAlign?: ImgVertAlign; // 정렬 기준 (yPt 없을 때)
14
+ horzRelTo?: ImgHorzRelTo; // 가로 기준점
15
+ vertRelTo?: ImgVertRelTo; // 세로 기준점
16
+ xPt?: number; // 명시적 가로 오프셋 (pt)
17
+ yPt?: number; // 명시적 세로 오프셋 (pt)
18
+ distT?: number; // 텍스트와의 거리 top (pt)
19
+ distB?: number;
20
+ distL?: number;
21
+ distR?: number;
22
+ behindDoc?: boolean; // 텍스트 뒤에 배치
23
+ zOrder?: number;
24
+ }
25
+ export type VAlign = 'top' | 'mid' | 'bot';
26
+ export type Heading = 1 | 2 | 3 | 4 | 5 | 6;
27
+ export type StrokeKind = 'solid' | 'dash' | 'dot' | 'double' | 'none';
28
+
29
+ export interface TextProps {
30
+ b?: boolean;
31
+ i?: boolean;
32
+ u?: boolean;
33
+ s?: boolean;
34
+ sup?: boolean;
35
+ sub?: boolean;
36
+ font?: string;
37
+ pt?: number;
38
+ color?: string;
39
+ bg?: string;
40
+ }
41
+
42
+ export interface ParaProps {
43
+ align?: Align;
44
+ heading?: Heading;
45
+ indentPt?: number;
46
+ spaceBefore?: number;
47
+ spaceAfter?: number;
48
+ lineHeight?: number;
49
+ listLv?: number;
50
+ listOrd?: boolean;
51
+ listMark?: string;
52
+ }
53
+
54
+ export interface Stroke {
55
+ kind: StrokeKind;
56
+ pt: number;
57
+ color: string;
58
+ }
59
+
60
+ export interface CellProps {
61
+ top?: Stroke;
62
+ bot?: Stroke;
63
+ left?: Stroke;
64
+ right?: Stroke;
65
+ bg?: string;
66
+ padPt?: number;
67
+ align?: Align;
68
+ va?: VAlign;
69
+ isHeader?: boolean;
70
+ }
71
+
72
+ export interface TableLook {
73
+ firstRow?: boolean;
74
+ lastRow?: boolean;
75
+ firstCol?: boolean;
76
+ lastCol?: boolean;
77
+ bandedRows?: boolean;
78
+ bandedCols?: boolean;
79
+ }
80
+
81
+ export interface GridProps {
82
+ widthPct?: number;
83
+ colWidths?: number[]; // column widths in points
84
+ defaultStroke?: Stroke;
85
+ look?: TableLook;
86
+ headerRow?: boolean;
87
+ }
88
+
89
+ export interface PageDims {
90
+ wPt: number;
91
+ hPt: number;
92
+ mt: number;
93
+ mb: number;
94
+ ml: number;
95
+ mr: number;
96
+ orient?: 'portrait' | 'landscape';
97
+ }
98
+
99
+ export interface DocMeta {
100
+ title?: string;
101
+ author?: string;
102
+ subject?: string;
103
+ desc?: string;
104
+ keywords?: string;
105
+ created?: string;
106
+ modified?: string;
107
+ }
108
+
109
+ export const A4: PageDims = {
110
+ wPt: 595.28, hPt: 841.89,
111
+ mt: 56.69, mb: 56.69, ml: 70.87, mr: 70.87,
112
+ orient: 'portrait',
113
+ };
114
+
115
+ export const A4_LANDSCAPE: PageDims = {
116
+ wPt: 841.89, hPt: 595.28,
117
+ mt: 56.69, mb: 56.69, ml: 70.87, mr: 70.87,
118
+ orient: 'landscape',
119
+ };
120
+
121
+ /**
122
+ * orient === 'landscape'일 때 wPt < hPt이면 swap,
123
+ * orient === 'portrait'일 때 wPt > hPt이면 swap하여
124
+ * 방향과 치수가 항상 일치하도록 정규화합니다.
125
+ */
126
+
127
+ export function normalizeDims(dims: PageDims): PageDims {
128
+ const orient = dims.orient ?? 'portrait';
129
+ if (orient === 'landscape' && dims.wPt < dims.hPt) {
130
+ return { ...dims, wPt: dims.hPt, hPt: dims.wPt };
131
+ }
132
+ if (orient === 'portrait' && dims.wPt > dims.hPt) {
133
+ return { ...dims, wPt: dims.hPt, hPt: dims.wPt };
134
+ }
135
+ return dims;
136
+ }
137
+
138
+ export const DEFAULT_STROKE: Stroke = { kind: 'solid', pt: 0.5, color: '000000' };
@@ -0,0 +1,90 @@
1
+ import type { TextProps, ParaProps, CellProps, GridProps, PageDims, DocMeta, ImgLayout } from './doc-props';
2
+
3
+ // ─── 노드 종류 ─────────────────────────────────────────────
4
+ export type BlockTag =
5
+ | 'root' | 'sheet' | 'para' | 'span'
6
+ | 'txt' | 'img' | 'link'
7
+ | 'grid' | 'row' | 'cell'
8
+ | 'br' | 'pb' | 'pagenum';
9
+
10
+ // ─── 리프 노드 ─────────────────────────────────────────────
11
+ export interface TxtNode { tag: 'txt'; content: string }
12
+ export interface BrNode { tag: 'br' }
13
+ export interface PbNode { tag: 'pb' }
14
+
15
+ export interface PageNumNode {
16
+ tag: 'pagenum';
17
+ format?: 'decimal' | 'roman' | 'romanCaps';
18
+ }
19
+
20
+ export interface ImgNode {
21
+ tag: 'img';
22
+ b64: string;
23
+ mime: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/bmp';
24
+ w: number;
25
+ h: number;
26
+ alt?: string;
27
+ layout?: ImgLayout; // 배치/위치 정보 (없으면 inline으로 취급)
28
+ }
29
+
30
+ // ─── 인라인 노드 ───────────────────────────────────────────
31
+ export interface SpanNode {
32
+ tag: 'span';
33
+ props: TextProps;
34
+ kids: (TxtNode | BrNode | PbNode | PageNumNode)[];
35
+ }
36
+
37
+ export interface LinkNode {
38
+ tag: 'link';
39
+ href: string;
40
+ kids: SpanNode[];
41
+ }
42
+
43
+ // ─── 블록 노드 ─────────────────────────────────────────────
44
+ export interface ParaNode {
45
+ tag: 'para';
46
+ props: ParaProps;
47
+ kids: (SpanNode | ImgNode | LinkNode)[];
48
+ }
49
+
50
+ // ─── 표(Grid) 노드 ─────────────────────────────────────────
51
+ export interface CellNode {
52
+ tag: 'cell';
53
+ cs: number;
54
+ rs: number;
55
+ props: CellProps;
56
+ kids: ParaNode[];
57
+ }
58
+
59
+ export interface RowNode { tag: 'row'; kids: CellNode[] }
60
+
61
+ export interface GridNode {
62
+ tag: 'grid';
63
+ props: GridProps;
64
+ kids: RowNode[];
65
+ }
66
+
67
+ // ─── 섹션(Sheet) 노드 ──────────────────────────────────────
68
+ export type ContentNode = ParaNode | GridNode;
69
+
70
+ export interface SheetNode {
71
+ tag: 'sheet';
72
+ dims: PageDims;
73
+ kids: ContentNode[];
74
+ header?: ParaNode[];
75
+ footer?: ParaNode[];
76
+ }
77
+
78
+ // ─── 루트 노드 ─────────────────────────────────────────────
79
+ export interface DocRoot {
80
+ tag: 'root';
81
+ meta: DocMeta;
82
+ kids: SheetNode[];
83
+ }
84
+
85
+ // ─── 모든 노드의 유니온 ─────────────────────────────────────
86
+ export type AnyNode =
87
+ | DocRoot | SheetNode | ParaNode | SpanNode
88
+ | TxtNode | ImgNode | LinkNode
89
+ | GridNode | RowNode | CellNode
90
+ | BrNode | PbNode | PageNumNode;
@@ -0,0 +1,71 @@
1
+ import type { DocRoot } from '../model/doc-tree';
2
+ import type { Outcome } from '../contract/result';
3
+ import { fail } from '../contract/result';
4
+ import { registry } from './registry';
5
+
6
+ // Side-effect imports: auto-register all decoders and encoders
7
+ import '../decoders/hwpx/HwpxDecoder';
8
+ import '../decoders/hwp/HwpScanner';
9
+ import '../decoders/docx/DocxDecoder';
10
+ import '../decoders/md/MdDecoder';
11
+ import '../encoders/hwpx/HwpxEncoder';
12
+ import '../encoders/docx/DocxEncoder';
13
+ import '../encoders/md/MdEncoder';
14
+
15
+ export class Pipeline {
16
+ private constructor(private raw: Uint8Array, private srcFmt: string) {}
17
+
18
+ /** 파일을 열고 포맷을 자동 감지하거나 명시 */
19
+ static open(input: Uint8Array | string, fmt?: string): Pipeline {
20
+ if (typeof input === 'string') {
21
+ return new Pipeline(new TextEncoder().encode(input), fmt ?? 'md');
22
+ }
23
+ return new Pipeline(input, fmt ?? detectFormat(input));
24
+ }
25
+
26
+ /** File/Blob 비동기 입력 */
27
+ static async openAsync(input: File | Blob | Uint8Array | string, fmt?: string): Promise<Pipeline> {
28
+ if (input instanceof Uint8Array || typeof input === 'string') {
29
+ return Pipeline.open(input, fmt);
30
+ }
31
+ const buf = await input.arrayBuffer();
32
+ const data = new Uint8Array(buf);
33
+ const detectedFmt = fmt ?? (input instanceof File ? getExt(input.name) : undefined) ?? detectFormat(data);
34
+ return new Pipeline(data, detectedFmt);
35
+ }
36
+
37
+ /** 목표 포맷으로 변환 */
38
+ async to(targetFmt: string): Promise<Outcome<Uint8Array>> {
39
+ const decoder = registry.getDecoder(this.srcFmt);
40
+ const encoder = registry.getEncoder(targetFmt);
41
+
42
+ if (!decoder) return fail(`지원하지 않는 입력 포맷: ${this.srcFmt}`);
43
+ if (!encoder) return fail(`지원하지 않는 출력 포맷: ${targetFmt}`);
44
+
45
+ const docResult = await decoder.decode(this.raw);
46
+ if (!docResult.ok) return docResult;
47
+
48
+ const encResult = await encoder.encode(docResult.data);
49
+ if (!encResult.ok) return { ...encResult, warns: [...docResult.warns, ...encResult.warns] };
50
+
51
+ return { ...encResult, warns: [...docResult.warns, ...encResult.warns] };
52
+ }
53
+
54
+ /** DocRoot만 추출 (인코딩 없이) */
55
+ async inspect(): Promise<Outcome<DocRoot>> {
56
+ const decoder = registry.getDecoder(this.srcFmt);
57
+ if (!decoder) return fail(`디코더 없음: ${this.srcFmt}`);
58
+ return decoder.decode(this.raw);
59
+ }
60
+ }
61
+
62
+ function detectFormat(data: Uint8Array): string {
63
+ if (data[0] === 0x50 && data[1] === 0x4B) return 'zip';
64
+ if (data[0] === 0xD0 && data[1] === 0xCF && data[2] === 0x11 && data[3] === 0xE0) return 'hwp';
65
+ return 'md';
66
+ }
67
+
68
+ function getExt(name: string): string | undefined {
69
+ const parts = name.split('.');
70
+ return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : undefined;
71
+ }
@@ -0,0 +1,18 @@
1
+ import type { Decoder } from '../contract/decoder';
2
+ import type { Encoder } from '../contract/encoder';
3
+
4
+ class FormatRegistry {
5
+ private decoders = new Map<string, Decoder>();
6
+ private encoders = new Map<string, Encoder>();
7
+
8
+ registerDecoder(d: Decoder): void { this.decoders.set(d.format, d); }
9
+ registerEncoder(e: Encoder): void { this.encoders.set(e.format, e); }
10
+
11
+ getDecoder(fmt: string): Decoder | undefined { return this.decoders.get(fmt); }
12
+ getEncoder(fmt: string): Encoder | undefined { return this.encoders.get(fmt); }
13
+
14
+ supportedInputs(): string[] { return [...this.decoders.keys()]; }
15
+ supportedOutputs(): string[] { return [...this.encoders.keys()]; }
16
+ }
17
+
18
+ export const registry = new FormatRegistry();
@@ -0,0 +1,91 @@
1
+ export class ShieldedParser {
2
+ private log: string[] = [];
3
+
4
+ /** 단일 요소 안전 파싱 */
5
+ guard<T>(fn: () => T, fallback: T, label: string): T {
6
+ try {
7
+ const v = fn();
8
+ if (v == null) {
9
+ this.warn(label, 'returned null/undefined');
10
+ return fallback;
11
+ }
12
+ return v;
13
+ } catch (e: any) {
14
+ this.warn(label, e?.message ?? String(e));
15
+ return fallback;
16
+ }
17
+ }
18
+
19
+ /** 배열 각 요소 독립 파싱 (하나 실패해도 나머지 계속) */
20
+ guardAll<I, O>(
21
+ items: I[],
22
+ fn: (x: I, i: number) => O,
23
+ fb: (x: I, i: number) => O,
24
+ label: string,
25
+ ): O[] {
26
+ return items.map((x, i) =>
27
+ this.guard(() => fn(x, i), fb(x, i), `${label}[${i}]`),
28
+ );
29
+ }
30
+
31
+ /**
32
+ * 표 전용 4단계 폴백
33
+ * Lv1: Full → Lv2: Grid → Lv3: Flat → Lv4: Text
34
+ */
35
+ guardGrid<T>(
36
+ node: unknown,
37
+ lv1Full: (n: unknown) => T,
38
+ lv2Grid: (n: unknown) => T,
39
+ lv3Flat: (n: unknown) => T,
40
+ lv4Text: (n: unknown) => T,
41
+ label: string,
42
+ ): { value: T; level: 1 | 2 | 3 | 4 } {
43
+ const levels: [(n: unknown) => T, 1 | 2 | 3 | 4][] = [
44
+ [lv1Full, 1], [lv2Grid, 2], [lv3Flat, 3], [lv4Text, 4],
45
+ ];
46
+
47
+ for (const [fn, lv] of levels) {
48
+ try {
49
+ const v = fn(node);
50
+ if (v != null) {
51
+ if (lv > 1) this.warn(label, `degraded to level ${lv}`);
52
+ return { value: v, level: lv };
53
+ }
54
+ } catch (e: any) {
55
+ this.warn(label, `Lv${lv} failed: ${e?.message ?? String(e)}`);
56
+ }
57
+ }
58
+
59
+ this.warn(label, 'ALL LEVELS FAILED — returning lv4Text forced');
60
+ return { value: lv4Text(null), level: 4 };
61
+ }
62
+
63
+ /** 이미지 안전 파싱 */
64
+ guardImg<T>(
65
+ node: unknown,
66
+ fn: (n: unknown) => T,
67
+ placeholder: (alt: string) => T,
68
+ label: string,
69
+ ): T {
70
+ try {
71
+ const v = fn(node);
72
+ if (v != null) return v;
73
+ } catch (e: any) {
74
+ this.warn(label, e?.message ?? String(e));
75
+ }
76
+ this.warn(label, 'using placeholder image');
77
+ return placeholder(`[이미지 로드 실패: ${label}]`);
78
+ }
79
+
80
+ private warn(label: string, msg: string): void {
81
+ const w = `[SHIELD] ${label}: ${msg}`;
82
+ console.warn(w);
83
+ this.log.push(w);
84
+ }
85
+
86
+ flush(): string[] {
87
+ const r = [...this.log];
88
+ this.log = [];
89
+ return r;
90
+ }
91
+ }