feat(core): add legend position and footer control options

- Add legendPosition option (right, left, bottom, none) to control legend placement
  - Add showFooter option to show/hide footer info
  - Support both config (YAML) and render options for flexibility
  - Fix legend overlapping issue when positioned at bottom
  - Maintain backward compatibility with showLegend option
This commit is contained in:
waaraawa 2025-10-14 11:19:33 +09:00
parent 43ae040047
commit 4d5cd4e289
14 changed files with 1387 additions and 119 deletions

View file

@ -1,92 +1,122 @@
# ByteGrid Examples
이 폴더에는 ByteGrid 플러그인에서 사용할 수 있는 예시 파일들이 있습니다.
This folder contains example files for the ByteGrid plugin.
## 사용 방법
## How to Use
1. 원하는 예시 파일을 엽니다
2. 내용을 **모두 선택**하여 복사합니다
3. Obsidian 노트에서 코드블록을 만듭니다:
1. Open the example file you want to use
2. **Select all** content and copy it
3. Create a code block in your Obsidian note:
```
```bytegrid
[여기에 붙여넣기]
[paste here]
```
```
## 예시 파일 목록
## Example Files
### 기본 예시
### Basic Examples
#### 1. simple-struct.yaml
- 간단한 16바이트 구조체
- 4개 필드 (ID, Timestamp, Value, Checksum)
- 8바이트/행 레이아웃
- Simple 16-byte structure
- 4 fields (ID, Timestamp, Value, Checksum)
- 8 bytes/row layout
#### 2. wav-header.yaml
- WAV 오디오 파일 헤더 (44바이트)
- 14개 필드 (RIFF, WAVE, fmt, data 청크)
- 16바이트/행 레이아웃
- 오디오 형식, 샘플레이트 등 포함
- WAV audio file header (44 bytes)
- 14 fields (RIFF, WAVE, fmt, data chunks)
- 16 bytes/row layout
- Includes audio format, sample rate, etc.
### 네트워크 프로토콜
### Network Protocols
#### 3. tcp-header.yaml
- TCP 프로토콜 헤더 (20바이트)
- 10개 필드 (포트, 시퀀스 번호, 플래그 등)
- **비트필드 예시**: 8개 TCP 플래그 (CWR, ECE, URG, ACK, PSH, RST, SYN, FIN)
- 16바이트/행 레이아웃
- TCP protocol header (20 bytes)
- 10 fields (ports, sequence number, flags, etc.)
- **Bitfield example**: 8 TCP flags (CWR, ECE, URG, ACK, PSH, RST, SYN, FIN)
- 16 bytes/row layout
#### 4. ip-header.yaml
- IPv4 헤더 (20바이트)
- 10개 필드 (Version, DSCP, TTL, Protocol, IP 주소 등)
- **비트필드 예시**: Version+IHL, DSCP+ECN, Flags+FragmentOffset
- IP 단편화 플래그 포함 (DF, MF)
- IPv4 header (20 bytes)
- 10 fields (Version, DSCP, TTL, Protocol, IP addresses, etc.)
- **Bitfield example**: Version+IHL, DSCP+ECN, Flags+FragmentOffset
- Includes IP fragmentation flags (DF, MF)
### 파일 시스템 & 권한
### File System & Permissions
#### 5. file-attributes.yaml
- FAT32 파일 속성 (4바이트)
- **비트필드 예시**: 8개 파일 속성 플래그
- READ_ONLY, HIDDEN, SYSTEM, DIRECTORY, ARCHIVE
- FAT32 file attributes (4 bytes)
- **Bitfield example**: 8 file attribute flags
- READ_ONLY, HIDDEN, SYSTEM, DIRECTORY, ARCHIVE, etc.
#### 6. unix-permissions.yaml
- Unix 파일 권한 (2바이트)
- **비트필드 예시**: rwxrwxrwx (9비트)
- Owner/Group/Others 권한 (읽기/쓰기/실행)
- Unix file permissions (2 bytes)
- **Bitfield example**: rwxrwxrwx (9 bits)
- Owner/Group/Others permissions (read/write/execute)
### 하드웨어 & CPU
### Hardware & CPU
#### 7. cpu-flags.yaml
- x86 CPU FLAGS 레지스터 (4바이트)
- **비트필드 예시**: 16개 CPU 플래그
- CF, ZF, SF, IF, DF, OF, IOPL
- x86 CPU FLAGS register (4 bytes)
- **Bitfield example**: 16 CPU flags
- CF, ZF, SF, IF, DF, OF, IOPL, etc.
## 주의사항
## Important Notes
⚠️ **복사 시 들여쓰기 주의**
⚠️ **Watch Out for Indentation When Copying**
Obsidian 코드블록에 붙여넣을 때 자동으로 들여쓰기가 추가될 수 있습니다.
Obsidian may automatically add indentation when pasting into code blocks.
**문제 발생 시:**
1. Obsidian **소스 모드**로 전환 (Ctrl/Cmd + E)
2. ` ```bytegrid` 다음 줄 확인
3. `name:` 앞에 공백이 **없어야** 정상
4. 공백이 있다면 제거
**If you encounter issues:**
1. Switch to Obsidian **Source Mode** (Ctrl/Cmd + E)
2. Check the line after ` ```bytegrid`
3. There should be **no spaces** before `name:`
4. Remove any extra spaces if present
## 직접 만들기
## Create Your Own
새로운 구조체를 시각화하려면:
To visualize a new structure:
```yaml
name: 구조체 이름
size: 전체 바이트 크기
layout: 16 # 한 행당 바이트 수 (선택, 기본값: 16)
name: Structure Name
size: total byte size
layout: 16 # bytes per row (optional, default: 16)
legendPosition: right # right, left, bottom, none (optional, default: right)
showFooter: true # show/hide footer (optional, default: true)
fields:
- offset: 0-3
name: 필드명
name: FieldName
type: uint32_t
color: blue # blue, cyan, yellow, green, orange, purple, mint, pink, gray
description: "설명" # 선택사항
description: "Description" # optional
```
더 자세한 내용은 [CLAUDE.md](../CLAUDE.md)를 참조하세요.
## Bit-Level Layout Examples
### 8. simple-bit-layout.yaml
- Basic bit-level layout test (16 bits)
- **Suffix notation**: `size: 16b`, `layout: 16b`, `offset: 0-7b`
- `layoutUnit` can be omitted (auto-inferred from suffix)
### 9. nibble-test.yaml
- 4-bit field (nibble) test
- Each nibble represented independently
### 10. mixed-bit-byte.yaml
- Mixed bit and byte offsets
- `0-3b` (bit), `1B` (Byte), `2-3` (byte, default)
### 11. ipv4-bit-layout.yaml
- IPv4 header in bit-level representation
- Sub-byte fields like Version(4bit) + IHL(4bit)
**Suffix Notation:**
- `b` (lowercase) = bit: `32b`, `0-7b`
- `B` (uppercase) = Byte: `4B`, `0-3B`
- no suffix = byte (default)
**layoutUnit field:**
- Can be omitted (auto-inferred from suffix)
- Can be explicit (use `layoutUnit: bit` or `layoutUnit: byte` if desired)
For more details, see [CLAUDE.md](../CLAUDE.md).

View file

@ -0,0 +1,24 @@
name: IPv4 Header (Bit Layout)
size: 4B
layout: 32b
fields:
- offset: 0-3b
name: Version
type: bits
description: "IP version (4 for IPv4)"
color: blue
- offset: 4-7b
name: IHL
type: bits
description: "Header length in 32-bit words"
color: cyan
- offset: 1B
name: DSCP+ECN
type: uint8_t
description: "Byte 1 using B suffix"
color: yellow
- offset: 16-31b
name: TotalLength
type: uint16_t
description: "Total packet length (2 bytes = 16 bits)"
color: green

View file

@ -0,0 +1,24 @@
name: Mixed Bit and Byte Offsets
size: 32b
layout: 32b
fields:
- offset: 0-3b
name: Version
type: bits
description: "4 bits with b suffix"
color: blue
- offset: 4-7b
name: IHL
type: bits
description: "4 bits with b suffix"
color: cyan
- offset: 1B
name: SecondByte
type: uint8_t
description: "Byte 1 with B suffix (= bits 8-15)"
color: yellow
- offset: 2-3
name: ThirdFourthByte
type: uint16_t
description: "Bytes 2-3 no suffix (= bits 16-31)"
color: green

14
examples/nibble-test.yaml Normal file
View file

@ -0,0 +1,14 @@
name: 4-bit Fields (Nibbles)
size: 8b
layout: 8b
fields:
- offset: 0-3b
name: Nibble1
type: bits
description: "First 4 bits"
color: blue
- offset: 4-7b
name: Nibble2
type: bits
description: "Second 4 bits"
color: cyan

View file

@ -0,0 +1,14 @@
name: Simple Bit Layout Test
size: 16b
layout: 16b
fields:
- offset: 0-7b
name: FirstByte
type: uint8_t
description: "8 bits using b suffix"
color: blue
- offset: 8-15b
name: SecondByte
type: uint8_t
description: "Another 8 bits"
color: cyan

View file

@ -14,21 +14,37 @@ import { parseOffset } from './validator';
*/
export function createLayout(config: ByteGridConfig): LayoutBlock[] {
const layout = config.layout || 16;
const layoutUnit = config.layoutUnit || 'byte';
const blocks: LayoutBlock[] = [];
// Convert offset to bits for unified processing
const toBits = (offset: ReturnType<typeof parseOffset>): { start: number; end: number; size: number } => {
if (layoutUnit === 'bit') {
// In bit layout mode, convert everything to bits
if (offset.unit === 'bit') {
return { start: offset.start, end: offset.end, size: offset.size };
}
// Convert byte offset to bits
return { start: offset.start * 8, end: offset.end * 8 + 7, size: (offset.end - offset.start + 1) * 8 };
}
// In byte layout mode, keep as bytes
return { start: offset.start, end: offset.end, size: offset.size };
};
for (const field of config.fields) {
const offset = parseOffset(field.offset);
const offsetInUnits = toBits(offset);
const color = (field.color || 'gray') as ColorName;
const isPadding = field.type === 'reserved' || field.type === 'padding';
// Check if field spans multiple rows
const startRow = Math.floor(offset.start / layout);
const endRow = Math.floor(offset.end / layout);
const startRow = Math.floor(offsetInUnits.start / layout);
const endRow = Math.floor(offsetInUnits.end / layout);
if (startRow === endRow) {
// Field fits in a single row
const col = offset.start % layout;
const span = offset.size;
const col = offsetInUnits.start % layout;
const span = offsetInUnits.size;
blocks.push({
row: startRow,
@ -39,42 +55,42 @@ export function createLayout(config: ByteGridConfig): LayoutBlock[] {
color,
value: field.value,
description: field.description,
offsetStart: offset.start,
offsetEnd: offset.end,
offsetStart: offsetInUnits.start,
offsetEnd: offsetInUnits.end,
isPadding,
bitfields: field.bitfields,
});
} else {
// Field spans multiple rows - split it
let currentByte = offset.start;
let currentUnit = offsetInUnits.start;
while (currentByte <= offset.end) {
const row = Math.floor(currentByte / layout);
const col = currentByte % layout;
while (currentUnit <= offsetInUnits.end) {
const row = Math.floor(currentUnit / layout);
const col = currentUnit % layout;
// Calculate how many bytes fit in this row
const bytesLeftInRow = layout - col;
const bytesLeftInField = offset.end - currentByte + 1;
const bytesInThisBlock = Math.min(bytesLeftInRow, bytesLeftInField);
// Calculate how many units fit in this row
const unitsLeftInRow = layout - col;
const unitsLeftInField = offsetInUnits.end - currentUnit + 1;
const unitsInThisBlock = Math.min(unitsLeftInRow, unitsLeftInField);
const blockEnd = currentByte + bytesInThisBlock - 1;
const blockEnd = currentUnit + unitsInThisBlock - 1;
blocks.push({
row,
col,
span: bytesInThisBlock,
span: unitsInThisBlock,
fieldName: field.name,
fieldType: field.type,
color,
value: field.value,
description: field.description,
offsetStart: currentByte,
offsetStart: currentUnit,
offsetEnd: blockEnd,
isPadding,
bitfields: field.bitfields,
});
currentByte += bytesInThisBlock;
currentUnit += unitsInThisBlock;
}
}
}

View file

@ -37,7 +37,7 @@ export function parse(source: string): ByteGridConfig {
throw new ParseError('Missing or invalid required field: name');
}
if (!obj.size || typeof obj.size !== 'number') {
if (!obj.size) {
throw new ParseError('Missing or invalid required field: size');
}
@ -49,8 +49,98 @@ export function parse(source: string): ByteGridConfig {
throw new ParseError('Fields array must contain at least one field');
}
// Parse layout (optional, defaults to 16)
const layout = obj.layout !== undefined ? Number(obj.layout) : 16;
// Parse layoutUnit (optional)
let layoutUnit: 'byte' | 'bit' | undefined = obj.layoutUnit && typeof obj.layoutUnit === 'string'
? (obj.layoutUnit as 'byte' | 'bit')
: undefined;
// Parse layout (optional, defaults to 16) and infer layoutUnit if not specified
let layout: number;
if (obj.layout === undefined) {
layout = 16;
} else if (typeof obj.layout === 'number') {
layout = obj.layout;
} else if (typeof obj.layout === 'string') {
const layoutStr = obj.layout.trim();
let layoutValue: number;
let layoutUnitSuffix: 'byte' | 'bit' | undefined;
// Extract suffix
if (layoutStr.endsWith('b')) {
layoutValue = parseInt(layoutStr.slice(0, -1).trim());
layoutUnitSuffix = 'bit';
// Auto-infer layoutUnit from suffix if not explicitly set
if (!layoutUnit) layoutUnit = 'bit';
} else if (layoutStr.endsWith('B')) {
layoutValue = parseInt(layoutStr.slice(0, -1).trim());
layoutUnitSuffix = 'byte';
} else {
layoutValue = parseInt(layoutStr);
layoutUnitSuffix = undefined;
}
if (isNaN(layoutValue) || layoutValue <= 0) {
throw new ParseError('Invalid layout value');
}
// Default layoutUnit to byte if still not set
const effectiveLayoutUnit = layoutUnit || 'byte';
const effectiveSuffix = layoutUnitSuffix || effectiveLayoutUnit;
// Convert to the layoutUnit
if (effectiveLayoutUnit === 'bit') {
// In bit layout mode, layout must be in bits
layout = effectiveSuffix === 'byte' ? layoutValue * 8 : layoutValue;
} else {
// In byte layout mode, layout must be in bytes
layout = effectiveSuffix === 'bit' ? Math.ceil(layoutValue / 8) : layoutValue;
}
} else {
layout = 16;
}
// Parse size (supports suffix: b = bit, B = Byte, no suffix = depends on layoutUnit) and infer layoutUnit if not specified
let size: number;
if (typeof obj.size === 'number') {
size = obj.size;
} else if (typeof obj.size === 'string') {
const sizeStr = obj.size.trim();
let sizeValue: number;
let sizeUnitSuffix: 'byte' | 'bit' | undefined;
// Extract suffix
if (sizeStr.endsWith('b')) {
sizeValue = parseInt(sizeStr.slice(0, -1).trim());
sizeUnitSuffix = 'bit';
// Auto-infer layoutUnit from suffix if not explicitly set
if (!layoutUnit) layoutUnit = 'bit';
} else if (sizeStr.endsWith('B')) {
sizeValue = parseInt(sizeStr.slice(0, -1).trim());
sizeUnitSuffix = 'byte';
} else {
sizeValue = parseInt(sizeStr);
sizeUnitSuffix = undefined;
}
if (isNaN(sizeValue) || sizeValue <= 0) {
throw new ParseError('Invalid size value');
}
// Default layoutUnit to byte if still not set
const effectiveLayoutUnit = layoutUnit || 'byte';
const effectiveSizeUnit = sizeUnitSuffix || effectiveLayoutUnit;
// Convert to the layoutUnit
if (effectiveLayoutUnit === 'bit') {
// In bit layout mode, size must be in bits
size = effectiveSizeUnit === 'byte' ? sizeValue * 8 : sizeValue;
} else {
// In byte layout mode, size must be in bytes
size = effectiveSizeUnit === 'bit' ? Math.ceil(sizeValue / 8) : sizeValue;
}
} else {
throw new ParseError('Size must be a number or string');
}
// Parse fields
const fields: Field[] = obj.fields.map((fieldObj, index) => {
@ -152,11 +242,27 @@ export function parse(source: string): ByteGridConfig {
? (obj.colorScheme as 'default' | 'dark' | 'light')
: undefined;
// Parse legendPosition (optional)
const legendPosition = obj.legendPosition && typeof obj.legendPosition === 'string'
? (obj.legendPosition as 'right' | 'left' | 'bottom' | 'none')
: undefined;
// Parse showFooter (optional)
const showFooter = obj.showFooter !== undefined && typeof obj.showFooter === 'boolean'
? obj.showFooter
: undefined;
// Set final layoutUnit (default to byte if not inferred)
const finalLayoutUnit = layoutUnit || 'byte';
return {
name: obj.name,
size: obj.size,
size,
layout,
layoutUnit: finalLayoutUnit,
colorScheme,
legendPosition,
showFooter,
fields,
};
}

View file

@ -3,7 +3,7 @@
* Renders LayoutBlocks to SVG string
*/
import { ByteGridConfig, LayoutBlock, RenderOptions, ColorName } from './types';
import { ByteGridConfig, LayoutBlock, RenderOptions, ColorName, LegendPosition } from './types';
/**
* Color palette mapping
@ -26,6 +26,8 @@ const COLORS: Record<ColorName, string> = {
const DEFAULT_OPTIONS: Required<RenderOptions> = {
showHexDump: false,
showLegend: true,
legendPosition: 'right',
showFooter: true,
showGrid: true,
cellWidth: 40, // Increased from 30 to make bit numbers readable
cellHeight: 30,
@ -47,8 +49,35 @@ export function renderSVG(
options?: RenderOptions
): string {
const opts = { ...DEFAULT_OPTIONS, ...options };
const layoutUnit = config.layoutUnit || 'byte';
const layout = config.layout || 16;
// Adjust cellWidth for bit layout
if (layoutUnit === 'bit' && !options?.cellWidth) {
opts.cellWidth = 10; // Smaller cells for bit-level layout
}
// Determine legend position
// Priority: options.legendPosition > config.legendPosition > options.showLegend (backward compat) > default
let effectiveLegendPosition: LegendPosition;
if (options?.legendPosition !== undefined) {
effectiveLegendPosition = options.legendPosition;
} else if (config.legendPosition !== undefined) {
effectiveLegendPosition = config.legendPosition;
} else if (options?.showLegend === false) {
effectiveLegendPosition = 'none';
} else {
effectiveLegendPosition = 'right'; // default
}
// Determine showFooter
// Priority: options.showFooter > config.showFooter > default (true)
const effectiveShowFooter = options?.showFooter !== undefined
? options.showFooter
: config.showFooter !== undefined
? config.showFooter
: opts.showFooter;
// Detect rows with bitfields
const rowsWithBitfields = new Set<number>();
for (const block of blocks) {
@ -65,13 +94,33 @@ export function renderSVG(
// Calculate dimensions
const rows = Math.ceil(config.size / layout);
const gridWidth = layout * opts.cellWidth;
const legendWidth = opts.showLegend ? 200 : 0;
const legendWidth = effectiveLegendPosition !== 'none' && effectiveLegendPosition !== 'bottom' ? 200 : 0;
const margin = 20;
// Count unique fields for legend height calculation
const uniqueFieldNames = new Set(blocks.map((b) => b.fieldName));
const fieldCount = uniqueFieldNames.size;
const legendHeight = opts.showLegend && fieldCount > 0 ? fieldCount * 50 + 40 : 0;
// Calculate legend height (for 'bottom' position or side positions)
let legendHeight = 0;
if (effectiveLegendPosition !== 'none' && fieldCount > 0) {
// Calculate actual height needed based on bitfields
let totalLegendHeight = 40; // Header space
const uniqueFields = new Map<string, LayoutBlock>();
for (const block of blocks) {
if (!uniqueFields.has(block.fieldName)) {
uniqueFields.set(block.fieldName, block);
}
}
for (const [, block] of uniqueFields) {
let entryHeight = 50;
if (block.bitfields && block.bitfields.length > 0) {
entryHeight = 50 + block.bitfields.length * 12;
}
totalLegendHeight += entryHeight;
}
legendHeight = totalLegendHeight;
}
// Calculate grid height based on row heights
let gridHeight = 0;
@ -89,14 +138,33 @@ export function renderSVG(
}
}
// Use the larger of grid height or legend height
const contentHeight = Math.max(gridHeight, legendHeight);
// Calculate total width and height based on legend position
const footerHeight = effectiveShowFooter ? 60 : 20; // Footer space or just bottom margin
let totalWidth: number;
let totalHeight: number;
let gridStartX: number;
let gridStartY: number;
const totalWidth = margin + gridWidth + margin + legendWidth + margin;
const totalHeight = margin + 60 + contentHeight + margin + 80;
const gridStartX = margin;
const gridStartY = margin + 60;
if (effectiveLegendPosition === 'left') {
// Legend on left, grid on right
totalWidth = margin + legendWidth + margin + gridWidth + margin;
totalHeight = margin + 60 + Math.max(gridHeight, legendHeight) + margin + footerHeight;
gridStartX = margin + legendWidth + margin;
gridStartY = margin + 60;
} else if (effectiveLegendPosition === 'bottom') {
// Legend at bottom (with extra 30px spacing for title)
totalWidth = margin + gridWidth + margin;
totalHeight = margin + 60 + gridHeight + margin + 30 + legendHeight + footerHeight;
gridStartX = margin;
gridStartY = margin + 60;
} else {
// Legend on right (default) or none
const contentHeight = Math.max(gridHeight, legendHeight);
totalWidth = margin + gridWidth + margin + legendWidth + margin;
totalHeight = margin + 60 + contentHeight + margin + footerHeight;
gridStartX = margin;
gridStartY = margin + 60;
}
// Calculate cumulative Y position for each row
const rowYPositions = new Map<number, number>();
@ -125,12 +193,27 @@ export function renderSVG(
// Title
svg += `<text x="${totalWidth / 2}" y="30" text-anchor="middle" font-size="16" font-weight="bold">${escapeXml(config.name)}</text>`;
// Column headers (byte offsets)
// Column headers
if (opts.showGrid) {
for (let col = 0; col < layout; col++) {
const x = gridStartX + col * opts.cellWidth + opts.cellWidth / 2;
const y = gridStartY - 10;
svg += `<text x="${x}" y="${y}" text-anchor="middle" font-size="9" fill="#666">${col}</text>`;
// For bit layout, show index 0-7 pattern, for byte layout show absolute position
const headerLabel = layoutUnit === 'bit' ? col % 8 : col;
svg += `<text x="${x}" y="${y}" text-anchor="middle" font-size="9" fill="#666">${headerLabel}</text>`;
}
}
// Row headers (left side) for bit layout
if (opts.showGrid && layoutUnit === 'bit') {
for (let row = 0; row < rows; row++) {
const y = rowYPositions.get(row) || gridStartY;
const cellHeight = rowsWithBitfields.has(row)
? (opts.uniformRowHeight && hasBitfields ? bitfieldCellHeight : bitfieldCellHeight)
: (opts.uniformRowHeight && hasBitfields ? bitfieldCellHeight : normalCellHeight);
const startOffset = row * layout;
const textY = y + cellHeight / 2 + 4;
svg += `<text x="${gridStartX - 5}" y="${textY}" text-anchor="end" font-size="9" fill="#666">${startOffset}</text>`;
}
}
@ -145,16 +228,18 @@ export function renderSVG(
// Draw block rectangle
svg += `<rect x="${x}" y="${y}" width="${width}" height="${cellHeight}" fill="${color}" stroke="#333" stroke-width="1"/>`;
// Draw byte numbers
for (let i = 0; i < block.span; i++) {
const byteNum = block.offsetStart + i;
const cellX = x + i * opts.cellWidth + opts.cellWidth / 2;
const cellY = y + (cellHeight / 3); // Upper third for byte number
svg += `<text x="${cellX}" y="${cellY}" text-anchor="middle" font-size="${opts.fontSize}" fill="#333">${byteNum}</text>`;
// Draw numbers inside cells (only for byte layout)
if (layoutUnit !== 'bit') {
for (let i = 0; i < block.span; i++) {
const byteNum = block.offsetStart + i;
const cellX = x + i * opts.cellWidth + opts.cellWidth / 2;
const cellY = y + (cellHeight / 3); // Upper third for byte number
svg += `<text x="${cellX}" y="${cellY}" text-anchor="middle" font-size="${opts.fontSize}" fill="#333">${byteNum}</text>`;
}
}
// Draw bit grid for bitfield cells
if (block.bitfields && block.bitfields.length > 0) {
// Draw bit grid for bitfield cells (only in byte layout mode)
if (block.bitfields && block.bitfields.length > 0 && layoutUnit !== 'bit') {
for (let i = 0; i < block.span; i++) {
const cellX = x + i * opts.cellWidth;
const bitCellWidth = opts.cellWidth / 8;
@ -175,12 +260,43 @@ export function renderSVG(
}
}
// Legend
if (opts.showLegend && blocks.length > 0) {
const legendX = margin + gridWidth + margin * 2;
const legendStartY = gridStartY;
// Draw bit boundary lines for bit layout
if (layoutUnit === 'bit') {
for (let col = 1; col < layout; col++) {
const lineX = gridStartX + col * opts.cellWidth;
// Every 8 bits: darker dotted line (byte boundary)
// Other bits: lighter dotted line (bit boundary)
if (col % 8 === 0) {
svg += `<line x1="${lineX}" y1="${gridStartY}" x2="${lineX}" y2="${gridStartY + gridHeight}" stroke="#999" stroke-width="1" stroke-dasharray="3,3"/>`;
} else {
svg += `<line x1="${lineX}" y1="${gridStartY}" x2="${lineX}" y2="${gridStartY + gridHeight}" stroke="#ddd" stroke-width="0.5" stroke-dasharray="2,2"/>`;
}
}
}
svg += `<text x="${legendX}" y="${legendStartY - 20}" font-size="12" font-weight="bold" fill="#333">Fields</text>`;
// Legend
if (effectiveLegendPosition !== 'none' && blocks.length > 0) {
let legendX: number;
let legendStartY: number;
let legendTitleY: number;
// Position legend based on effectiveLegendPosition
if (effectiveLegendPosition === 'left') {
legendX = margin;
legendStartY = gridStartY;
legendTitleY = legendStartY - 20;
} else if (effectiveLegendPosition === 'bottom') {
legendX = margin;
legendStartY = gridStartY + gridHeight + margin + 30; // Extra space for title
legendTitleY = gridStartY + gridHeight + margin + 10;
} else {
// 'right' (default)
legendX = margin + gridWidth + margin * 2;
legendStartY = gridStartY;
legendTitleY = legendStartY - 20;
}
svg += `<text x="${legendX}" y="${legendTitleY}" font-size="12" font-weight="bold" fill="#333">Fields</text>`;
// Group blocks by field name to avoid duplicates
const uniqueFields = new Map<string, LayoutBlock>();
@ -212,7 +328,9 @@ export function renderSVG(
svg += `<text x="${legendX + 30}" y="${y + 24}" font-size="9" fill="#666">${escapeXml(block.fieldType)}</text>`;
// Offset info
svg += `<text x="${legendX + 30}" y="${y + 36}" font-size="9" fill="#999">offset: ${block.offsetStart}-${block.offsetEnd} (${block.offsetEnd - block.offsetStart + 1} bytes)</text>`;
const unit = layoutUnit === 'bit' ? 'bits' : 'bytes';
const size = block.offsetEnd - block.offsetStart + 1;
svg += `<text x="${legendX + 30}" y="${y + 36}" font-size="9" fill="#999">offset: ${block.offsetStart}-${block.offsetEnd} (${size} ${unit})</text>`;
// Bitfields (if any)
if (block.bitfields && block.bitfields.length > 0) {
@ -231,8 +349,12 @@ export function renderSVG(
}
// Footer
const footerY = totalHeight - 40;
svg += `<text x="${totalWidth / 2}" y="${footerY}" text-anchor="middle" font-size="11" fill="#999">Total size: ${config.size} bytes | Layout: ${layout} bytes/row</text>`;
if (effectiveShowFooter) {
const footerY = totalHeight - 40;
const sizeUnit = layoutUnit === 'bit' ? 'bits' : 'bytes';
const layoutUnitText = layoutUnit === 'bit' ? 'bits/row' : 'bytes/row';
svg += `<text x="${totalWidth / 2}" y="${footerY}" text-anchor="middle" font-size="11" fill="#999">Total size: ${config.size} ${sizeUnit} | Layout: ${layout} ${layoutUnitText}</text>`;
}
svg += `</svg>`;
return svg;

View file

@ -72,14 +72,27 @@ export interface Field {
bitfields?: Bitfield[];
}
/**
* Layout unit type
*/
export type LayoutUnit = 'byte' | 'bit';
/**
* Legend position options
*/
export type LegendPosition = 'right' | 'left' | 'bottom' | 'none';
/**
* Main configuration for ByteGrid visualization
*/
export interface ByteGridConfig {
name: string;
size: number; // Total size in bytes
layout?: number; // Bytes per row (default: 16)
size: number; // Total size in bytes (for bit layouts, this is total bits)
layout?: number; // Units per row (default: 16), unit depends on layoutUnit
layoutUnit?: LayoutUnit; // 'byte' (default) or 'bit'
colorScheme?: ColorScheme;
legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none'
showFooter?: boolean; // Show footer with total size and layout info (default: true)
fields: Field[];
}
@ -90,6 +103,7 @@ export interface OffsetRange {
start: number;
end: number;
size: number;
unit: LayoutUnit; // 'byte' or 'bit'
}
/**
@ -115,7 +129,9 @@ export interface LayoutBlock {
*/
export interface RenderOptions {
showHexDump?: boolean;
showLegend?: boolean;
showLegend?: boolean; // Deprecated: use legendPosition instead
legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none'
showFooter?: boolean; // Show footer with total size and layout info (default: true)
showGrid?: boolean;
cellWidth?: number;
cellHeight?: number;

View file

@ -26,6 +26,7 @@ const SUPPORTED_TYPES: Set<string> = new Set([
'double',
'reserved',
'padding',
'bits', // For bit-level fields
]);
/**
@ -47,10 +48,14 @@ const TYPE_BITS: Record<string, number> = {
/**
* Parse offset string to OffsetRange
* Supports formats: "0-3" (range) or "4" (single byte)
* Supports formats:
* "0-3" (byte range), "4" (single byte)
* "0-7b" (bit range), "5b" (single bit)
* "0-3B" (byte range), "0B" (single byte)
* No suffix defaults to byte
*
* @param offset Offset string
* @returns Parsed offset range
* @returns Parsed offset range with unit
* @throws ValidationError if format is invalid
*/
export function parseOffset(offset: string): OffsetRange {
@ -60,9 +65,21 @@ export function parseOffset(offset: string): OffsetRange {
throw new ValidationError('Offset cannot be empty');
}
// Extract suffix (b = bit, B = Byte, none = byte default)
let unit: 'byte' | 'bit' = 'byte';
let offsetWithoutSuffix = trimmed;
if (trimmed.endsWith('b')) {
unit = 'bit';
offsetWithoutSuffix = trimmed.slice(0, -1).trim();
} else if (trimmed.endsWith('B')) {
unit = 'byte';
offsetWithoutSuffix = trimmed.slice(0, -1).trim();
}
// Check for range format "0-3"
if (trimmed.includes('-')) {
const parts = trimmed.split('-');
if (offsetWithoutSuffix.includes('-')) {
const parts = offsetWithoutSuffix.split('-');
if (parts.length !== 2) {
throw new ValidationError(`Invalid offset format: "${offset}"`);
@ -89,11 +106,12 @@ export function parseOffset(offset: string): OffsetRange {
start,
end,
size: end - start + 1,
unit,
};
}
// Single byte format "4"
const value = parseInt(trimmed);
// Single value format "4"
const value = parseInt(offsetWithoutSuffix);
if (isNaN(value)) {
throw new ValidationError(`Invalid offset format: "${offset}"`);
@ -107,6 +125,7 @@ export function parseOffset(offset: string): OffsetRange {
start: value,
end: value,
size: 1,
unit,
};
}
@ -168,9 +187,24 @@ function parseBitfieldBits(bits: string): { start: number; end: number } {
* @throws ValidationError if validation fails
*/
export function validate(config: ByteGridConfig): void {
const layoutUnit = config.layoutUnit || 'byte';
// Convert offset to bits for unified comparison
const toBits = (offset: OffsetRange): { start: number; end: number } => {
if (offset.unit === 'bit') {
return { start: offset.start, end: offset.end };
}
// Convert bytes to bits
return { start: offset.start * 8, end: offset.end * 8 + 7 };
};
// Total size in bits
const totalSizeInBits = layoutUnit === 'bit' ? config.size : config.size * 8;
const parsedFields: Array<{
index: number;
offset: OffsetRange;
offsetInBits: { start: number; end: number };
name: string;
type: DataType;
}> = [];
@ -190,10 +224,12 @@ export function validate(config: ByteGridConfig): void {
);
}
// Validate field doesn't exceed total size
if (offset.end >= config.size) {
const offsetInBits = toBits(offset);
// Validate field doesn't exceed total size (in bits)
if (offsetInBits.end >= totalSizeInBits) {
throw new ValidationError(
`Field "${field.name}" at index ${i} (offset ${offset.start}-${offset.end}) exceeds total size ${config.size}`,
`Field "${field.name}" at index ${i} (offset ${field.offset}) exceeds total size ${config.size} ${layoutUnit}${config.size !== 1 ? 's' : ''}`,
i
);
}
@ -246,26 +282,29 @@ export function validate(config: ByteGridConfig): void {
parsedFields.push({
index: i,
offset,
offsetInBits,
name: field.name,
type: field.type,
});
}
// Check for overlapping fields
// Check for overlapping fields (in bit space)
for (let i = 0; i < parsedFields.length; i++) {
for (let j = i + 1; j < parsedFields.length; j++) {
const field1 = parsedFields[i];
const field2 = parsedFields[j];
// Check if ranges overlap
// Check if ranges overlap (in bits)
const overlap =
(field1.offset.start <= field2.offset.end && field1.offset.end >= field2.offset.start) ||
(field2.offset.start <= field1.offset.end && field2.offset.end >= field1.offset.start);
(field1.offsetInBits.start <= field2.offsetInBits.end && field1.offsetInBits.end >= field2.offsetInBits.start) ||
(field2.offsetInBits.start <= field1.offsetInBits.end && field2.offsetInBits.end >= field1.offsetInBits.start);
if (overlap) {
const field1Desc = `${config.fields[field1.index].offset}`;
const field2Desc = `${config.fields[field2.index].offset}`;
throw new ValidationError(
`Field "${field2.name}" at index ${field2.index} (offset ${field2.offset.start}-${field2.offset.end}) ` +
`overlaps with field "${field1.name}" (offset ${field1.offset.start}-${field1.offset.end})`,
`Field "${field2.name}" at index ${field2.index} (offset ${field2Desc}) ` +
`overlaps with field "${field1.name}" (offset ${field1Desc})`,
field2.index
);
}

View file

@ -247,4 +247,141 @@ describe('LayoutEngine', () => {
expect(blocks[3].col).toBe(12);
});
});
describe('createLayout() - bit-unit layout', () => {
it('should create layout blocks for bit-unit fields', () => {
const config: ByteGridConfig = {
name: 'Bit Layout',
size: 16, // 16 bits
layoutUnit: 'bit',
layout: 16, // 16 bits per row
fields: [
{ offset: '0-7b', name: 'FirstByte', type: 'uint8_t', color: 'blue' },
{ offset: '8-15b', name: 'SecondByte', type: 'uint8_t', color: 'cyan' },
],
};
const blocks = createLayout(config);
expect(blocks).toHaveLength(2);
// FirstByte: bits 0-7
expect(blocks[0].row).toBe(0);
expect(blocks[0].col).toBe(0);
expect(blocks[0].span).toBe(8);
expect(blocks[0].fieldName).toBe('FirstByte');
expect(blocks[0].offsetStart).toBe(0);
expect(blocks[0].offsetEnd).toBe(7);
// SecondByte: bits 8-15
expect(blocks[1].row).toBe(0);
expect(blocks[1].col).toBe(8);
expect(blocks[1].span).toBe(8);
expect(blocks[1].fieldName).toBe('SecondByte');
});
it('should handle Byte offset in bit layout', () => {
const config: ByteGridConfig = {
name: 'Mixed',
size: 16, // 16 bits
layoutUnit: 'bit',
layout: 16,
fields: [
{ offset: '0B', name: 'ByteField', type: 'uint8_t', color: 'blue' },
],
};
const blocks = createLayout(config);
expect(blocks).toHaveLength(1);
// 0B = byte 0 = bits 0-7
expect(blocks[0].row).toBe(0);
expect(blocks[0].col).toBe(0);
expect(blocks[0].span).toBe(8);
expect(blocks[0].offsetStart).toBe(0);
expect(blocks[0].offsetEnd).toBe(7);
});
it('should split bit fields across multiple rows', () => {
const config: ByteGridConfig = {
name: 'Multi-row Bits',
size: 32, // 32 bits
layoutUnit: 'bit',
layout: 16, // 16 bits per row
fields: [
{ offset: '0-23b', name: 'LargeBitField', type: 'bits', color: 'green' },
],
};
const blocks = createLayout(config);
// Should split into 2 blocks (16 bits each)
expect(blocks).toHaveLength(2);
// First row: bits 0-15
expect(blocks[0].row).toBe(0);
expect(blocks[0].col).toBe(0);
expect(blocks[0].span).toBe(16);
expect(blocks[0].offsetStart).toBe(0);
expect(blocks[0].offsetEnd).toBe(15);
// Second row: bits 16-23
expect(blocks[1].row).toBe(1);
expect(blocks[1].col).toBe(0);
expect(blocks[1].span).toBe(8);
expect(blocks[1].offsetStart).toBe(16);
expect(blocks[1].offsetEnd).toBe(23);
});
it('should handle mixed bit and Byte offsets', () => {
const config: ByteGridConfig = {
name: 'IPv4 Header',
size: 32, // 32 bits
layoutUnit: 'bit',
layout: 32,
fields: [
{ offset: '0-3b', name: 'Version', type: 'bits', color: 'blue' },
{ offset: '4-7b', name: 'IHL', type: 'bits', color: 'cyan' },
{ offset: '1B', name: 'DSCP', type: 'uint8_t', color: 'yellow' },
],
};
const blocks = createLayout(config);
expect(blocks).toHaveLength(3);
// Version: bits 0-3
expect(blocks[0].col).toBe(0);
expect(blocks[0].span).toBe(4);
// IHL: bits 4-7
expect(blocks[1].col).toBe(4);
expect(blocks[1].span).toBe(4);
// DSCP: byte 1 = bits 8-15
expect(blocks[2].col).toBe(8);
expect(blocks[2].span).toBe(8);
expect(blocks[2].offsetStart).toBe(8);
expect(blocks[2].offsetEnd).toBe(15);
});
it('should handle 4-bit fields', () => {
const config: ByteGridConfig = {
name: '4-bit Fields',
size: 8, // 8 bits = 1 byte
layoutUnit: 'bit',
layout: 8,
fields: [
{ offset: '0-3b', name: 'Nibble1', type: 'bits' },
{ offset: '4-7b', name: 'Nibble2', type: 'bits' },
],
};
const blocks = createLayout(config);
expect(blocks).toHaveLength(2);
expect(blocks[0].span).toBe(4);
expect(blocks[1].span).toBe(4);
});
});
});

View file

@ -230,5 +230,399 @@ fields:
expect(() => parse(yaml)).toThrow(ParseError);
expect(() => parse(yaml)).toThrow(/field.*type/i);
});
// Bit-unit layout tests
describe('Bit-unit layout', () => {
it('should parse layoutUnit: bit', () => {
const yaml = `
name: Bit Layout Test
size: 2
layoutUnit: bit
layout: 16
fields:
- offset: 0-7b
name: BitField
type: uint8_t
`;
const result = parse(yaml);
expect(result.layoutUnit).toBe('bit');
expect(result.layout).toBe(16);
});
it('should default layoutUnit to byte', () => {
const yaml = `
name: Test
size: 16
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.layoutUnit).toBe('byte');
});
it('should parse bit offset with b suffix: 0-7b', () => {
const yaml = `
name: Test
size: 2
layoutUnit: bit
fields:
- offset: 0-7b
name: BitField
type: uint8_t
`;
const result = parse(yaml);
expect(result.fields[0].offset).toBe('0-7b');
});
it('should parse Byte offset with B suffix: 0B', () => {
const yaml = `
name: Test
size: 2
layoutUnit: bit
fields:
- offset: 0B
name: ByteField
type: uint8_t
`;
const result = parse(yaml);
expect(result.fields[0].offset).toBe('0B');
});
it('should parse offset without suffix as Byte', () => {
const yaml = `
name: Test
size: 2
layoutUnit: bit
fields:
- offset: 0-1
name: TwoBytes
type: uint16_t
`;
const result = parse(yaml);
expect(result.fields[0].offset).toBe('0-1');
});
it('should parse mixed bit and Byte offsets', () => {
const yaml = `
name: IPv4 Header
size: 4
layoutUnit: bit
layout: 32
fields:
- offset: 0-3b
name: Version
type: bits
- offset: 4-7b
name: IHL
type: bits
- offset: 1B
name: DSCP
type: uint8_t
`;
const result = parse(yaml);
expect(result.fields).toHaveLength(3);
expect(result.fields[0].offset).toBe('0-3b');
expect(result.fields[1].offset).toBe('4-7b');
expect(result.fields[2].offset).toBe('1B');
});
it('should parse size with B suffix (Byte)', () => {
const yaml = `
name: Test
size: 4B
layoutUnit: bit
fields:
- offset: 0-31b
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.size).toBe(32); // 4 bytes = 32 bits (in bit layout mode)
});
it('should parse size with b suffix (bit)', () => {
const yaml = `
name: Test
size: 32b
layoutUnit: bit
fields:
- offset: 0-31b
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.size).toBe(32); // 32 bits
});
it('should parse size without suffix (uses layoutUnit)', () => {
const yaml = `
name: Test
size: 4
layoutUnit: byte
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.size).toBe(4); // 4 bytes (layoutUnit=byte)
});
it('should parse layout with B suffix (Byte)', () => {
const yaml = `
name: Test
size: 16b
layoutUnit: bit
layout: 2B
fields:
- offset: 0-15b
name: Field1
type: uint16_t
`;
const result = parse(yaml);
expect(result.layout).toBe(16); // 2 Bytes = 16 bits (in bit layout mode)
});
it('should parse layout with b suffix (bit)', () => {
const yaml = `
name: Test
size: 32b
layoutUnit: bit
layout: 24b
fields:
- offset: 0-31b
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.layout).toBe(24); // 24 bits
});
it('should parse layout without suffix (uses layoutUnit)', () => {
const yaml = `
name: Test
size: 32
layoutUnit: byte
layout: 8
fields:
- offset: 0-31
name: Field1
type: uint8_t[32]
`;
const result = parse(yaml);
expect(result.layout).toBe(8); // 8 bytes (layoutUnit=byte)
});
it('should auto-infer layoutUnit=bit from b suffix', () => {
const yaml = `
name: Test
size: 32b
layout: 16b
fields:
- offset: 0-7b
name: Field1
type: uint8_t
`;
const result = parse(yaml);
expect(result.layoutUnit).toBe('bit'); // Auto-inferred from b suffix
expect(result.size).toBe(32);
expect(result.layout).toBe(16);
});
it('should default to byte when no layoutUnit and no b suffix', () => {
const yaml = `
name: Test
size: 4B
layout: 16
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.layoutUnit).toBe('byte'); // Default
expect(result.size).toBe(4);
expect(result.layout).toBe(16);
});
it('should prefer explicit layoutUnit over suffix inference', () => {
const yaml = `
name: Test
size: 4B
layoutUnit: byte
layout: 16
fields:
- offset: 0-31b
name: Field1
type: bits
`;
const result = parse(yaml);
expect(result.layoutUnit).toBe('byte'); // Explicit wins
});
});
// Legend position tests
describe('Legend position', () => {
it('should parse legendPosition: right', () => {
const yaml = `
name: Test
size: 16
legendPosition: right
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendPosition).toBe('right');
});
it('should parse legendPosition: left', () => {
const yaml = `
name: Test
size: 16
legendPosition: left
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendPosition).toBe('left');
});
it('should parse legendPosition: bottom', () => {
const yaml = `
name: Test
size: 16
legendPosition: bottom
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendPosition).toBe('bottom');
});
it('should parse legendPosition: none', () => {
const yaml = `
name: Test
size: 16
legendPosition: none
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendPosition).toBe('none');
});
it('should return undefined when legendPosition is not specified', () => {
const yaml = `
name: Test
size: 16
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendPosition).toBeUndefined();
});
});
// showFooter tests
describe('showFooter', () => {
it('should parse showFooter: true', () => {
const yaml = `
name: Test
size: 16
showFooter: true
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.showFooter).toBe(true);
});
it('should parse showFooter: false', () => {
const yaml = `
name: Test
size: 16
showFooter: false
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.showFooter).toBe(false);
});
it('should return undefined when showFooter is not specified', () => {
const yaml = `
name: Test
size: 16
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.showFooter).toBeUndefined();
});
});
});
});

View file

@ -284,5 +284,204 @@ describe('SVGRenderer', () => {
expect(svg).toContain('height="30"');
});
});
// Legend position tests
describe('Legend position', () => {
const legendConfig: ByteGridConfig = {
name: 'Legend Position Test',
size: 16,
layout: 16,
fields: [
{ offset: '0-3', name: 'Field1', type: 'uint32_t', color: 'blue' },
{ offset: '4-7', name: 'Field2', type: 'uint32_t', color: 'cyan' },
],
};
const legendBlocks: LayoutBlock[] = [
{
row: 0,
col: 0,
span: 4,
fieldName: 'Field1',
fieldType: 'uint32_t',
color: 'blue',
offsetStart: 0,
offsetEnd: 3,
isPadding: false,
},
{
row: 0,
col: 4,
span: 4,
fieldName: 'Field2',
fieldType: 'uint32_t',
color: 'cyan',
offsetStart: 4,
offsetEnd: 7,
isPadding: false,
},
];
it('should render legend on right by default', () => {
const svg = renderSVG(legendConfig, legendBlocks);
// Should contain legend title
expect(svg).toContain('Fields');
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
});
it('should render legend on right when legendPosition is "right"', () => {
const svg = renderSVG(legendConfig, legendBlocks, {
legendPosition: 'right',
});
expect(svg).toContain('Fields');
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
});
it('should render legend on left when legendPosition is "left"', () => {
const svg = renderSVG(legendConfig, legendBlocks, {
legendPosition: 'left',
});
expect(svg).toContain('Fields');
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
});
it('should render legend at bottom when legendPosition is "bottom"', () => {
const svg = renderSVG(legendConfig, legendBlocks, {
legendPosition: 'bottom',
});
expect(svg).toContain('Fields');
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
});
it('should not render legend when legendPosition is "none"', () => {
const svg = renderSVG(legendConfig, legendBlocks, {
legendPosition: 'none',
});
// Should NOT contain legend
expect(svg).not.toContain('Fields');
});
it('should not render legend when showLegend is false (backward compatibility)', () => {
const svg = renderSVG(legendConfig, legendBlocks, {
showLegend: false,
});
// Should NOT contain legend
expect(svg).not.toContain('Fields');
});
it('should prioritize legendPosition over showLegend', () => {
// legendPosition: 'none' should override showLegend: true
const svg1 = renderSVG(legendConfig, legendBlocks, {
showLegend: true,
legendPosition: 'none',
});
expect(svg1).not.toContain('Fields');
// legendPosition: 'right' should override showLegend: false
const svg2 = renderSVG(legendConfig, legendBlocks, {
showLegend: false,
legendPosition: 'right',
});
expect(svg2).toContain('Fields');
});
it('should use config.legendPosition when options.legendPosition is not provided', () => {
const configWithLegend: ByteGridConfig = {
...legendConfig,
legendPosition: 'left',
};
const svg = renderSVG(configWithLegend, legendBlocks);
expect(svg).toContain('Fields');
});
it('should prioritize options.legendPosition over config.legendPosition', () => {
const configWithLegend: ByteGridConfig = {
...legendConfig,
legendPosition: 'left',
};
const svg = renderSVG(configWithLegend, legendBlocks, {
legendPosition: 'none',
});
expect(svg).not.toContain('Fields');
});
it('should respect config.legendPosition: none', () => {
const configWithLegend: ByteGridConfig = {
...legendConfig,
legendPosition: 'none',
};
const svg = renderSVG(configWithLegend, legendBlocks);
expect(svg).not.toContain('Fields');
});
});
// showFooter tests
describe('showFooter option', () => {
it('should render footer by default', () => {
const svg = renderSVG(sampleConfig, sampleBlocks);
expect(svg).toContain('Total size:');
expect(svg).toContain('Layout:');
});
it('should render footer when showFooter is true in options', () => {
const svg = renderSVG(sampleConfig, sampleBlocks, {
showFooter: true,
});
expect(svg).toContain('Total size:');
});
it('should not render footer when showFooter is false in options', () => {
const svg = renderSVG(sampleConfig, sampleBlocks, {
showFooter: false,
});
expect(svg).not.toContain('Total size:');
expect(svg).not.toContain('Layout:');
});
it('should use config.showFooter when options.showFooter is not provided', () => {
const configWithFooter: ByteGridConfig = {
...sampleConfig,
showFooter: false,
};
const svg = renderSVG(configWithFooter, sampleBlocks);
expect(svg).not.toContain('Total size:');
});
it('should prioritize options.showFooter over config.showFooter', () => {
const configWithFooter: ByteGridConfig = {
...sampleConfig,
showFooter: true,
};
const svg = renderSVG(configWithFooter, sampleBlocks, {
showFooter: false,
});
expect(svg).not.toContain('Total size:');
});
});
});
});

View file

@ -246,4 +246,137 @@ describe('Validator', () => {
expect(() => validate(config)).toThrow(/bit.*out of range/i);
});
});
describe('parseOffset() - bit/byte suffix', () => {
it('should parse bit offset with b suffix: 0-7b', () => {
const result = parseOffset('0-7b');
expect(result.start).toBe(0);
expect(result.end).toBe(7);
expect(result.size).toBe(8);
expect(result.unit).toBe('bit');
});
it('should parse single bit with b suffix: 5b', () => {
const result = parseOffset('5b');
expect(result.start).toBe(5);
expect(result.end).toBe(5);
expect(result.size).toBe(1);
expect(result.unit).toBe('bit');
});
it('should parse Byte offset with B suffix: 0B', () => {
const result = parseOffset('0B');
expect(result.start).toBe(0);
expect(result.end).toBe(0);
expect(result.size).toBe(1);
expect(result.unit).toBe('byte');
});
it('should parse Byte range with B suffix: 1-3B', () => {
const result = parseOffset('1-3B');
expect(result.start).toBe(1);
expect(result.end).toBe(3);
expect(result.size).toBe(3);
expect(result.unit).toBe('byte');
});
it('should default to byte unit when no suffix: 0-3', () => {
const result = parseOffset('0-3');
expect(result.start).toBe(0);
expect(result.end).toBe(3);
expect(result.size).toBe(4);
expect(result.unit).toBe('byte');
});
it('should parse with whitespace: 0 - 7b', () => {
const result = parseOffset('0 - 7b');
expect(result.start).toBe(0);
expect(result.end).toBe(7);
expect(result.size).toBe(8);
expect(result.unit).toBe('bit');
});
});
describe('validate() - bit-unit layout', () => {
it('should validate bit-unit layout with bit offsets', () => {
const config: ByteGridConfig = {
name: 'Bit Layout',
size: 16, // 16 bits = 2 bytes
layoutUnit: 'bit',
layout: 16,
fields: [
{ offset: '0-7b', name: 'FirstByte', type: 'uint8_t' },
{ offset: '8-15b', name: 'SecondByte', type: 'uint8_t' },
],
};
expect(() => validate(config)).not.toThrow();
});
it('should validate mixed bit and Byte offsets', () => {
const config: ByteGridConfig = {
name: 'Mixed Layout',
size: 32, // 32 bits = 4 bytes
layoutUnit: 'bit',
layout: 32,
fields: [
{ offset: '0-3b', name: 'Version', type: 'bits' },
{ offset: '4-7b', name: 'IHL', type: 'bits' },
{ offset: '1B', name: 'DSCP', type: 'uint8_t' },
],
};
expect(() => validate(config)).not.toThrow();
});
it('should detect overlap in bit-unit layout', () => {
const config: ByteGridConfig = {
name: 'Overlap Test',
size: 16, // 16 bits = 2 bytes
layoutUnit: 'bit',
fields: [
{ offset: '0-7b', name: 'Field1', type: 'uint8_t' },
{ offset: '4-11b', name: 'Field2', type: 'uint8_t' }, // overlaps bits 4-7
],
};
expect(() => validate(config)).toThrow(ValidationError);
expect(() => validate(config)).toThrow(/overlap/i);
});
it('should detect overlap between bit and Byte offsets', () => {
const config: ByteGridConfig = {
name: 'Mixed Overlap',
size: 16, // 16 bits = 2 bytes
layoutUnit: 'bit',
fields: [
{ offset: '0-3b', name: 'Version', type: 'bits' },
{ offset: '0B', name: 'FullByte', type: 'uint8_t' }, // overlaps bits 0-3
],
};
expect(() => validate(config)).toThrow(ValidationError);
expect(() => validate(config)).toThrow(/overlap/i);
});
it('should throw ValidationError if bit offset exceeds size', () => {
const config: ByteGridConfig = {
name: 'Size Test',
size: 16, // 16 bits
layoutUnit: 'bit',
fields: [
{ offset: '0-19b', name: 'TooBig', type: 'bits' }, // exceeds 16 bits
],
};
expect(() => validate(config)).toThrow(ValidationError);
expect(() => validate(config)).toThrow(/exceeds total size/i);
});
});
});