UNPKG

penframe

Version:

A lightweight DSL-based wireframe and UI structure visualization tool.

788 lines (704 loc) 27.4 kB
function astToSvg(ast) { if (!Array.isArray(ast) || ast.length === 0) { throw new Error('AST must be a non-empty array'); } const appNode = ast.find(n => n.type === 'config') || {}; const TITLE_HEIGHT = 60; const appWidth = appNode.width || 1200; const appHeight = appNode.height || 800; const title = appNode.title || 'Penframe SVG'; const svgWidth = appWidth; const svgHeight = appHeight + TITLE_HEIGHT; const rootElements = ast.filter(n => n.type !== 'config'); const body = renderElements(rootElements, { width: appWidth, height: appHeight }); return `<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}"> <style> .container { stroke: #333; stroke-width: 1; } .app-area { fill: #ffffff; stroke: #cccccc; stroke-width: 1; } .headline { font-family: sans-serif; dominant-baseline: middle; } .paragraph { font-family: sans-serif; dominant-baseline: middle; } .title { font-family: sans-serif; font-weight: bold; dominant-baseline: middle; } .button { font-family: sans-serif; } .form-label { font-family: sans-serif; font-size: 12px; } .form-control { stroke: #333; stroke-width: 1; } .placeholder-text { font-family: sans-serif; fill: #999; } .list-item-text { font-family: sans-serif; font-size: 16px; } .table-header { font-family: sans-serif; font-weight: bold; } .table-cell { font-family: sans-serif; } </style> <text class="title" x="${svgWidth / 2}" y="${TITLE_HEIGHT / 2}" text-anchor="middle" font-size="20">${escapeXml(title)}</text> <g class="app-group" transform="translate(0, ${TITLE_HEIGHT})"> <rect class="app-area" x="0" y="0" width="${appWidth}" height="${appHeight}" /> ${body} </g> </svg>`; } function renderElements(elements, parent) { if (!elements || elements.length === 0) { return ''; } let defaultY = 40; const headlineYStep = 40; const paragraphYStep = 20; const buttonYStep = 40; const formControlYStep = 60; const listYStep = 80; const imageYStep = 150; const tableYStep = 200; return elements.map(node => { if (node.type === 'container') { const y = node.y ?? defaultY; const height = node.height || 100; if (node.y === undefined) { defaultY += height; } return renderContainer(node, parent, y); } if (node.type === 'headline') { const x = node.x ?? parent.width / 2; const y = node.y ?? defaultY; if (node.y === undefined) { defaultY += headlineYStep; } return renderHeadline(node, x, y); } if (node.type === 'paragraph') { const y = node.y ?? defaultY; let height = paragraphYStep; const fontSize = 14; if (node.width) { const lines = getWrappedLines(node.value, node.width, fontSize); height = lines.length * fontSize * 1.2; } if (node.y === undefined) { defaultY += height; } return renderParagraph(node, parent, y); } if (node.type === 'button') { const y = node.y ?? defaultY; if (node.y === undefined) { defaultY += buttonYStep; } return renderButton(node, parent, y); } if (node.type === 'formcontrol') { const y = node.y ?? defaultY; if (node.y === undefined) { let controlHeight = 30; // Default height const labelHeight = 18; let step = labelHeight; const items = node.items; if (node.control === 'textbox' && node.rows > 1) { controlHeight = (node.rows * 16) + 14; } else if (node.control === 'select' && node.multiple === true && Array.isArray(items)) { controlHeight = (items.length * 16) + 14; } else if ((node.control === 'checkbox' || node.control === 'radio') && Array.isArray(items)) { const direction = node.direction || 'column'; if (direction === 'column') { controlHeight = items.length * 25; } else { // row controlHeight = 25; } } step += controlHeight + 20; // Add control height and bottom margin defaultY += step; } return renderFormControl(node, parent, y); } if (node.type === 'list') { const y = node.y ?? defaultY; if (node.y === undefined) { let step = listYStep; if (Array.isArray(node.items)) { const direction = node.direction || 'column'; if (direction === 'column') { step = (node.items.length * 20) + 20; } } defaultY += step; } return renderList(node, parent, y); } if (node.type === 'image') { const y = node.y ?? defaultY; if (node.y === undefined) { let step = node.height ? node.height + 20 : imageYStep; defaultY += step; } return renderImage(node, parent, y); } if (node.type === 'table') { const y = node.y ?? defaultY; if (node.y === undefined) { defaultY += tableYStep; } return renderTable(node, parent, y); } if (node.type === 'tabs') { const y = node.y ?? defaultY; if (node.y === undefined) { const tabHeight = 40; // タブヘッダーの高さ const tabMargin = 20; // 次のコンポーネントとのマージン defaultY += tabHeight + tabMargin; } return renderTabs(node, parent, y); } if (node.type === 'badge') { const y = node.y ?? defaultY; if (node.y === undefined) { const badgeHeight = 25; // バッジの高さ const badgeMargin = 15; // 次のコンポーネントとのマージン defaultY += badgeHeight + badgeMargin; } return renderBadge(node, parent, y); } if (node.type === 'hr') { const y = node.y ?? defaultY; if (node.y === undefined) { defaultY += 20; // hrの高さ分を追加 } return renderHR(node, parent, y); } if (node.type === 'spacer') { const height = node.height || 16; // デフォルトの高さ if (node.y === undefined) { defaultY += height; } return renderSpacer(node, parent); } return ''; }).join('\\n'); } function renderContainer(node, parent, y) { const x = node.x || 0; const width = node.width ?? (parent.width - x); const height = node.height || 100; const color = node.color || 'transparent'; const childrenSvg = renderElements(node.children, { width, height }); return ` <g transform="translate(${x}, ${y})"> <rect class="container" x="0" y="0" width="${width}" height="${height}" fill="${escapeXml(color)}" /> ${childrenSvg} </g> `; } function renderHeadline(node, x, y) { const level = node.level || 2; const color = node.color || '#333333'; let fontSize, fontWeight; switch (level) { case 1: fontSize = 24; fontWeight = 'bold'; break; case 2: fontSize = 18; fontWeight = 'bold'; break; default: fontSize = 14; fontWeight = 'normal'; } return ` <text class="headline" x="${x}" y="${y}" font-size="${fontSize}" font-weight="${fontWeight}" fill="${escapeXml(color)}" text-anchor="middle"> ${escapeXml(node.value)} </text> `; } function renderParagraph(node, parent, y) { const align = node.align || 'left'; let x; let textAnchor; switch (align) { case 'center': x = parent.width / 2 + (node.x || 0); textAnchor = 'middle'; break; case 'right': x = parent.width + (node.x || 0); textAnchor = 'end'; break; case 'left': default: x = 0 + (node.x || 0); textAnchor = 'start'; break; } const color = node.color || '#333333'; const fontSize = 14; const fontWeight = 'normal'; const width = node.width; let textContent; let textX = x; if (width) { textX = 0; // x is applied to tspan, not the text element const lines = getWrappedLines(node.value, width, fontSize); textContent = lines.map((line, index) => { const dy = index === 0 ? 0 : '1.2em'; return `<tspan x="${x}" dy="${dy}">${escapeXml(line)}</tspan>`; }).join(''); } else { textContent = escapeXml(node.value); } return ` <text class="paragraph" x="${textX}" y="${y}" font-size="${fontSize}" font-weight="${fontWeight}" fill="${escapeXml(color)}" text-anchor="${textAnchor}"> ${textContent} </text> `; } function renderButton(node, parent, y) { const align = node.align || 'left'; const padding = 20; const height = 30; const fontSize = 14; // Note: This is a simplified text width calculation const textLength = node.value.length * fontSize * 0.6; const width = node.width || textLength + padding * 2; let rectX; switch (align) { case 'center': rectX = (parent.width / 2) - (width / 2) + (node.x || 0); break; case 'right': rectX = parent.width - width + (node.x || 0); break; case 'left': default: rectX = 0 + (node.x || 0); break; } const color = node.color || '#cccccc'; const textColor = node.textColor || '#000000'; const textX = rectX + width / 2; return ` <g transform="translate(0, ${y})"> <rect x="${rectX}" y="-${height/2}" width="${width}" height="${height}" fill="${escapeXml(color)}" rx="5" /> <text class="button" x="${textX}" y="0" font-size="${fontSize}" fill="${escapeXml(textColor)}" text-anchor="middle" dominant-baseline="middle"> ${escapeXml(node.value)} </text> </g> `; } function renderFormControl(node, parent, y) { const x = node.x ?? 20; const label = node.label || ''; const labelPosition = node.position || 'top'; const labelWidth = 120; let controlX = x; // --- Calculate control height first --- let controlHeight = 30; // default let items = node.items; const direction = node.direction || 'column'; if (node.control === 'textbox' && node.rows > 1) { controlHeight = (node.rows * 16) + 14; } else if (node.control === 'select' && node.multiple === true && Array.isArray(items)) { controlHeight = (items.length * 16) + 14; } else if ((node.control === 'checkbox' || node.control === 'radio') && Array.isArray(items)) { if (direction === 'column') { controlHeight = items.length * 25; } else { controlHeight = 25; // height of a single row } } if (label && labelPosition === 'left' && (node.control === 'textbox' || node.control === 'select' || node.control === 'calendar')) { controlX = x + labelWidth; } const width = node.width ?? ((node.control === 'textbox' || node.control === 'select' || node.control === 'calendar') ? parent.width - controlX - 20 : null); let labelSvg = ''; const labelHeight = 18; // font-size + margin let controlTopY = y; // The top Y-coord for the control part // --- Draw label at the top --- if (label) { if (labelPosition === 'left') { const labelY = y + controlHeight / 2; labelSvg = `<text class="form-label" x="${x}" y="${labelY}" dominant-baseline="middle">${escapeXml(label)}</text>`; controlTopY = y; } else { // top const labelTextY = y + 12; // 12 is font-size labelSvg = `<text class="form-label" x="${x}" y="${labelTextY}">${escapeXml(label)}</text>`; controlTopY = y + labelHeight; } } let controlSvg = ''; switch (node.control) { case 'textbox': { const rectY = label ? controlTopY : y - controlHeight / 2; controlSvg = `<rect class="form-control" x="${controlX}" y="${rectY}" width="${width}" height="${controlHeight}" fill="#fff" />`; if (node.placeholder) { const placeholderY = rectY + controlHeight / 2; controlSvg += ` <text class="placeholder-text" x="${controlX + 10}" y="${placeholderY}" dominant-baseline="middle"> ${escapeXml(node.placeholder)} </text> `; } break; } case 'select': if (node.multiple === true && Array.isArray(items)) { controlSvg = `<rect class="form-control" x="${controlX}" y="${controlTopY}" width="${width}" height="${controlHeight}" fill="#fff" />`; let listY = controlTopY + 18; items.forEach(item => { controlSvg += `<text class="form-label" x="${controlX + 10}" y="${listY}">${escapeXml(item)}</text>`; listY += 16; }); } else { const rectY = label ? controlTopY : y - controlHeight / 2; const controlCenterY = rectY + controlHeight / 2; const arrowPath = `M ${controlX + width - 20} ${controlCenterY - 5} l 5 10 l 5 -10 z`; controlSvg = ` <rect class="form-control" x="${controlX}" y="${rectY}" width="${width}" height="${controlHeight}" fill="#fff" /> <path d="${arrowPath}" fill="#333" /> `; } break; case 'calendar': { const rectY = label ? controlTopY : y - controlHeight / 2; const placeholder = node.placeholder ?? 'YYYY-MM-DD'; const placeholderY = rectY + controlHeight / 2; const iconWidth = 14; const iconHeight = 14; const iconX = controlX + width - iconWidth - 8; const iconY = rectY + (controlHeight / 2) - (iconHeight / 2); // Simple calendar icon const iconPath = ` M ${iconX} ${iconY+2} h ${iconWidth} v ${iconHeight-2} h -${iconWidth} z M ${iconX} ${iconY+5} h ${iconWidth} M ${iconX+3} ${iconY} v 4 M ${iconX+iconWidth-3} ${iconY} v 4 `; controlSvg = ` <rect class="form-control" x="${controlX}" y="${rectY}" width="${width}" height="${controlHeight}" fill="#fff" /> <path d="${iconPath}" stroke="#333" stroke-width="1.5" fill="none" /> <text class="placeholder-text" x="${controlX + 10}" y="${placeholderY}" dominant-baseline="middle"> ${escapeXml(placeholder)} </text> `; break; } case 'checkbox': case 'radio': if (Array.isArray(items)) { const itemHeight = 25; const itemWidth = 100; const checkedValue = node.value; items.forEach((item, index) => { let itemX = controlX; let itemCenterY = controlTopY + itemHeight / 2; if (direction === 'column') { itemCenterY = controlTopY + (index * itemHeight) + (itemHeight / 2); } else { // row itemX = controlX + (index * itemWidth); } const isChecked = Array.isArray(checkedValue) ? checkedValue.includes(item) : checkedValue === item; if (node.control === 'checkbox') { const checkWidth = 16; const checkX = itemX; const checkY = itemCenterY - checkWidth/2; controlSvg += `<rect class="form-control" x="${checkX}" y="${checkY}" width="${checkWidth}" height="${checkWidth}" fill="#fff" />`; if (isChecked) { const checkMarkPath = `M ${checkX+4} ${checkY+8} l 3 3 l 6 -6`; controlSvg += `<path d="${checkMarkPath}" stroke="#333" stroke-width="2" fill="none" />`; } } else { const radioRadius = 8; controlSvg += `<circle class="form-control" cx="${itemX + radioRadius}" cy="${itemCenterY}" r="${radioRadius}" fill="#fff" />`; if (isChecked) { controlSvg += `<circle cx="${itemX + radioRadius}" cy="${itemCenterY}" r="${radioRadius/2}" fill="#333" />`; } } controlSvg += ` <text class="form-label" x="${itemX + 25}" y="${itemCenterY}" dominant-baseline="middle"> ${escapeXml(item)} </text> `; }); } else { // single checkbox/radio const controlCenterY = y; const controlX = parent.width / 2; if (node.control === 'checkbox') { const checkWidth = 16; const checkX = controlX - (checkWidth / 2); const checkY = controlCenterY - (checkWidth / 2); controlSvg = `<rect class="form-control" x="${checkX}" y="${checkY}" width="${checkWidth}" height="${checkWidth}" fill="#fff" />`; if (node.value === true) { const checkMarkPath = `M ${checkX + 3} ${checkY + 8} l 4 4 l 8 -8`; controlSvg += `<path d="${checkMarkPath}" stroke="#333" stroke-width="2" fill="none" />`; } } else { const radioRadius = 10; controlSvg = `<circle class="form-control" cx="${controlX}" cy="${controlCenterY}" r="${radioRadius}" fill="#fff" />`; } } break; } return ` <g> ${labelSvg} ${controlSvg} </g> `; } function renderList(node, parent, y) { const x = node.x ?? 20; const items = node.items || []; const direction = node.direction || 'column'; const listColor = node.listColor || '#333'; const textColor = node.color || '#333'; const itemHeight = 22; // Increased to accommodate larger font const itemWidth = 120; const bulletRadius = 3; let listSvg = ''; items.forEach((item, index) => { let itemX = x; let itemY = y; if (direction === 'column') { itemY = y + (index * itemHeight); } else { // row itemX = x + (index * itemWidth); } listSvg += ` <circle cx="${itemX}" cy="${itemY}" r="${bulletRadius}" fill="${listColor}" /> <text class="list-item-text" x="${itemX + 10}" y="${itemY}" fill="${textColor}" dominant-baseline="middle"> ${escapeXml(item)} </text> `; }); return `<g>${listSvg}</g>`; } function renderImage(node, parent, y) { const x = node.x ?? 20; const width = node.width || 100; const height = node.height || 100; const color = node.color || '#ccc'; const altText = node.alt || ''; let textSvg = ''; if (altText) { const textX = width / 2; const textY = height / 2; const textFontSize = 14; // Estimate text width to create a background rect const estimatedTextWidth = altText.length * textFontSize * 0.6; const padding = 10; const bgWidth = estimatedTextWidth + padding * 2; const bgHeight = textFontSize + padding; textSvg = ` <rect x="${textX - bgWidth / 2}" y="${textY - bgHeight / 2}" width="${bgWidth}" height="${bgHeight}" fill="#ffffff" opacity="0.8" /> <text x="${textX}" y="${textY}" text-anchor="middle" dominant-baseline="middle" font-size="${textFontSize}" fill="#333"> ${escapeXml(altText)} </text> `; } return ` <g transform="translate(${x}, ${y})"> <rect x="0" y="0" width="${width}" height="${height}" fill="#f9f9f9" stroke="${color}" stroke-width="1" /> <line x1="0" y1="0" x2="${width}" y2="${height}" stroke="${color}" stroke-width="1" /> <line x1="0" y1="${height}" x2="${width}" y2="0" stroke="${color}" stroke-width="1" /> ${textSvg} </g> `; } function renderTable(node, parent, y) { const x = node.x ?? 20; const tableWidth = node.width ?? parent.width - (x * 2); const rows = node.rows || []; const headers = node.headers || []; const columnCount = headers.length || (rows[0] ? rows[0].length : 0); if (columnCount === 0) return ''; const columnWidths = node.widths || Array(columnCount).fill(tableWidth / columnCount); const rowHeight = 40; let tableSvg = ''; let currentY = y; // Draw Header if (headers.length > 0) { let currentX = x; headers.forEach((header, i) => { tableSvg += ` <rect x="${currentX}" y="${currentY}" width="${columnWidths[i]}" height="${rowHeight}" fill="#f0f0f0" stroke="#ccc" /> <text class="table-header" x="${currentX + columnWidths[i] / 2}" y="${currentY + rowHeight / 2}" text-anchor="middle" dominant-baseline="middle"> ${escapeXml(header)} </text> `; currentX += columnWidths[i]; }); currentY += rowHeight; } // Draw Rows rows.forEach(row => { let currentX = x; row.forEach((cell, i) => { const cellWidth = columnWidths[i]; const cellX = currentX; const cellY = currentY; let cellContentSvg = ''; // A cell is like a mini parent container for the element inside const cellParent = { width: cellWidth, height: rowHeight }; // We need to translate the element rendering into the cell's coordinate system. // The render functions return SVG content, so we wrap it in a transform group. let elementSvg = ''; switch (cell.type) { case 'button': const btn = renderButton({ ...cell, align: cell.align || 'center' }, cellParent, rowHeight / 2); elementSvg = btn; break; case 'checkbox': const chk = renderFormControl({ control: 'checkbox', ...cell }, cellParent, rowHeight / 2); elementSvg = chk; break; case 'textbox': const txt = renderFormControl({ control: 'textbox', ...cell }, cellParent, rowHeight / 2); elementSvg = txt; break; case 'text': default: { const textColor = cell.color || '#333'; const align = cell.align || 'left'; let textX; let textAnchor; const padding = 10; switch(align) { case 'center': textX = columnWidths[i] / 2; textAnchor = 'middle'; break; case 'right': textX = columnWidths[i] - padding; textAnchor = 'end'; break; case 'left': default: textX = padding; textAnchor = 'start'; break; } elementSvg = `<text class="table-cell" x="${textX}" y="${rowHeight / 2}" dominant-baseline="middle" fill="${textColor}" text-anchor="${textAnchor}">${escapeXml(cell.value || '')}</text>`; break; } } tableSvg += ` <rect x="${cellX}" y="${cellY}" width="${cellWidth}" height="${rowHeight}" fill="#fff" stroke="#ccc" /> <g transform="translate(${cellX}, ${cellY})"> ${elementSvg} </g> `; currentX += cellWidth; }); currentY += rowHeight; }); return `<g>${tableSvg}</g>`; } function renderHR(node, parent, y) { const x = node.x ?? 20; const width = node.width ?? (parent.width - x * 2); const color = node.color || '#cccccc'; const strokeWidth = node.strokeWidth || 1; return ` <line x1="${x}" y1="${y}" x2="${x + width}" y2="${y}" stroke="${escapeXml(color)}" stroke-width="${strokeWidth}" /> `; } function renderSpacer(node, parent) { // スペーサーは視覚的な要素を持たず、レイアウトスペースのみを提供する return ''; } function renderTabs(node, parent, y) { const x = node.x ?? 20; const width = node.width ?? (parent.width - x * 2); const items = node.items || []; const active = node.active ?? 0; const tabHeight = 40; const tabWidth = width / Math.max(items.length, 1); let tabsSvg = ''; // タブヘッダーの描画 items.forEach((item, index) => { const tabX = x + (index * tabWidth); const isActive = index === active; const bgColor = isActive ? '#ffffff' : '#f0f0f0'; const textColor = isActive ? '#333333' : '#666666'; const borderColor = '#cccccc'; tabsSvg += ` <rect x="${tabX}" y="${y}" width="${tabWidth}" height="${tabHeight}" fill="${bgColor}" stroke="${borderColor}" stroke-width="1" /> <text x="${tabX + tabWidth / 2}" y="${y + tabHeight / 2}" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif" font-size="14" fill="${textColor}"> ${escapeXml(item)} </text> `; }); // タブのボトムライン(アクティブタブの下は線を消す) if (items.length > 0) { const activeTabX = x + (active * tabWidth); // アクティブタブの左側の線 if (activeTabX > x) { tabsSvg += ` <line x1="${x}" y1="${y + tabHeight}" x2="${activeTabX}" y2="${y + tabHeight}" stroke="#cccccc" stroke-width="1" /> `; } // アクティブタブの右側の線 const activeTabEndX = activeTabX + tabWidth; if (activeTabEndX < x + width) { tabsSvg += ` <line x1="${activeTabEndX}" y1="${y + tabHeight}" x2="${x + width}" y2="${y + tabHeight}" stroke="#cccccc" stroke-width="1" /> `; } } return `<g>${tabsSvg}</g>`; } function renderBadge(node, parent, y) { const x = node.x ?? 20; const text = node.value || ''; const bgColor = node.color || '#007bff'; const textColor = node.textColor || '#ffffff'; const borderRadius = node.borderRadius ?? 12; const fontSize = 12; const padding = 8; // テキストの幅を推定(フォントサイズの0.6倍 × 文字数) const estimatedTextWidth = text.length * fontSize * 0.6; const width = node.width || (estimatedTextWidth + padding * 2); const height = node.height || 20; const centerX = x + width / 2; const centerY = y + height / 2; return ` <rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXml(bgColor)}" rx="${borderRadius}" ry="${borderRadius}" /> <text x="${centerX}" y="${centerY}" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif" font-size="${fontSize}" font-weight="bold" fill="${escapeXml(textColor)}"> ${escapeXml(text)} </text> `; } function getWrappedLines(text, width, fontSize) { // NOTE: This is a simplified text wrapping logic using an average character width. // It may not be perfectly accurate for all fonts and characters. const avgCharWidth = fontSize * 0.6; const words = text.split(/\s+/); const lines = []; let currentLine = ''; for (const word of words) { if (currentLine === '') { currentLine = word; } else { let testLine = currentLine + ' ' + word; if (testLine.length * avgCharWidth > width) { lines.push(currentLine); currentLine = word; } else { currentLine = testLine; } } } if (currentLine) { lines.push(currentLine); } return lines; } function escapeXml(str) { return String(str).replace(/[&<>"']/g, function (s) { switch (s) { case '&': return '&amp;'; case '<': return '&lt;'; case '>': return '&gt;'; case '"': return '&quot;'; case "'": return '&apos;'; } }); } module.exports = astToSvg;