From 53055f2a03d08096fb512c25503f0ac9702385ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:30:49 +0000 Subject: [PATCH 1/2] Initial plan From 04ac2aa0acff8e7d95a6bf6d55f21f2619125986 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:39:13 +0000 Subject: [PATCH 2/2] feat: add fig-to-pencil converter (CLI command + tests) Co-authored-by: NishikantaRay <62615392+NishikantaRay@users.noreply.github.com> Agent-Logs-Url: https://github.com/NishikantaRay/renderer/sessions/eae1d39a-b4a2-446d-b527-5cb5eee03ec2 --- bin/renderer.js | 27 +++ js/fig-to-pencil.js | 377 ++++++++++++++++++++++++++++++ package.json | 4 +- tests/fig-to-pencil.test.js | 450 ++++++++++++++++++++++++++++++++++++ 4 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 js/fig-to-pencil.js create mode 100644 tests/fig-to-pencil.test.js diff --git a/bin/renderer.js b/bin/renderer.js index a8afff5..7f65fb2 100755 --- a/bin/renderer.js +++ b/bin/renderer.js @@ -22,6 +22,7 @@ const COMMANDS = { migrate: 'Migrate from other frameworks', serve: 'Start a development server', build: 'Build optimized production files', + convert: 'Convert a Figma export (.fig) to a Pencil mockup file (.pencil)', help: 'Show help information' }; @@ -381,6 +382,19 @@ async function serve(options = {}) { } } +// Command: convert (fig → pencil) +async function convertFigToPencil(inputPath, outputPath) { + const { FigToPencilConverter } = require('../js/fig-to-pencil'); + const converter = new FigToPencilConverter(); + + log('\nšŸ”„ Converting Figma export to Pencil format...', 'bright'); + info(`Input: ${inputPath}`); + info(`Output: ${outputPath}`); + + const result = await converter.convert(inputPath, outputPath); + success(`Conversion complete! ${result.pages} page(s) written to ${result.outputPath}`); +} + // Command: build async function build(options = {}) { log('\nšŸ”Ø Building for production...', 'bright'); @@ -613,6 +627,7 @@ function showHelp() { log(' renderer validate', 'yellow'); log(' renderer deploy netlify', 'yellow'); log(' renderer serve --port 3000', 'yellow'); + log(' renderer convert design.fig design.pencil', 'yellow'); } // Main CLI handler @@ -680,6 +695,18 @@ async function main() { await build(); break; + case 'convert': { + if (!subArgs[0]) { + error('Please provide the path to a .fig file'); + log('Usage: renderer convert [output.pencil]'); + process.exit(1); + } + const figInput = subArgs[0]; + const pencilOutput = subArgs[1] || figInput.replace(/\.fig$/i, '') + '.pencil'; + await convertFigToPencil(figInput, pencilOutput); + break; + } + default: error(`Unknown command: ${command}`); log('Run "renderer help" for available commands'); diff --git a/js/fig-to-pencil.js b/js/fig-to-pencil.js new file mode 100644 index 0000000..fea5534 --- /dev/null +++ b/js/fig-to-pencil.js @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +/** + * fig-to-pencil.js + * + * Converts a Figma JSON export (.fig) to a Pencil 3.x mockup file (.pencil). + * + * Figma source format: + * A Figma API JSON export whose top-level document node contains one or more + * CANVAS children (pages), each holding a tree of design nodes. + * + * Pencil target format: + * A ZIP archive (renamed to .pencil) containing one XML file per Figma page + * in a `pages/` directory, using Pencil 3.x's SVG-based XML schema. + * + * Usage (Node API): + * const { FigToPencilConverter } = require('./fig-to-pencil'); + * const converter = new FigToPencilConverter(); + * await converter.convert('design.fig', 'design.pencil'); + * + * Usage (CLI, via bin/renderer.js): + * renderer convert design.fig design.pencil + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Convert a Figma RGBA colour object { r, g, b, a } (each 0-1) to a CSS hex + * string like "#rrggbb". + * @param {{ r: number, g: number, b: number, a?: number }} color + * @returns {string} + */ +function figmaColorToHex(color) { + if (!color) return '#000000'; + const r = Math.round((color.r || 0) * 255); + const g = Math.round((color.g || 0) * 255); + const b = Math.round((color.b || 0) * 255); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +/** + * Extract the primary solid fill color from a Figma fills array. + * Returns null when there is no solid fill. + * @param {Array} fills + * @returns {string|null} + */ +function extractFillColor(fills) { + if (!Array.isArray(fills)) return null; + for (const fill of fills) { + if (fill.type === 'SOLID' && fill.color) { + return figmaColorToHex(fill.color); + } + } + return null; +} + +/** + * Extract the primary stroke color from a Figma strokes array. + * @param {Array} strokes + * @returns {string|null} + */ +function extractStrokeColor(strokes) { + if (!Array.isArray(strokes)) return null; + for (const stroke of strokes) { + if (stroke.type === 'SOLID' && stroke.color) { + return figmaColorToHex(stroke.color); + } + } + return null; +} + +/** + * Escape a string so it is safe to embed in XML. + * @param {string} text + * @returns {string} + */ +function escapeXml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Generate a short deterministic id from a Figma node id. + * Figma ids can contain colons which are illegal in XML ids. + * @param {string} figmaId + * @returns {string} + */ +function sanitiseId(figmaId) { + return 'n-' + String(figmaId).replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +// --------------------------------------------------------------------------- +// SVG element builders (all coordinate-safe with defaults) +// --------------------------------------------------------------------------- + +function buildRect(node, x, y, w, h, fill, stroke, strokeWeight) { + const rx = node.cornerRadius ? ` rx="${node.cornerRadius}"` : ''; + return ``; +} + +function buildEllipse(node, x, y, w, h, fill, stroke, strokeWeight) { + const cx = x + w / 2; + const cy = y + h / 2; + const rx = w / 2; + const ry = h / 2; + return ``; +} + +function buildLine(node, x, y, w, h, stroke, strokeWeight) { + return ``; +} + +function buildText(node, x, y, w, h, fill) { + const fontSize = (node.style && node.style.fontSize) ? node.style.fontSize : 14; + const fontFamily = (node.style && node.style.fontFamily) ? node.style.fontFamily : 'Arial'; + const text = escapeXml(node.characters || ''); + return `${text}`; +} + +function buildGroup(node, x, y, innerSvg) { + return `${innerSvg}`; +} + +// --------------------------------------------------------------------------- +// Node converter +// --------------------------------------------------------------------------- + +/** + * Recursively convert a Figma node tree to SVG element strings. + * `offsetX` / `offsetY` are the parent's absolute origin so that child + * coordinates are expressed relative to the nearest page (CANVAS) boundary. + * + * @param {object} node - Figma node object + * @param {number} offsetX - parent absolute X + * @param {number} offsetY - parent absolute Y + * @returns {string} SVG fragment + */ +function convertNode(node, offsetX, offsetY) { + if (!node || !node.type) return ''; + + const bb = node.absoluteBoundingBox || {}; + const x = (bb.x != null ? bb.x : 0) - offsetX; + const y = (bb.y != null ? bb.y : 0) - offsetY; + const w = bb.width != null ? bb.width : 0; + const h = bb.height != null ? bb.height : 0; + + const fill = extractFillColor(node.fills); + const stroke = extractStrokeColor(node.strokes); + const strokeWeight = node.strokeWeight || 1; + + switch (node.type) { + case 'RECTANGLE': + case 'SECTION': + return buildRect(node, x, y, w, h, fill, stroke, strokeWeight); + + case 'ELLIPSE': + return buildEllipse(node, x, y, w, h, fill, stroke, strokeWeight); + + case 'LINE': + return buildLine(node, x, y, w, h, stroke, strokeWeight); + + case 'TEXT': + return buildText(node, x, y, w, h, fill); + + case 'VECTOR': + case 'STAR': + case 'POLYGON': + case 'BOOLEAN_OPERATION': { + // Fall back to bounding rectangle when path data is unavailable + return buildRect(node, x, y, w, h, fill, stroke, strokeWeight); + } + + case 'FRAME': + case 'COMPONENT': + case 'COMPONENT_SET': + case 'INSTANCE': + case 'GROUP': { + const children = (node.children || []) + .map(child => convertNode(child, offsetX, offsetY)) + .join('\n '); + const frameFill = fill ? `` : ''; + return `${frameFill}\n ${children}`; + } + + default: + // Unknown / unsupported node – silently skip + return ''; + } +} + +// --------------------------------------------------------------------------- +// Pencil XML builder +// --------------------------------------------------------------------------- + +/** + * Build the Pencil XML string for a single page. + * + * @param {object} canvas - Figma CANVAS node + * @param {number} pageIndex - zero-based page index (used to derive the id) + * @returns {string} + */ +function buildPencilPageXml(canvas, pageIndex) { + const pageId = `page-${String(pageIndex + 1).padStart(3, '0')}`; + const pageName = escapeXml(canvas.name || `Page ${pageIndex + 1}`); + + // Determine page dimensions from the first child frame, or default to 1024Ɨ768 + let pageWidth = 1024; + let pageHeight = 768; + const firstFrame = (canvas.children || []).find( + c => c.absoluteBoundingBox && (c.type === 'FRAME' || c.type === 'SECTION' || c.type === 'GROUP') + ); + if (firstFrame && firstFrame.absoluteBoundingBox) { + pageWidth = Math.round(firstFrame.absoluteBoundingBox.width) || pageWidth; + pageHeight = Math.round(firstFrame.absoluteBoundingBox.height) || pageHeight; + } + + // Use the canvas top-left as the SVG origin + const originX = firstFrame ? (firstFrame.absoluteBoundingBox.x || 0) : 0; + const originY = firstFrame ? (firstFrame.absoluteBoundingBox.y || 0) : 0; + + const shapeSvg = (canvas.children || []) + .map(child => convertNode(child, originX, originY)) + .filter(Boolean) + .join('\n '); + + return ` + + + + + ${shapeSvg} + + + + +`; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +class FigToPencilConverter { + /** + * Convert a Figma JSON export file (.fig) to a Pencil 3.x mockup file (.pencil). + * + * @param {string} inputPath - Path to the Figma JSON export file + * @param {string} outputPath - Path for the generated .pencil file + * @returns {Promise<{ pages: number, outputPath: string }>} + */ + async convert(inputPath, outputPath) { + // 1. Read & parse the Figma JSON export + const figData = this.parseFigFile(inputPath); + + // 2. Collect CANVAS nodes (Figma "pages") + const canvases = this.extractCanvases(figData); + if (canvases.length === 0) { + throw new Error('No pages (CANVAS nodes) found in the Figma file.'); + } + + // 3. Build per-page Pencil XML + const pageXmls = canvases.map((canvas, idx) => ({ + name: `page_${idx + 1}.xml`, + content: buildPencilPageXml(canvas, idx) + })); + + // 4. Package into a .pencil ZIP archive + const resolvedOutput = path.resolve(outputPath); + this.createPencilZip(pageXmls, resolvedOutput); + + return { pages: canvases.length, outputPath: resolvedOutput }; + } + + /** + * Read and JSON-parse a .fig file. + * Figma API exports are UTF-8 JSON; binary .fig files are not supported. + * + * @param {string} filePath + * @returns {object} + */ + parseFigFile(filePath) { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + throw new Error(`Input file not found: ${resolved}`); + } + + const raw = fs.readFileSync(resolved); + + // Detect binary .fig files (they start with a non-printable magic byte) + if (raw.length > 0 && raw[0] < 0x20 && raw[0] !== 0x09 && raw[0] !== 0x0a && raw[0] !== 0x0d) { + throw new Error( + 'Binary .fig files are not directly supported.\n' + + 'Please export your Figma design as JSON via the Figma REST API\n' + + '(GET /v1/files/:file_key) and save the result as a .fig file.' + ); + } + + try { + return JSON.parse(raw.toString('utf8')); + } catch (err) { + throw new Error(`Failed to parse .fig file as JSON: ${err.message}`); + } + } + + /** + * Extract all CANVAS nodes from a Figma document. + * + * @param {object} figData + * @returns {object[]} + */ + extractCanvases(figData) { + // Support both a bare DOCUMENT node and the full API response + const document = figData.document || figData; + if (!document) return []; + + if (document.type === 'CANVAS') return [document]; + + const children = document.children || []; + // A DOCUMENT's direct children are CANVAS nodes (pages) + return children.filter(c => c && c.type === 'CANVAS'); + } + + /** + * Write page XML files into a temporary directory and zip them into the + * target `.pencil` file using the system `zip` utility. + * + * @param {{ name: string, content: string }[]} pageXmls + * @param {string} outputPath + */ + createPencilZip(pageXmls, outputPath) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fig-to-pencil-')); + try { + // Create pages/ sub-directory + const pagesDir = path.join(tmpDir, 'pages'); + fs.mkdirSync(pagesDir); + + // Write each page XML + for (const page of pageXmls) { + fs.writeFileSync(path.join(pagesDir, page.name), page.content, 'utf8'); + } + + // Remove output file if it already exists so zip doesn't append + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + + // Create the ZIP archive (the `zip` binary ships with most Unix systems) + execSync(`cd "${tmpDir}" && zip -r "${outputPath}" pages/`, { stdio: 'pipe' }); + } finally { + // Clean up temp directory + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } +} + +module.exports = { FigToPencilConverter, buildPencilPageXml, convertNode }; diff --git a/package.json b/package.json index 3c40cfa..97158a1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "start": "npx serve -l 3000", "serve": "node bin/renderer.js serve", "build": "node bin/renderer.js build", - "validate": "node bin/renderer.js validate" + "validate": "node bin/renderer.js validate", + "convert": "node bin/renderer.js convert", + "test": "node tests/fig-to-pencil.test.js" }, "keywords": [ "portfolio", diff --git a/tests/fig-to-pencil.test.js b/tests/fig-to-pencil.test.js new file mode 100644 index 0000000..329baae --- /dev/null +++ b/tests/fig-to-pencil.test.js @@ -0,0 +1,450 @@ +/** + * Tests for js/fig-to-pencil.js + * + * Run with: node tests/fig-to-pencil.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + FigToPencilConverter, + buildPencilPageXml, + convertNode +} = require('../js/fig-to-pencil'); + +// --------------------------------------------------------------------------- +// Minimal Figma JSON fixture +// --------------------------------------------------------------------------- + +const SIMPLE_FIGMA_DOC = { + document: { + id: '0:0', + name: 'Document', + type: 'DOCUMENT', + children: [ + { + id: '1:0', + name: 'Page 1', + type: 'CANVAS', + backgroundColor: { r: 1, g: 1, b: 1, a: 1 }, + children: [ + { + id: '2:0', + name: 'Background', + type: 'RECTANGLE', + absoluteBoundingBox: { x: 0, y: 0, width: 800, height: 600 }, + fills: [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9, a: 1 } }], + strokes: [], + strokeWeight: 0, + opacity: 1 + }, + { + id: '3:0', + name: 'Headline', + type: 'TEXT', + absoluteBoundingBox: { x: 50, y: 50, width: 300, height: 40 }, + characters: 'Hello, Pencil!', + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 } }], + style: { fontSize: 24, fontFamily: 'Inter' }, + opacity: 1 + }, + { + id: '4:0', + name: 'Button', + type: 'FRAME', + absoluteBoundingBox: { x: 50, y: 120, width: 120, height: 40 }, + fills: [{ type: 'SOLID', color: { r: 0.2, g: 0.5, b: 1, a: 1 } }], + strokes: [], + strokeWeight: 1, + opacity: 0.9, + children: [ + { + id: '5:0', + name: 'Label', + type: 'TEXT', + absoluteBoundingBox: { x: 60, y: 128, width: 100, height: 24 }, + characters: 'Click me', + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 } }], + style: { fontSize: 14, fontFamily: 'Inter' }, + opacity: 1 + } + ] + }, + { + id: '6:0', + name: 'Circle', + type: 'ELLIPSE', + absoluteBoundingBox: { x: 200, y: 200, width: 80, height: 80 }, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 } }], + strokes: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 } }], + strokeWeight: 2, + opacity: 1 + } + ] + }, + { + id: '7:0', + name: 'Page 2', + type: 'CANVAS', + backgroundColor: { r: 1, g: 1, b: 1, a: 1 }, + children: [] + } + ] + }, + name: 'Test Design', + lastModified: '2024-01-01T00:00:00Z', + version: '1' +}; + +// --------------------------------------------------------------------------- +// Helper – write a temp .fig file +// --------------------------------------------------------------------------- + +function writeTempFig(data) { + const tmpFile = path.join(os.tmpdir(), `test-${Date.now()}.fig`); + fs.writeFileSync(tmpFile, JSON.stringify(data), 'utf8'); + return tmpFile; +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` āœ“ ${name}`); + passed++; + } catch (err) { + console.error(` āœ— ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +async function testAsync(name, fn) { + try { + await fn(); + console.log(` āœ“ ${name}`); + passed++; + } catch (err) { + console.error(` āœ— ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +console.log('\n--- Unit tests: convertNode ---\n'); + +test('RECTANGLE node emits an svg:rect element', () => { + const node = { + id: 'r1', type: 'RECTANGLE', + absoluteBoundingBox: { x: 10, y: 20, width: 100, height: 50 }, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 } }], + strokes: [], strokeWeight: 0, opacity: 1 + }; + const svg = convertNode(node, 0, 0); + assert.ok(svg.includes(' { + const node = { + id: 'e1', type: 'ELLIPSE', + absoluteBoundingBox: { x: 0, y: 0, width: 80, height: 80 }, + fills: [], strokes: [], strokeWeight: 1, opacity: 1 + }; + const svg = convertNode(node, 0, 0); + assert.ok(svg.includes(' { + const node = { + id: 't1', type: 'TEXT', + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 30 }, + characters: 'Hello World', + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 } }], + style: { fontSize: 16, fontFamily: 'Arial' }, + opacity: 1 + }; + const svg = convertNode(node, 0, 0); + assert.ok(svg.includes(' { + const node = { + id: 't2', type: 'TEXT', + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 30 }, + characters: '&"test"', + fills: [], style: {}, opacity: 1 + }; + const svg = convertNode(node, 0, 0); + assert.ok(!svg.includes(''), 'Raw < should be escaped'); + assert.ok(svg.includes('<b>'), 'Should contain escaped angle brackets'); +}); + +test('LINE node emits an svg:line element', () => { + const node = { + id: 'l1', type: 'LINE', + absoluteBoundingBox: { x: 0, y: 10, width: 100, height: 0 }, + fills: [], + strokes: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 } }], + strokeWeight: 2, opacity: 1 + }; + const svg = convertNode(node, 0, 0); + assert.ok(svg.includes(' { + const node = { + id: 'f1', type: 'FRAME', + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + fills: [{ type: 'SOLID', color: { r: 0, g: 0.5, b: 1, a: 1 } }], + strokes: [], strokeWeight: 0, opacity: 1, + children: [ + { + id: 'c1', type: 'RECTANGLE', + absoluteBoundingBox: { x: 10, y: 10, width: 50, height: 30 }, + fills: [], strokes: [], strokeWeight: 0, opacity: 1 + } + ] + }; + const svg = convertNode(node, 0, 0); + assert.ok(svg.includes('fill="#0080ff"'), 'Frame fill should be rendered'); + assert.ok(svg.includes(' { + const node = { id: 'x1', type: 'UNKNOWN_NODE', absoluteBoundingBox: { x: 0, y: 0, width: 10, height: 10 } }; + const svg = convertNode(node, 0, 0); + assert.strictEqual(svg, '', 'Unknown nodes should produce no output'); +}); + +test('VECTOR node falls back to bounding rectangle', () => { + const node = { + id: 'v1', type: 'VECTOR', + absoluteBoundingBox: { x: 0, y: 0, width: 60, height: 60 }, + fills: [], strokes: [], strokeWeight: 1, opacity: 1 + }; + const svg = convertNode(node, 0, 0); + assert.ok(svg.includes(' { + const node = { + id: 'r2', type: 'RECTANGLE', + absoluteBoundingBox: { x: 100, y: 200, width: 50, height: 30 }, + fills: [], strokes: [], strokeWeight: 0, opacity: 1 + }; + const svg = convertNode(node, 50, 100); + // x should be 100-50=50, y should be 200-100=100 + assert.ok(svg.includes('x="50"'), 'x offset should be 50'); + assert.ok(svg.includes('y="100"'), 'y offset should be 100'); +}); + +console.log('\n--- Unit tests: buildPencilPageXml ---\n'); + +test('Page XML has correct root element and namespaces', () => { + const canvas = { id: 'c1', name: 'My Page', type: 'CANVAS', children: [] }; + const xml = buildPencilPageXml(canvas, 0); + assert.ok(xml.includes(' { + const canvas = { id: 'c2', name: 'Page', type: 'CANVAS', children: [] }; + const xml = buildPencilPageXml(canvas, 2); + assert.ok(xml.includes('id="page-003"'), 'Page id should be page-003 for index 2'); +}); + +test('Page XML uses frame dimensions when available', () => { + const canvas = { + id: 'c3', name: 'Sized Page', type: 'CANVAS', + children: [{ + id: 'frame1', type: 'FRAME', + absoluteBoundingBox: { x: 0, y: 0, width: 1440, height: 900 }, + fills: [], children: [] + }] + }; + const xml = buildPencilPageXml(canvas, 0); + assert.ok(xml.includes('width="1440"'), 'Width should be 1440'); + assert.ok(xml.includes('height="900"'), 'Height should be 900'); +}); + +test('Page XML falls back to 1024Ɨ768 when no frame exists', () => { + const canvas = { id: 'c4', name: 'Empty Page', type: 'CANVAS', children: [] }; + const xml = buildPencilPageXml(canvas, 0); + assert.ok(xml.includes('width="1024"'), 'Default width should be 1024'); + assert.ok(xml.includes('height="768"'), 'Default height should be 768'); +}); + +test('Page name is XML-escaped', () => { + const canvas = { id: 'c5', name: 'Page <1> & "Test"', type: 'CANVAS', children: [] }; + const xml = buildPencilPageXml(canvas, 0); + assert.ok(!xml.includes('name="Page <1>'), 'Raw < should not appear in name attribute'); + assert.ok(xml.includes('<'), 'Escaped < should appear'); +}); + +// --------------------------------------------------------------------------- +// Integration tests: FigToPencilConverter +// --------------------------------------------------------------------------- + +console.log('\n--- Integration tests: FigToPencilConverter ---\n'); + +const converter = new FigToPencilConverter(); + +test('parseFigFile throws on missing file', () => { + assert.throws( + () => converter.parseFigFile('/nonexistent/path/file.fig'), + /Input file not found/ + ); +}); + +test('parseFigFile throws on binary .fig file', () => { + const tmpFile = path.join(os.tmpdir(), `binary-${Date.now()}.fig`); + // Write a buffer starting with a non-printable byte (simulates binary .fig) + fs.writeFileSync(tmpFile, Buffer.from([0x00, 0x01, 0x02])); + try { + assert.throws( + () => converter.parseFigFile(tmpFile), + /Binary .fig files are not directly supported/ + ); + } finally { + fs.unlinkSync(tmpFile); + } +}); + +test('parseFigFile throws on invalid JSON', () => { + const tmpFile = path.join(os.tmpdir(), `badjson-${Date.now()}.fig`); + fs.writeFileSync(tmpFile, 'this is not json', 'utf8'); + try { + assert.throws( + () => converter.parseFigFile(tmpFile), + /Failed to parse .fig file as JSON/ + ); + } finally { + fs.unlinkSync(tmpFile); + } +}); + +test('parseFigFile successfully reads a valid JSON .fig file', () => { + const tmpFile = writeTempFig(SIMPLE_FIGMA_DOC); + try { + const data = converter.parseFigFile(tmpFile); + assert.ok(data.document, 'Parsed data should have a document property'); + assert.strictEqual(data.document.type, 'DOCUMENT'); + } finally { + fs.unlinkSync(tmpFile); + } +}); + +test('extractCanvases returns CANVAS nodes from DOCUMENT', () => { + const canvases = converter.extractCanvases(SIMPLE_FIGMA_DOC); + assert.strictEqual(canvases.length, 2, 'Should extract 2 canvas pages'); + assert.strictEqual(canvases[0].name, 'Page 1'); + assert.strictEqual(canvases[1].name, 'Page 2'); +}); + +test('extractCanvases handles a bare CANVAS node', () => { + const canvas = { id: 'c1', type: 'CANVAS', name: 'Only Page', children: [] }; + const canvases = converter.extractCanvases(canvas); + assert.strictEqual(canvases.length, 1); +}); + +test('extractCanvases returns empty array for empty document', () => { + const canvases = converter.extractCanvases({ type: 'DOCUMENT', children: [] }); + assert.strictEqual(canvases.length, 0); +}); + +// --------------------------------------------------------------------------- +// Async integration tests (wrapped in main to avoid top-level await) +// --------------------------------------------------------------------------- + +async function runAsyncTests() { + console.log('\n--- Integration tests: FigToPencilConverter (async) ---\n'); + + await testAsync('convert() produces a .pencil ZIP file with correct pages', async () => { + const tmpFig = writeTempFig(SIMPLE_FIGMA_DOC); + const tmpPencil = path.join(os.tmpdir(), `out-${Date.now()}.pencil`); + try { + const result = await converter.convert(tmpFig, tmpPencil); + assert.strictEqual(result.pages, 2, 'Should report 2 pages'); + assert.ok(fs.existsSync(tmpPencil), 'Output .pencil file should exist'); + + // Verify it is a ZIP by checking the magic bytes (PK\x03\x04) + const buf = fs.readFileSync(tmpPencil); + assert.ok( + buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04, + 'Output file should start with ZIP magic bytes' + ); + } finally { + if (fs.existsSync(tmpFig)) fs.unlinkSync(tmpFig); + if (fs.existsSync(tmpPencil)) fs.unlinkSync(tmpPencil); + } + }); + + await testAsync('convert() overwrites an existing output file', async () => { + const tmpFig = writeTempFig(SIMPLE_FIGMA_DOC); + const tmpPencil = path.join(os.tmpdir(), `overwrite-${Date.now()}.pencil`); + fs.writeFileSync(tmpPencil, 'old content', 'utf8'); + try { + await converter.convert(tmpFig, tmpPencil); + const buf = fs.readFileSync(tmpPencil); + assert.ok( + buf[0] === 0x50 && buf[1] === 0x4b, + 'Overwritten file should be a valid ZIP' + ); + } finally { + if (fs.existsSync(tmpFig)) fs.unlinkSync(tmpFig); + if (fs.existsSync(tmpPencil)) fs.unlinkSync(tmpPencil); + } + }); + + await testAsync('convert() throws when no CANVAS nodes found', async () => { + const emptyDoc = { document: { id: '0', type: 'DOCUMENT', children: [] } }; + const tmpFig = writeTempFig(emptyDoc); + const tmpPencil = path.join(os.tmpdir(), `empty-${Date.now()}.pencil`); + try { + await assert.rejects( + () => converter.convert(tmpFig, tmpPencil), + /No pages .* found/ + ); + } finally { + if (fs.existsSync(tmpFig)) fs.unlinkSync(tmpFig); + if (fs.existsSync(tmpPencil)) fs.unlinkSync(tmpPencil); + } + }); + + await testAsync('convert() produces correct output at derived path', async () => { + const tmpFig = path.join(os.tmpdir(), `auto-${Date.now()}.fig`); + const expectedPencil = tmpFig.replace(/\.fig$/, '.pencil'); + fs.writeFileSync(tmpFig, JSON.stringify(SIMPLE_FIGMA_DOC), 'utf8'); + try { + const result = await converter.convert(tmpFig, expectedPencil); + assert.ok(fs.existsSync(result.outputPath), 'Output file should exist at derived path'); + } finally { + if (fs.existsSync(tmpFig)) fs.unlinkSync(tmpFig); + if (fs.existsSync(expectedPencil)) fs.unlinkSync(expectedPencil); + } + }); + + // Summary + console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`); + if (failed > 0) process.exit(1); +} + +runAsyncTests();